Skip to content

Commit 22e6b9b

Browse files
committed
fix(logger): keep a throwing toJSON from escaping the final fallback
1 parent 3f4720e commit 22e6b9b

2 files changed

Lines changed: 40 additions & 1 deletion

File tree

packages/logger/src/index.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,25 @@ describe('Logger', () => {
275275
expect(parsed.boom).toBe('[Unreadable]')
276276
})
277277

278+
test('should not throw when retained metadata has a throwing toJSON', () => {
279+
const hostile = {
280+
evil: {
281+
toJSON() {
282+
throw new Error('toJSON exploded')
283+
},
284+
},
285+
} as unknown as Parameters<Logger['withMetadata']>[0]
286+
287+
const child = createEnabledLogger().withMetadata(hostile)
288+
289+
expect(() => child.info('hello')).not.toThrow()
290+
const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string)
291+
expect(parsed.message).toBe('hello')
292+
expect(parsed.module).toBe('Test')
293+
expect(parsed.serializationError).toBe(true)
294+
expect(parsed.evil).toBeUndefined()
295+
})
296+
278297
test('should not throw when withMetadata receives a hostile proxy', () => {
279298
const hostile = new Proxy(
280299
{},

packages/logger/src/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,27 @@ const serializeEntry = (base: Record<string, unknown>, args: unknown[]): string
185185
return JSON.stringify(mergeArgs({ ...base }, args), tolerantReplacer())
186186
} catch {}
187187

188-
return JSON.stringify({ ...base, serializationError: true }, tolerantReplacer())
188+
return minimalEntry(base)
189+
}
190+
191+
/**
192+
* Last-resort entry built only from fields this module controls.
193+
*
194+
* A replacer cannot rescue a throwing `toJSON`, because `JSON.stringify` invokes
195+
* it before the replacer ever sees the value. So the final fallback drops every
196+
* caller-supplied value instead of re-serializing it, and passes strings through
197+
* only when they are already strings — coercing would re-enter hostile
198+
* `toString`. What remains cannot throw.
199+
*/
200+
const minimalEntry = (base: Record<string, unknown>): string => {
201+
const asString = (value: unknown) => (typeof value === 'string' ? value : '[Unserializable]')
202+
return JSON.stringify({
203+
timestamp: asString(base.timestamp),
204+
level: asString(base.level),
205+
module: asString(base.module),
206+
message: asString(base.message),
207+
serializationError: true,
208+
})
189209
}
190210

191211
/**

0 commit comments

Comments
 (0)