Skip to content
Open
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: 4 additions & 1 deletion docs/reference/api/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
60 changes: 43 additions & 17 deletions packages/void/src/runtime/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@

type Fields = Record<string, unknown>;

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
Expand All @@ -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?: Fields): string {
try {
const payload: Record<string, unknown> = 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<Fields> = []): 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();
96 changes: 96 additions & 0 deletions packages/void/test/unit/runtime-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).mock.calls[0][0] as string),
).toEqual({ msg: 'failed', component: 'worker' });
expect(
JSON.parse((console.warn as ReturnType<typeof vi.fn>).mock.calls[0][0] as string),
).toEqual({ msg: 'retrying', component: 'worker' });
expect(
JSON.parse((console.info as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<string, unknown>;

const child = logger.child(throwingFields);

expect(() => child.info('bound getter')).not.toThrow();

let arg = (console.info as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls[1][0] as string;

expect(JSON.parse(arg)).toEqual({
msg: 'call getter',
error: 'log: fields not serializable',
});
});
});