Skip to content

Commit eb0cf2c

Browse files
committed
Fork the current scope in withIsolationScope and withSetIsolationScope
The strategy was derived from the deno/cloudflare one, which passes the current scope by reference. Both the OpenTelemetry strategy and v10's node-core light strategy clone it, so without this, current-scope mutations leak out of an isolation fork.
1 parent 033bdce commit eb0cf2c

2 files changed

Lines changed: 39 additions & 2 deletions

File tree

packages/server-utils/src/async-context.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void {
5454
});
5555
}
5656

57+
// The current scope is forked alongside the isolation scope, matching the OpenTelemetry strategy
58+
// (`buildContextWithSentryScopes` clones it on every fork). Sharing it by reference would let
59+
// current-scope mutations inside the callback leak back out to the caller.
5760
function withIsolationScope<T>(callback: (isolationScope: Scope) => T): T {
58-
const scope = getScopes().scope;
61+
const scope = getScopes().scope.clone();
5962
const isolationScope = getScopes().isolationScope.clone();
6063

6164
return asyncStorage.run({ scope, isolationScope }, () => {
@@ -64,7 +67,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void {
6467
}
6568

6669
function withSetIsolationScope<T>(isolationScope: Scope, callback: (isolationScope: Scope) => T): T {
67-
const scope = getScopes().scope;
70+
const scope = getScopes().scope.clone();
6871
return asyncStorage.run({ scope, isolationScope }, () => {
6972
return callback(isolationScope);
7073
});

packages/server-utils/test/async-context.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,40 @@ describe('withIsolationScope()', () => {
166166
done();
167167
});
168168
}));
169+
170+
it('forks the current scope as well, so mutations do not leak out', () =>
171+
new Promise<void>(done => {
172+
const initialScope = getCurrentScope();
173+
initialScope.setTag('aa', 'aa');
174+
175+
withIsolationScope(() => {
176+
const scope = getCurrentScope();
177+
expect(scope).not.toBe(initialScope);
178+
expect(scope.getScopeData().tags).toEqual({ aa: 'aa' });
179+
180+
scope.setTag('bb', 'bb');
181+
done();
182+
});
183+
184+
expect(initialScope.getScopeData().tags).toEqual({ aa: 'aa' });
185+
}));
186+
187+
it('forks the current scope as well when passing an isolation scope', () =>
188+
new Promise<void>(done => {
189+
const initialScope = getCurrentScope();
190+
initialScope.setTag('aa', 'aa');
191+
192+
withIsolationScope(new Scope(), () => {
193+
const scope = getCurrentScope();
194+
expect(scope).not.toBe(initialScope);
195+
expect(scope.getScopeData().tags).toEqual({ aa: 'aa' });
196+
197+
scope.setTag('bb', 'bb');
198+
done();
199+
});
200+
201+
expect(initialScope.getScopeData().tags).toEqual({ aa: 'aa' });
202+
}));
169203
});
170204

171205
describe('AsyncLocalStorage re-use', () => {

0 commit comments

Comments
 (0)