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
2 changes: 2 additions & 0 deletions docs/reference/api/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,5 @@ logger.error('webhook failed', { provider: 'stripe', attempt: 3 });
```

**Methods:** `logger.error(message, fields?)`, `logger.warn(message, fields?)`, `logger.info(message, fields?)`.

An `Error` in `fields` is logged with its `name`, `message`, `stack`, `cause` and own fields, so you can pass a caught error directly: `logger.error('save failed', { error })`.
26 changes: 25 additions & 1 deletion packages/void/src/runtime/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@

type Fields = Record<string, unknown>;

// `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
// its name and message so serialization always terminates.
function errorReplacer(): (key: string, value: unknown) => unknown {
const seen = new WeakSet<Error>();
return (_key, value) => {
if (!(value instanceof Error)) {
return value;
}
if (seen.has(value)) {
return { name: value.name, message: value.message };
}
seen.add(value);
return {
...value,
name: value.name,
message: value.message,
stack: value.stack,
cause: value.cause,
};
};
}

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.
Expand All @@ -33,7 +57,7 @@ function format(message: string, fields?: Fields): string {
payload[key] = value;
}
}
return JSON.stringify(payload);
return JSON.stringify(payload, errorReplacer());
} catch {
return JSON.stringify({ msg: message, error: 'log: fields not serializable' });
}
Expand Down
42 changes: 42 additions & 0 deletions packages/void/test/unit/runtime-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,46 @@ describe('runtime log', () => {
const parsed = JSON.parse(arg as string);
expect(parsed.msg).toBe('circular failure');
});

it('Error fields keep name, message, stack and cause', () => {
const inner = new Error('inner');
const error = new Error('wrapped', { cause: inner });

logger.error('save failed', { error });

const arg = (console.error as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
const parsed = JSON.parse(arg);
expect(parsed.error).toEqual({
name: 'Error',
message: 'wrapped',
stack: error.stack,
cause: { name: 'Error', message: 'inner', stack: inner.stack },
});
});

it('Error subclasses keep their own enumerable fields', () => {
class AppError extends Error {
status = 500;
}

logger.error('save failed', { error: new AppError('boom') });

const arg = (console.error as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
const parsed = JSON.parse(arg);
expect(parsed.error).toMatchObject({ status: 500, name: 'Error', message: 'boom' });
expect(typeof parsed.error.stack).toBe('string');
});

it('a cyclic Error cause does not drop the other fields', () => {
const error = new Error('loop');
error.cause = error;

logger.error('cycle', { error, jobId: 'j1' });

const arg = (console.error as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
const parsed = JSON.parse(arg);
expect(parsed.jobId).toBe('j1');
expect(parsed.error.message).toBe('loop');
expect(parsed.error.cause).toEqual({ name: 'Error', message: 'loop' });
});
});