Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-sync-logger-child.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ff-serv": minor
---

Add `.child()` to sync logger for contextual annotations
1 change: 1 addition & 0 deletions packages/serv/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './http/index.js';
export type { SyncLogger } from './logger.js';
export { Logger } from './logger.js';
export { getPort } from './port.js';
168 changes: 168 additions & 0 deletions packages/serv/src/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { it } from '@effect/vitest';
import {
Effect,
Array as EffectArray,
Logger as EffectLogger,
Layer,
LogLevel,
} from 'effect';
import { describe, expect } from 'vitest';
import { Logger } from './logger.js';

type CapturedEntry = {
message: string;
logLevel: LogLevel.LogLevel;
annotations: Record<string, unknown>;
};

function makeTestLogger() {
const entries: Array<CapturedEntry> = [];

const logger = EffectLogger.make((options) => {
const annotations: Record<string, unknown> = {};
for (const [key, value] of options.annotations) {
annotations[key] = value;
}
entries.push({
message: EffectArray.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',
});
}),
);
});
57 changes: 39 additions & 18 deletions packages/serv/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,48 @@ function extractParams(...[obj, msg]: LogParams) {
return { message: msg, attributes: obj as Record<string, any> };
}

// biome-ignore lint/suspicious/noExplicitAny: log annotations are unstructured
type LogAnnotations = Record<string, any>;

export type SyncLogger = {
info: (...params: Parameters<typeof Logger.info>) => void;
debug: (...params: Parameters<typeof Logger.debug>) => void;
warn: (...params: Parameters<typeof Logger.warn>) => void;
error: (...params: Parameters<typeof Logger.error>) => void;
child: (annotations: LogAnnotations) => SyncLogger;
};

function makeSyncLogger(
runtime: Runtime.Runtime<never>,
annotations: LogAnnotations,
): SyncLogger {
const run = (e: Effect.Effect<void, never, never>) => {
const annotated =
Object.keys(annotations).length > 0
? e.pipe(Effect.annotateLogs(annotations))
: e;
void Runtime.runPromise(runtime)(annotated);
};

return {
info: (...params: Parameters<typeof Logger.info>) =>
run(Logger.info(...params)),
debug: (...params: Parameters<typeof Logger.debug>) =>
run(Logger.debug(...params)),
warn: (...params: Parameters<typeof Logger.warn>) =>
run(Logger.warn(...params)),
error: (...params: Parameters<typeof Logger.error>) =>
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<void, never, never>) => {
// Intentionally ignoring the await here
// void runPromise(e);
void Runtime.runPromise(runtime)(e);
};

return {
info: (...params: Parameters<typeof Logger.info>) =>
run(Logger.info(...params)),
debug: (...params: Parameters<typeof Logger.debug>) =>
run(Logger.debug(...params)),
warn: (...params: Parameters<typeof Logger.warn>) =>
run(Logger.warn(...params)),
error: (...params: Parameters<typeof Logger.error>) =>
run(Logger.error(...params)),
};
return makeSyncLogger(runtime, annotations ?? {});
});

// --
Expand Down