diff --git a/docs/reference/api/resources.md b/docs/reference/api/resources.md index 97e015fb3..f8935e8aa 100644 --- a/docs/reference/api/resources.md +++ b/docs/reference/api/resources.md @@ -128,6 +128,9 @@ import { logger } from 'void/log'; logger.info('checkout completed', { orderId, userId }); logger.error('webhook failed', { provider: 'stripe', attempt: 3 }); + +const log = logger.child({ component: 'queue', queue: 'tasks' }); +log.info('task.started', { taskId }); ``` -**Methods:** `logger.error(message, fields?)`, `logger.warn(message, fields?)`, `logger.info(message, fields?)`. +**Methods:** `logger.error(message, fields?)`, `logger.warn(message, fields?)`, `logger.info(message, fields?)`, `logger.child(fields)`. diff --git a/packages/void/src/runtime/log.ts b/packages/void/src/runtime/log.ts index 8a4d919ac..727d0d5d4 100644 --- a/packages/void/src/runtime/log.ts +++ b/packages/void/src/runtime/log.ts @@ -16,6 +16,13 @@ type Fields = Record; +type Logger = { + error(message: string, fields?: Fields): void; + warn(message: string, fields?: Fields): void; + info(message: string, fields?: Fields): void; + child(fields: Fields): Logger; +}; + // `name`, `message`, `stack` and `cause` are non-enumerable on `Error`, so plain // `JSON.stringify` turns an error into `{}`. Copy them next to the error's own // enumerable fields. An error seen again (for example a `cause` cycle) keeps only @@ -40,37 +47,56 @@ function errorReplacer(): (key: string, value: unknown) => unknown { }; } -function format(message: string, fields?: Fields): string { - // `message` always wins over `fields.msg`. The loop sits inside the try because - // getters and proxy traps on `fields` may throw; logging must never throw. +function format(message: string, boundFields: ReadonlyArray = [], fields?: Fields): string { try { const payload: Record = Object.create(null); payload.msg = message; - if (fields) { - for (const [key, value] of Object.entries(fields)) { + + for (const source of [...boundFields, fields]) { + if (!source) { + continue; + } + + for (const [key, value] of Object.entries(source)) { if (key === 'msg' || key === 'toJSON' || key === '__proto__' || key === 'constructor') { continue; } + if (typeof value === 'function') { continue; } + payload[key] = value; } } + return JSON.stringify(payload, errorReplacer()); } catch { - return JSON.stringify({ msg: message, error: 'log: fields not serializable' }); + return JSON.stringify({ + msg: message, + error: 'log: fields not serializable', + }); } } -export const logger = { - error(message: string, fields?: Fields): void { - console.error(format(message, fields)); - }, - warn(message: string, fields?: Fields): void { - console.warn(format(message, fields)); - }, - info(message: string, fields?: Fields): void { - console.info(format(message, fields)); - }, -}; +function createLogger(boundFields: ReadonlyArray = []): Logger { + return { + error(message, fields) { + console.error(format(message, boundFields, fields)); + }, + + warn(message, fields) { + console.warn(format(message, boundFields, fields)); + }, + + info(message, fields) { + console.info(format(message, boundFields, fields)); + }, + + child(fields) { + return createLogger([...boundFields, fields]); + }, + }; +} + +export const logger: Logger = createLogger(); diff --git a/packages/void/test/unit/runtime-log.test.ts b/packages/void/test/unit/runtime-log.test.ts index c24f0c7dc..a6d441b1d 100644 --- a/packages/void/test/unit/runtime-log.test.ts +++ b/packages/void/test/unit/runtime-log.test.ts @@ -188,4 +188,100 @@ describe('runtime log', () => { expect(parsed.error.message).toBe('loop'); expect(parsed.error.cause).toEqual({ name: 'Error', message: 'loop' }); }); + + it('child binds fields to every log level', () => { + const child = logger.child({ component: 'worker' }); + + child.error('failed'); + child.warn('retrying'); + child.info('started'); + + expect( + JSON.parse((console.error as ReturnType).mock.calls[0][0] as string), + ).toEqual({ msg: 'failed', component: 'worker' }); + expect( + JSON.parse((console.warn as ReturnType).mock.calls[0][0] as string), + ).toEqual({ msg: 'retrying', component: 'worker' }); + expect( + JSON.parse((console.info as ReturnType).mock.calls[0][0] as string), + ).toEqual({ msg: 'started', component: 'worker' }); + }); + + it('call fields override bound fields', () => { + logger.child({ component: 'worker', attempt: 1 }).info('retry', { + attempt: 2, + taskId: 't1', + }); + + const arg = (console.info as ReturnType).mock.calls[0][0] as string; + + expect(JSON.parse(arg)).toEqual({ + msg: 'retry', + component: 'worker', + attempt: 2, + taskId: 't1', + }); + }); + + it('nested children inherit and extend bound fields', () => { + logger.child({ component: 'queue' }).child({ taskId: 't1' }).info('started'); + + const arg = (console.info as ReturnType).mock.calls[0][0] as string; + + expect(JSON.parse(arg)).toEqual({ + msg: 'started', + component: 'queue', + taskId: 't1', + }); + }); + + it('reserved keys and functions in bound fields are skipped', () => { + logger + .child({ + component: 'worker', + msg: 'ignored', + toJSON: () => ({ hacked: true }), + constructor: 'ignored', + fn: () => {}, + }) + .info('safe'); + + const arg = (console.info as ReturnType).mock.calls[0][0] as string; + + expect(JSON.parse(arg)).toEqual({ + msg: 'safe', + component: 'worker', + }); + }); + + it('throwing getters in bound and call fields do not crash logging', () => { + const throwingFields = Object.defineProperty({}, 'bomb', { + enumerable: true, + get() { + throw new Error('getter'); + }, + }) as Record; + + const child = logger.child(throwingFields); + + expect(() => child.info('bound getter')).not.toThrow(); + + let arg = (console.info as ReturnType).mock.calls[0][0] as string; + + expect(JSON.parse(arg)).toEqual({ + msg: 'bound getter', + error: 'log: fields not serializable', + }); + + expect(() => + logger.child({ component: 'worker' }).info('call getter', throwingFields), + ).not.toThrow(); + + arg = (console.info as ReturnType).mock.calls[1][0] as string; + + expect(JSON.parse(arg)).toEqual({ + msg: 'call getter', + error: 'log: fields not serializable', + }); + }); });