From faeada414b97caec51988420f6cd0ae80ac9f0a3 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:23:50 +0700 Subject: [PATCH 1/5] feat(serv): add .child() to sync logger for contextual annotations --- packages/serv/src/index.ts | 1 + packages/serv/src/logger.test.ts | 162 +++++++++++++++++++++++++++++++ packages/serv/src/logger.ts | 57 +++++++---- 3 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 packages/serv/src/logger.test.ts diff --git a/packages/serv/src/index.ts b/packages/serv/src/index.ts index 7db3d41..148e0b8 100644 --- a/packages/serv/src/index.ts +++ b/packages/serv/src/index.ts @@ -1,3 +1,4 @@ export * from './http/index.js'; export { Logger } from './logger.js'; +export type { SyncLogger } from './logger.js'; export { getPort } from './port.js'; diff --git a/packages/serv/src/logger.test.ts b/packages/serv/src/logger.test.ts new file mode 100644 index 0000000..7d0f90d --- /dev/null +++ b/packages/serv/src/logger.test.ts @@ -0,0 +1,162 @@ +import { it } from '@effect/vitest'; +import { Array, Effect, Layer, Logger as EffectLogger, LogLevel } from 'effect'; +import { describe, expect } from 'vitest'; +import { Logger } from './logger.js'; + +type CapturedEntry = { + message: string; + logLevel: LogLevel.LogLevel; + annotations: Record; +}; + +function makeTestLogger() { + const entries: Array = []; + + const logger = EffectLogger.make((options) => { + const annotations: Record = {}; + for (const [key, value] of options.annotations) { + annotations[key] = value; + } + entries.push({ + message: Array.ensure(options.message).join(' '), + logLevel: options.logLevel, + annotations, + }); + }); + + const layer = Layer.merge( + EffectLogger.replace(EffectLogger.defaultLogger, logger), + EffectLogger.minimumLogLevel(LogLevel.All), + ); + + return { entries, layer }; +} + +describe('Logger.sync', () => { + it.effect('logs without annotations', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync().pipe(Effect.provide(layer)); + log.info('hello'); + yield* Effect.yieldNow(); + expect(entries).toHaveLength(1); + expect(entries[0].message).toBe('hello'); + expect(entries[0].annotations).toEqual({}); + }), + ); + + it.effect('initial annotations from sync()', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync({ service: 'api' }).pipe( + Effect.provide(layer), + ); + log.info('started'); + yield* Effect.yieldNow(); + expect(entries[0].annotations).toEqual({ service: 'api' }); + }), + ); + + it.effect('.child() adds annotations', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync().pipe(Effect.provide(layer)); + const child = log.child({ requestId: '123' }); + child.info('handled'); + yield* Effect.yieldNow(); + expect(entries[0].annotations).toEqual({ requestId: '123' }); + }), + ); + + it.effect('.child() merges parent + child annotations', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync({ service: 'api' }).pipe( + Effect.provide(layer), + ); + const child = log.child({ requestId: '123' }); + child.info('handled'); + yield* Effect.yieldNow(); + expect(entries[0].annotations).toEqual({ + service: 'api', + requestId: '123', + }); + }), + ); + + it.effect('.child() overrides parent on key conflict', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync({ env: 'dev' }).pipe( + Effect.provide(layer), + ); + const child = log.child({ env: 'prod' }); + child.info('test'); + yield* Effect.yieldNow(); + expect(entries[0].annotations).toEqual({ env: 'prod' }); + }), + ); + + it.effect('chained .child().child() accumulates annotations', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync({ a: 1 }).pipe(Effect.provide(layer)); + const grandchild = log.child({ b: 2 }).child({ c: 3 }); + grandchild.info('deep'); + yield* Effect.yieldNow(); + expect(entries[0].annotations).toEqual({ a: 1, b: 2, c: 3 }); + }), + ); + + it.effect('parent unaffected by child creation', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const parent = yield* Logger.sync({ service: 'api' }).pipe( + Effect.provide(layer), + ); + parent.child({ requestId: '123' }); + parent.info('still parent'); + yield* Effect.yieldNow(); + expect(entries[0].annotations).toEqual({ service: 'api' }); + }), + ); + + it.effect('all log levels work on child', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync().pipe(Effect.provide(layer)); + const child = log.child({ ctx: 'test' }); + child.info('i'); + child.debug('d'); + child.warn('w'); + child.error('e'); + yield* Effect.yieldNow(); + expect(entries).toHaveLength(4); + expect(entries[0].logLevel).toBe(LogLevel.Info); + expect(entries[1].logLevel).toBe(LogLevel.Debug); + expect(entries[2].logLevel).toBe(LogLevel.Warning); + expect(entries[3].logLevel).toBe(LogLevel.Error); + for (const entry of entries) { + expect(entry.annotations).toEqual({ ctx: 'test' }); + } + }), + ); + + it.effect('per-call attributes merge with persistent annotations', () => + Effect.gen(function* () { + const { entries, layer } = makeTestLogger(); + const log = yield* Logger.sync({ service: 'api' }).pipe( + Effect.provide(layer), + ); + const child = log.child({ requestId: '123' }); + child.info({ extra: 'val' }, 'with attrs'); + yield* Effect.yieldNow(); + expect(entries[0].message).toBe('with attrs'); + expect(entries[0].annotations).toEqual({ + service: 'api', + requestId: '123', + extra: 'val', + }); + }), + ); +}); diff --git a/packages/serv/src/logger.ts b/packages/serv/src/logger.ts index 9da6459..c25156a 100644 --- a/packages/serv/src/logger.ts +++ b/packages/serv/src/logger.ts @@ -9,27 +9,48 @@ function extractParams(...[obj, msg]: LogParams) { return { message: msg, attributes: obj as Record }; } +// biome-ignore lint/suspicious/noExplicitAny: log annotations are unstructured +type LogAnnotations = Record; + +export type SyncLogger = { + info: (...params: Parameters) => void; + debug: (...params: Parameters) => void; + warn: (...params: Parameters) => void; + error: (...params: Parameters) => void; + child: (annotations: LogAnnotations) => SyncLogger; +}; + +function makeSyncLogger( + runtime: Runtime.Runtime, + annotations: LogAnnotations, +): SyncLogger { + const run = (e: Effect.Effect) => { + const annotated = + Object.keys(annotations).length > 0 + ? e.pipe(Effect.annotateLogs(annotations)) + : e; + void Runtime.runPromise(runtime)(annotated); + }; + + return { + info: (...params: Parameters) => + run(Logger.info(...params)), + debug: (...params: Parameters) => + run(Logger.debug(...params)), + warn: (...params: Parameters) => + run(Logger.warn(...params)), + error: (...params: Parameters) => + run(Logger.error(...params)), + child: (childAnnotations: LogAnnotations) => + makeSyncLogger(runtime, { ...annotations, ...childAnnotations }), + }; +} + export namespace Logger { - export const sync = () => + export const sync = (annotations?: LogAnnotations) => Effect.gen(function* () { const runtime = yield* Effect.runtime(); - - const run = (e: Effect.Effect) => { - // Intentionally ignoring the await here - // void runPromise(e); - void Runtime.runPromise(runtime)(e); - }; - - return { - info: (...params: Parameters) => - run(Logger.info(...params)), - debug: (...params: Parameters) => - run(Logger.debug(...params)), - warn: (...params: Parameters) => - run(Logger.warn(...params)), - error: (...params: Parameters) => - run(Logger.error(...params)), - }; + return makeSyncLogger(runtime, annotations ?? {}); }); // -- From e7775e9ba5005cf91e6d0aa839e5c11260801d68 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:19:51 +0700 Subject: [PATCH 2/5] chore: add changeset --- .changeset/add-sync-logger-child.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-sync-logger-child.md diff --git a/.changeset/add-sync-logger-child.md b/.changeset/add-sync-logger-child.md new file mode 100644 index 0000000..173bf47 --- /dev/null +++ b/.changeset/add-sync-logger-child.md @@ -0,0 +1,5 @@ +--- +"ff-serv": minor +--- + +Add `.child()` to sync logger for contextual annotations From be69320ef5b29ff33a8c9671efcd9cb88fe7ea3f Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:21:09 +0700 Subject: [PATCH 3/5] chore: format --- packages/serv/src/index.ts | 2 +- packages/serv/src/logger.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serv/src/index.ts b/packages/serv/src/index.ts index 148e0b8..779dd6a 100644 --- a/packages/serv/src/index.ts +++ b/packages/serv/src/index.ts @@ -1,4 +1,4 @@ export * from './http/index.js'; -export { Logger } from './logger.js'; export type { SyncLogger } from './logger.js'; +export { Logger } from './logger.js'; export { getPort } from './port.js'; diff --git a/packages/serv/src/logger.test.ts b/packages/serv/src/logger.test.ts index 7d0f90d..a3378eb 100644 --- a/packages/serv/src/logger.test.ts +++ b/packages/serv/src/logger.test.ts @@ -1,5 +1,5 @@ import { it } from '@effect/vitest'; -import { Array, Effect, Layer, Logger as EffectLogger, LogLevel } from 'effect'; +import { Array, Effect, Logger as EffectLogger, Layer, LogLevel } from 'effect'; import { describe, expect } from 'vitest'; import { Logger } from './logger.js'; From fe558878f3ccf8735ff6336fea9b5924b26b972f Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:22:06 +0700 Subject: [PATCH 4/5] chore: avoid shadowing --- packages/serv/src/logger.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serv/src/logger.test.ts b/packages/serv/src/logger.test.ts index a3378eb..3c0d2dc 100644 --- a/packages/serv/src/logger.test.ts +++ b/packages/serv/src/logger.test.ts @@ -1,5 +1,5 @@ import { it } from '@effect/vitest'; -import { Array, Effect, Logger as EffectLogger, Layer, LogLevel } from 'effect'; +import { Array as EffectArray, Effect, Logger as EffectLogger, Layer, LogLevel } from 'effect'; import { describe, expect } from 'vitest'; import { Logger } from './logger.js'; @@ -18,7 +18,7 @@ function makeTestLogger() { annotations[key] = value; } entries.push({ - message: Array.ensure(options.message).join(' '), + message: EffectArray.ensure(options.message).join(' '), logLevel: options.logLevel, annotations, }); From 0deb79b7145440d1f358eaf94276c92e6551e513 Mon Sep 17 00:00:00 2001 From: Farrel Darian <62016900+fdarian@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:22:56 +0700 Subject: [PATCH 5/5] chore: format --- packages/serv/src/logger.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/serv/src/logger.test.ts b/packages/serv/src/logger.test.ts index 3c0d2dc..7a58dda 100644 --- a/packages/serv/src/logger.test.ts +++ b/packages/serv/src/logger.test.ts @@ -1,5 +1,11 @@ import { it } from '@effect/vitest'; -import { Array as EffectArray, Effect, Logger as EffectLogger, Layer, LogLevel } from 'effect'; +import { + Effect, + Array as EffectArray, + Logger as EffectLogger, + Layer, + LogLevel, +} from 'effect'; import { describe, expect } from 'vitest'; import { Logger } from './logger.js';