Skip to content

Commit 3332fec

Browse files
authored
fix(opentelemetry): Use WeakRef for context stored on scope to prevent memory leak (#20328)
1 parent 684a41f commit 3332fec

6 files changed

Lines changed: 438 additions & 43 deletions

File tree

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export { getTraceData } from './utils/traceData';
105105
export { shouldPropagateTraceForUrl } from './utils/tracePropagationTargets';
106106
export { getTraceMetaTags } from './utils/meta';
107107
export { debounce } from './utils/debounce';
108+
export { makeWeakRef, derefWeakRef } from './utils/weakRef';
109+
export type { MaybeWeakRef } from './utils/weakRef';
108110
export { shouldIgnoreSpan } from './utils/should-ignore-span';
109111
export {
110112
winterCGHeadersToDict,

packages/core/src/tracing/utils.ts

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,20 @@
11
import type { Scope } from '../scope';
22
import type { Span } from '../types-hoist/span';
33
import { addNonEnumerableProperty } from '../utils/object';
4-
import { GLOBAL_OBJ } from '../utils/worldwide';
4+
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from '../utils/weakRef';
55

66
const SCOPE_ON_START_SPAN_FIELD = '_sentryScope';
77
const ISOLATION_SCOPE_ON_START_SPAN_FIELD = '_sentryIsolationScope';
88

9-
type ScopeWeakRef = { deref(): Scope | undefined } | Scope;
10-
119
type SpanWithScopes = Span & {
1210
[SCOPE_ON_START_SPAN_FIELD]?: Scope;
13-
[ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: ScopeWeakRef;
11+
[ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: MaybeWeakRef<Scope>;
1412
};
1513

16-
/** Wrap a scope with a WeakRef if available, falling back to a direct scope. */
17-
function wrapScopeWithWeakRef(scope: Scope): ScopeWeakRef {
18-
try {
19-
// @ts-expect-error - WeakRef is not available in all environments
20-
const WeakRefClass = GLOBAL_OBJ.WeakRef;
21-
if (typeof WeakRefClass === 'function') {
22-
return new WeakRefClass(scope);
23-
}
24-
} catch {
25-
// WeakRef not available or failed to create
26-
// We'll fall back to a direct scope
27-
}
28-
29-
return scope;
30-
}
31-
32-
/** Try to unwrap a scope from a potential WeakRef wrapper. */
33-
function unwrapScopeFromWeakRef(scopeRef: ScopeWeakRef | undefined): Scope | undefined {
34-
if (!scopeRef) {
35-
return undefined;
36-
}
37-
38-
if (typeof scopeRef === 'object' && 'deref' in scopeRef && typeof scopeRef.deref === 'function') {
39-
try {
40-
return scopeRef.deref();
41-
} catch {
42-
return undefined;
43-
}
44-
}
45-
46-
// Fallback to a direct scope
47-
return scopeRef as Scope;
48-
}
49-
5014
/** Store the scope & isolation scope for a span, which can the be used when it is finished. */
5115
export function setCapturedScopesOnSpan(span: Span | undefined, scope: Scope, isolationScope: Scope): void {
5216
if (span) {
53-
addNonEnumerableProperty(span, ISOLATION_SCOPE_ON_START_SPAN_FIELD, wrapScopeWithWeakRef(isolationScope));
17+
addNonEnumerableProperty(span, ISOLATION_SCOPE_ON_START_SPAN_FIELD, makeWeakRef(isolationScope));
5418
// We don't wrap the scope with a WeakRef here because webkit aggressively garbage collects
5519
// and scopes are not held in memory for long periods of time.
5620
addNonEnumerableProperty(span, SCOPE_ON_START_SPAN_FIELD, scope);
@@ -66,6 +30,6 @@ export function getCapturedScopesOnSpan(span: Span): { scope?: Scope; isolationS
6630

6731
return {
6832
scope: spanWithScopes[SCOPE_ON_START_SPAN_FIELD],
69-
isolationScope: unwrapScopeFromWeakRef(spanWithScopes[ISOLATION_SCOPE_ON_START_SPAN_FIELD]),
33+
isolationScope: derefWeakRef(spanWithScopes[ISOLATION_SCOPE_ON_START_SPAN_FIELD]),
7034
};
7135
}

packages/core/src/utils/weakRef.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { GLOBAL_OBJ } from './worldwide';
2+
3+
/**
4+
* Interface representing a weak reference to an object.
5+
* This matches the standard WeakRef interface but is defined here
6+
* because WeakRef is not available in ES2020 type definitions.
7+
*/
8+
interface WeakRefLike<T extends object> {
9+
deref(): T | undefined;
10+
}
11+
12+
/**
13+
* A wrapper type that represents either a WeakRef-like object or a direct reference.
14+
* Used for optional weak referencing in environments where WeakRef may not be available.
15+
*/
16+
export type MaybeWeakRef<T extends object> = WeakRefLike<T> | T;
17+
18+
/**
19+
* Creates a weak reference to an object if WeakRef is available,
20+
* otherwise returns the object directly.
21+
*
22+
* This is useful for breaking circular references while maintaining
23+
* compatibility with environments that don't support WeakRef (e.g., older browsers).
24+
*
25+
* @param value - The object to create a weak reference to
26+
* @returns A WeakRef wrapper if available, or the original object as fallback
27+
*/
28+
export function makeWeakRef<T extends object>(value: T): MaybeWeakRef<T> {
29+
try {
30+
// @ts-expect-error - WeakRef may not be in the type definitions for older TS targets
31+
const WeakRefImpl = GLOBAL_OBJ.WeakRef;
32+
if (typeof WeakRefImpl === 'function') {
33+
return new WeakRefImpl(value);
34+
}
35+
} catch {
36+
// WeakRef not available or construction failed
37+
}
38+
return value;
39+
}
40+
41+
/**
42+
* Resolves a potentially weak reference, returning the underlying object
43+
* or undefined if the reference has been garbage collected.
44+
*
45+
* @param ref - A MaybeWeakRef or undefined
46+
* @returns The referenced object, or undefined if GC'd or ref was undefined
47+
*/
48+
export function derefWeakRef<T extends object>(ref: MaybeWeakRef<T> | undefined): T | undefined {
49+
if (!ref) {
50+
return undefined;
51+
}
52+
53+
// Check if this is a WeakRef (has deref method)
54+
if (typeof ref === 'object' && 'deref' in ref && typeof ref.deref === 'function') {
55+
try {
56+
return ref.deref();
57+
} catch {
58+
// deref() failed - treat as GC'd
59+
return undefined;
60+
}
61+
}
62+
63+
// Direct reference fallback
64+
return ref as T;
65+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from '../../../src/utils/weakRef';
3+
4+
describe('Unit | util | weakRef', () => {
5+
describe('makeWeakRef', () => {
6+
it('creates a WeakRef when available', () => {
7+
const obj = { foo: 'bar' };
8+
const ref = makeWeakRef(obj);
9+
10+
// Should be a WeakRef, not the direct object
11+
expect(ref).toBeInstanceOf(WeakRef);
12+
expect((ref as WeakRef<typeof obj>).deref()).toBe(obj);
13+
});
14+
15+
it('returns the object directly when WeakRef is not available', () => {
16+
const originalWeakRef = globalThis.WeakRef;
17+
(globalThis as any).WeakRef = undefined;
18+
19+
try {
20+
const obj = { foo: 'bar' };
21+
const ref = makeWeakRef(obj);
22+
23+
// Should be the direct object
24+
expect(ref).toBe(obj);
25+
} finally {
26+
(globalThis as any).WeakRef = originalWeakRef;
27+
}
28+
});
29+
30+
it('returns the object directly when WeakRef constructor throws', () => {
31+
const originalWeakRef = globalThis.WeakRef;
32+
(globalThis as any).WeakRef = function () {
33+
throw new Error('WeakRef not supported');
34+
};
35+
36+
try {
37+
const obj = { foo: 'bar' };
38+
const ref = makeWeakRef(obj);
39+
40+
// Should fall back to the direct object
41+
expect(ref).toBe(obj);
42+
} finally {
43+
(globalThis as any).WeakRef = originalWeakRef;
44+
}
45+
});
46+
47+
it('works with different object types', () => {
48+
const plainObject = { key: 'value' };
49+
const array = [1, 2, 3];
50+
const func = () => 'test';
51+
const date = new Date();
52+
53+
expect(derefWeakRef(makeWeakRef(plainObject))).toBe(plainObject);
54+
expect(derefWeakRef(makeWeakRef(array))).toBe(array);
55+
expect(derefWeakRef(makeWeakRef(func))).toBe(func);
56+
expect(derefWeakRef(makeWeakRef(date))).toBe(date);
57+
});
58+
});
59+
60+
describe('derefWeakRef', () => {
61+
it('returns undefined for undefined input', () => {
62+
expect(derefWeakRef(undefined)).toBeUndefined();
63+
});
64+
65+
it('correctly dereferences a WeakRef', () => {
66+
const obj = { foo: 'bar' };
67+
const weakRef = new WeakRef(obj);
68+
69+
expect(derefWeakRef(weakRef)).toBe(obj);
70+
});
71+
72+
it('returns the direct object when not a WeakRef', () => {
73+
const obj = { foo: 'bar' };
74+
75+
// Passing a direct object (fallback case)
76+
expect(derefWeakRef(obj as MaybeWeakRef<typeof obj>)).toBe(obj);
77+
});
78+
79+
it('returns undefined when WeakRef.deref() returns undefined (simulating GC)', () => {
80+
const mockWeakRef = {
81+
deref: vi.fn().mockReturnValue(undefined),
82+
};
83+
84+
expect(derefWeakRef(mockWeakRef as MaybeWeakRef<object>)).toBeUndefined();
85+
expect(mockWeakRef.deref).toHaveBeenCalled();
86+
});
87+
88+
it('returns undefined when WeakRef.deref() throws an error', () => {
89+
const mockWeakRef = {
90+
deref: vi.fn().mockImplementation(() => {
91+
throw new Error('deref failed');
92+
}),
93+
};
94+
95+
expect(derefWeakRef(mockWeakRef as MaybeWeakRef<object>)).toBeUndefined();
96+
expect(mockWeakRef.deref).toHaveBeenCalled();
97+
});
98+
99+
it('handles objects with a non-function deref property', () => {
100+
const objWithDerefProperty = {
101+
deref: 'not a function',
102+
actualData: 'test',
103+
};
104+
105+
// Should treat it as a direct object since deref is not a function
106+
expect(derefWeakRef(objWithDerefProperty as unknown as MaybeWeakRef<object>)).toBe(objWithDerefProperty);
107+
});
108+
});
109+
110+
describe('roundtrip (makeWeakRef + derefWeakRef)', () => {
111+
it('preserves object identity with WeakRef available', () => {
112+
const obj = { nested: { data: [1, 2, 3] } };
113+
const ref = makeWeakRef(obj);
114+
const retrieved = derefWeakRef(ref);
115+
116+
expect(retrieved).toBe(obj);
117+
expect(retrieved?.nested.data).toEqual([1, 2, 3]);
118+
});
119+
120+
it('preserves object identity with WeakRef unavailable', () => {
121+
const originalWeakRef = globalThis.WeakRef;
122+
(globalThis as any).WeakRef = undefined;
123+
124+
try {
125+
const obj = { nested: { data: [1, 2, 3] } };
126+
const ref = makeWeakRef(obj);
127+
const retrieved = derefWeakRef(ref);
128+
129+
expect(retrieved).toBe(obj);
130+
expect(retrieved?.nested.data).toEqual([1, 2, 3]);
131+
} finally {
132+
(globalThis as any).WeakRef = originalWeakRef;
133+
}
134+
});
135+
136+
it('allows multiple refs to the same object', () => {
137+
const obj = { id: 'shared' };
138+
const ref1 = makeWeakRef(obj);
139+
const ref2 = makeWeakRef(obj);
140+
141+
expect(derefWeakRef(ref1)).toBe(obj);
142+
expect(derefWeakRef(ref2)).toBe(obj);
143+
expect(derefWeakRef(ref1)).toBe(derefWeakRef(ref2));
144+
});
145+
});
146+
147+
describe('type safety', () => {
148+
it('preserves generic type information', () => {
149+
interface TestInterface {
150+
id: number;
151+
name: string;
152+
}
153+
154+
const obj: TestInterface = { id: 1, name: 'test' };
155+
const ref: MaybeWeakRef<TestInterface> = makeWeakRef(obj);
156+
const retrieved: TestInterface | undefined = derefWeakRef(ref);
157+
158+
expect(retrieved?.id).toBe(1);
159+
expect(retrieved?.name).toBe('test');
160+
});
161+
162+
it('works with class instances', () => {
163+
class TestClass {
164+
constructor(public value: string) {}
165+
getValue(): string {
166+
return this.value;
167+
}
168+
}
169+
170+
const instance = new TestClass('hello');
171+
const ref = makeWeakRef(instance);
172+
const retrieved = derefWeakRef(ref);
173+
174+
expect(retrieved).toBeInstanceOf(TestClass);
175+
expect(retrieved?.getValue()).toBe('hello');
176+
});
177+
});
178+
});

packages/opentelemetry/src/utils/contextData.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import type { Context } from '@opentelemetry/api';
22
import type { Scope } from '@sentry/core';
3-
import { addNonEnumerableProperty } from '@sentry/core';
3+
import { addNonEnumerableProperty, derefWeakRef, makeWeakRef, type MaybeWeakRef } from '@sentry/core';
44
import { SENTRY_SCOPES_CONTEXT_KEY } from '../constants';
55
import type { CurrentScopes } from '../types';
66

77
const SCOPE_CONTEXT_FIELD = '_scopeContext';
88

9+
type ScopeWithContext = Scope & {
10+
[SCOPE_CONTEXT_FIELD]?: MaybeWeakRef<Context>;
11+
};
12+
913
/**
1014
* Try to get the current scopes from the given OTEL context.
1115
* This requires a Context Manager that was wrapped with getWrappedContextManager.
@@ -25,14 +29,21 @@ export function setScopesOnContext(context: Context, scopes: CurrentScopes): Con
2529
/**
2630
* Set the context on the scope so we can later look it up.
2731
* We need this to get the context from the scope in the `trace` functions.
32+
*
33+
* We use WeakRef to avoid a circular reference between the scope and the context.
34+
* The context holds scopes (via SENTRY_SCOPES_CONTEXT_KEY), and if the scope held
35+
* a strong reference back to the context, neither could be garbage collected even
36+
* when the context is no longer reachable from application code (e.g., after a
37+
* request completes but pooled connections retain patched callbacks).
2838
*/
2939
export function setContextOnScope(scope: Scope, context: Context): void {
30-
addNonEnumerableProperty(scope, SCOPE_CONTEXT_FIELD, context);
40+
addNonEnumerableProperty(scope, SCOPE_CONTEXT_FIELD, makeWeakRef(context));
3141
}
3242

3343
/**
3444
* Get the context related to a scope.
45+
* Returns undefined if the context has been garbage collected (when WeakRef is used).
3546
*/
3647
export function getContextFromScope(scope: Scope): Context | undefined {
37-
return (scope as { [SCOPE_CONTEXT_FIELD]?: Context })[SCOPE_CONTEXT_FIELD];
48+
return derefWeakRef((scope as ScopeWithContext)[SCOPE_CONTEXT_FIELD]);
3849
}

0 commit comments

Comments
 (0)