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' }); + }); });