Skip to content

Commit 1a4b662

Browse files
committed
fix(logger): keep repeated references out of the circular-reference fallback
1 parent 22e6b9b commit 1a4b662

2 files changed

Lines changed: 41 additions & 6 deletions

File tree

packages/logger/src/index.test.ts

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

278+
test('should keep repeated references that are not cycles', () => {
279+
const shared = { s: 'REAL_DATA', n: 42 }
280+
const payload = { p: shared, q: shared, arr: [shared, shared], big: 1n } as unknown as object
281+
282+
createEnabledLogger().info('hello', payload)
283+
284+
const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string)
285+
expect(parsed.p).toEqual({ s: 'REAL_DATA', n: 42 })
286+
expect(parsed.q).toEqual({ s: 'REAL_DATA', n: 42 })
287+
expect(parsed.arr).toEqual([
288+
{ s: 'REAL_DATA', n: 42 },
289+
{ s: 'REAL_DATA', n: 42 },
290+
])
291+
expect(parsed.big).toBe('1')
292+
})
293+
294+
test('should still mark a genuine cycle as circular', () => {
295+
const cyclic: Record<string, unknown> = { name: 'root' }
296+
cyclic.self = cyclic
297+
const payload = { cyclic, big: 1n } as unknown as object
298+
299+
createEnabledLogger().info('hello', payload)
300+
301+
const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string)
302+
expect(parsed.cyclic.name).toBe('root')
303+
expect(parsed.cyclic.self).toBe('[Circular]')
304+
})
305+
278306
test('should not throw when retained metadata has a throwing toJSON', () => {
279307
const hostile = {
280308
evil: {

packages/logger/src/index.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,20 @@ const mergeArgs = (entry: Record<string, unknown>, args: unknown[]): Record<stri
157157

158158
/** JSON replacer that tolerates cyclic references and BigInt values. */
159159
const tolerantReplacer = () => {
160-
const seen = new WeakSet<object>()
161-
return (_key: string, value: unknown): unknown => {
160+
const ancestors: object[] = []
161+
return function (this: unknown, _key: string, value: unknown): unknown {
162162
if (typeof value === 'bigint') return value.toString()
163-
if (value !== null && typeof value === 'object') {
164-
if (seen.has(value)) return '[Circular]'
165-
seen.add(value)
166-
}
163+
if (value === null || typeof value !== 'object') return value
164+
/**
165+
* Track the ancestor path, not every object ever visited. `this` is the
166+
* object holding the current key, so unwinding to it drops the siblings we
167+
* have finished descending. A set of everything seen would label the second
168+
* appearance of a merely repeated reference `[Circular]` and discard real
169+
* data, since a payload that references one object twice has no cycle.
170+
*/
171+
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop()
172+
if (ancestors.includes(value)) return '[Circular]'
173+
ancestors.push(value)
167174
return value
168175
}
169176
}

0 commit comments

Comments
 (0)