From d9cdb6df9d43b18b45600d4b506eff825288ef25 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:40:07 +0000 Subject: [PATCH] fix(log): keep Error details in logger fields --- docs/reference/api/resources.md | 2 + packages/void/src/runtime/log.ts | 26 ++++++++++++- packages/void/test/unit/runtime-log.test.ts | 42 +++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/reference/api/resources.md b/docs/reference/api/resources.md index 97e015fb3..3d73d29f3 100644 --- a/docs/reference/api/resources.md +++ b/docs/reference/api/resources.md @@ -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 })`. diff --git a/packages/void/src/runtime/log.ts b/packages/void/src/runtime/log.ts index 1ace7bc41..8a4d919ac 100644 --- a/packages/void/src/runtime/log.ts +++ b/packages/void/src/runtime/log.ts @@ -16,6 +16,30 @@ type Fields = Record; +// `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(); + 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. @@ -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' }); } diff --git a/packages/void/test/unit/runtime-log.test.ts b/packages/void/test/unit/runtime-log.test.ts index f0fcd042b..c24f0c7dc 100644 --- a/packages/void/test/unit/runtime-log.test.ts +++ b/packages/void/test/unit/runtime-log.test.ts @@ -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).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).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).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' }); + }); });