Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/core/src/utils/safeJsonStringify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { 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);
Expand Down
17 changes: 12 additions & 5 deletions packages/core/src/utils/safeJsonStringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
Loading