From 30ba6866227516668b259849cb058f78c4c78bad Mon Sep 17 00:00:00 2001 From: zero-dot7 Date: Fri, 2 Oct 2026 11:55:49 +0000 Subject: [PATCH 1/2] fix(core): preserve shared references in JSON serialization safeJsonStringify used a global 'seen' WeakSet, so any shared (non-circular) object reference was replaced with [Circular]. OpenTelemetry records share endTime objects and histogram bound arrays, which corrupted exported telemetry files. Track only the current depth-first ancestor path via the replacer's 'this' (the holder of the current key) and unwind completed subtrees. True cycles still produce [Circular]; shared references are now serialized at each occurrence. Fixes #29406 --- .../core/src/utils/safeJsonStringify.test.ts | 51 +++++++++++++++++++ packages/core/src/utils/safeJsonStringify.ts | 17 +++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/safeJsonStringify.test.ts b/packages/core/src/utils/safeJsonStringify.test.ts index 9a38c048810..32e3f365afd 100644 --- a/packages/core/src/utils/safeJsonStringify.test.ts +++ b/packages/core/src/utils/safeJsonStringify.test.ts @@ -60,6 +60,57 @@ describe('safeJsonStringify', () => { expect(result).toBe('[{"id":1,"parent":"[Circular]"}]'); }); + it('should preserve shared (non-circular) references instead of emitting [Circular]', () => { + // Regression test for #29406: OpenTelemetry metrics share the same + // `endTime` object reference across records; a global "seen" set wrongly + // replaced the second occurrence with [Circular]. + const sharedEndTime = { seconds: 1758900000, nanos: 0 }; + const telemetry = { + resourceName: 'resource1', + scopeMetrics: [ + { + scope: { name: 'scopeA' }, + metrics: [ + { name: 'metricA', endTime: sharedEndTime }, + { name: 'metricB', endTime: sharedEndTime }, + ], + }, + ], + }; + + const result = safeJsonStringify(telemetry); + expect(result).toContain('"name":"metricA"'); + expect(result).toContain('"name":"metricB"'); + expect(result).toContain('"seconds":1758900000'); + // Both occurrences of the shared endTime must be serialized, not [Circular]. + expect(result.match(/1758900000/g)).toHaveLength(2); + expect(result).not.toContain('[Circular]'); + }); + + it('should preserve shared histogram bound arrays (explicit bucket boundaries)', () => { + // Regression test for #29406: explicit histogram bounds share one array + // reference between lower and upper bounds. + const bounds = [0, 5, 10, 25, 50, 75, 100]; + const histogram = { + dataPoints: [{ lowerBounds: bounds, upperBounds: bounds, count: 42 }], + }; + + const result = safeJsonStringify(histogram); + expect( + result.match(/"0,5,10,25,50,75,100"|0,5,10,25,50,75,100/g), + ).toHaveLength(2); + expect(result).not.toContain('[Circular]'); + }); + + it('should still detect true circularity when a shared object is also its own descendant', () => { + const shared = { marker: 'shared' }; + const root: Record = { a: shared, b: shared }; + shared.self = root; // now genuinely circular through the shared object + + const result = safeJsonStringify(root); + expect(result).toContain('[Circular]'); + }); + it('should handle null and undefined values', () => { expect(safeJsonStringify(null)).toBe('null'); expect(safeJsonStringify(undefined)).toBe(undefined); diff --git a/packages/core/src/utils/safeJsonStringify.ts b/packages/core/src/utils/safeJsonStringify.ts index d3998fa0f4f..94cd63d4609 100644 --- a/packages/core/src/utils/safeJsonStringify.ts +++ b/packages/core/src/utils/safeJsonStringify.ts @@ -17,17 +17,24 @@ export function safeJsonStringify( obj: unknown, space?: string | number, ): string { - const seen = new WeakSet(); + // Ancestor stack: only references on the current depth-first path are + // circular. Shared (non-circular) references reached from a sibling are + // safe to serialize again. The replacer's `this` is the object holding the + // current key, which lets us unwind completed subtrees. + const stack: object[] = []; return JSON.stringify( obj, - (key, value) => { + function (this: unknown, _key: string, value: unknown) { if (typeof value === 'object' && value !== null) { - if (seen.has(value)) { + while (stack.length > 0 && stack[stack.length - 1] !== this) { + stack.pop(); + } + if (stack.includes(value)) { return '[Circular]'; } - seen.add(value); + stack.push(value); } - return value as unknown; + return value; }, space, ); From 2aab3ce047a30281dee1949b73686c673d4c48ce Mon Sep 17 00:00:00 2001 From: zero-dot7 Date: Sun, 4 Oct 2026 08:07:26 +0000 Subject: [PATCH 2/2] chore: trigger CLA recheck