Skip to content

Commit 8979856

Browse files
committed
feat: Streamline isolation scope handling & reset in isolation scopes
1 parent cdafffe commit 8979856

8 files changed

Lines changed: 109 additions & 35 deletions

File tree

packages/browser/src/tracing/browserTracingIntegration.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
getClient,
2020
getCurrentScope,
2121
getDynamicSamplingContextFromSpan,
22-
getIsolationScope,
2322
getLocationHref,
2423
GLOBAL_OBJ,
2524
hasSpansEnabled,
@@ -554,12 +553,6 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
554553

555554
maybeEndActiveSpan();
556555

557-
getIsolationScope().setPropagationContext({
558-
traceId: generateTraceId(),
559-
sampleRand: Math.random(),
560-
propagationSpanId: hasSpansEnabled() ? undefined : generateSpanId(),
561-
});
562-
563556
const scope = getCurrentScope();
564557
scope.setPropagationContext({
565558
traceId: generateTraceId(),

packages/core/src/tracing/trace.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ function createChildOrRootSpan({
367367
const isolationScope = getIsolationScope();
368368

369369
if (!hasSpansEnabled()) {
370-
const scopePropagationContext = { ...isolationScope.getPropagationContext(), ...scope.getPropagationContext() };
370+
const scopePropagationContext = scope.getPropagationContext();
371371
const traceId = parentSpan ? parentSpan.spanContext().traceId : scopePropagationContext.traceId;
372372

373373
// The placeholder is a thin marker; it carries no sampling decision or DSC. Both are read from

packages/opentelemetry/src/asyncContextStrategy.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as api from '@opentelemetry/api';
22
import type { Scope, TracingChannelBinding } from '@sentry/core';
33
import {
44
getAsyncContextStrategy,
5+
_INTERNAL_safeMathRandom,
6+
generateTraceId,
57
getDefaultCurrentScope,
68
getDefaultIsolationScope,
79
getMainCarrier,
@@ -86,6 +88,18 @@ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorage
8688
// the OTEL context manager, which uses the presence of this key to determine if it should
8789
// fork the isolation scope, or not
8890
return api.context.with(ctx.setValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, true), () => {
91+
// When forking an isolation scope, unless we are continuing an incoming trace (identified by a `parentSpanId`),
92+
// we give the freshly forked scope its own trace.
93+
// This way, new root spans in an isolation scope will get separate traces
94+
const scope = getCurrentScope();
95+
const propagationContext = scope.getPropagationContext();
96+
if (!propagationContext.parentSpanId) {
97+
scope.setPropagationContext({
98+
...propagationContext,
99+
traceId: generateTraceId(),
100+
sampleRand: _INTERNAL_safeMathRandom(),
101+
});
102+
}
89103
return callback(getIsolationScope());
90104
});
91105
}

packages/opentelemetry/test/asyncContextStrategy.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ import { TraceState } from '../src/utils/TraceState';
1919
import { mockSdkInit } from './helpers/mockSdkInit';
2020

2121
describe('asyncContextStrategy', () => {
22+
// `withIsolationScope` gives the forked current scope a fresh propagation context (unless it is
23+
// continuing an incoming trace), so scope data is expected to match apart from that context.
24+
function scopeDataWithoutPropagationContext(
25+
scope: Scope,
26+
): Omit<ReturnType<Scope['getScopeData']>, 'propagationContext'> {
27+
const { propagationContext: _propagationContext, ...rest } = scope.getScopeData();
28+
return rest;
29+
}
30+
2231
beforeEach(() => {
2332
getCurrentScope().clear();
2433
getIsolationScope().clear();
@@ -45,7 +54,8 @@ describe('asyncContextStrategy', () => {
4554
expect(scope1).not.toBe(initialScope);
4655
expect(isolationScope1).not.toBe(initialIsolationScope);
4756

48-
expect(scope1.getScopeData()).toEqual(initialScope.getScopeData());
57+
expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope));
58+
expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId);
4959
expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData());
5060

5161
scope1.setExtra('b', 'b');
@@ -162,7 +172,8 @@ describe('asyncContextStrategy', () => {
162172
expect(scope1).not.toBe(initialScope);
163173
expect(isolationScope1).not.toBe(initialIsolationScope);
164174

165-
expect(scope1.getScopeData()).toEqual(initialScope.getScopeData());
175+
expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope));
176+
expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId);
166177
expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData());
167178

168179
await asyncSetExtra(scope1, 'b', 'b');
@@ -207,7 +218,8 @@ describe('asyncContextStrategy', () => {
207218
expect(scope1).not.toBe(initialScope);
208219
expect(isolationScope1).not.toBe(initialIsolationScope);
209220

210-
expect(scope1.getScopeData()).toEqual(initialScope.getScopeData());
221+
expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope));
222+
expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId);
211223
expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData());
212224

213225
scope1.setExtra('b', 'b');
@@ -244,7 +256,8 @@ describe('asyncContextStrategy', () => {
244256
expect(scope1).not.toBe(initialScope);
245257
expect(isolationScope1).not.toBe(initialIsolationScope);
246258

247-
expect(scope1.getScopeData()).toEqual(initialScope.getScopeData());
259+
expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope));
260+
expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId);
248261
expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData());
249262

250263
scope1.setExtra('b2', 'b');
@@ -294,7 +307,8 @@ describe('asyncContextStrategy', () => {
294307
expect(scope1).not.toBe(initialScope);
295308
expect(isolationScope1).not.toBe(initialIsolationScope);
296309

297-
expect(scope1.getScopeData()).toEqual(initialScope.getScopeData());
310+
expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope));
311+
expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId);
298312
expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData());
299313

300314
await asyncSetExtra(scope1, 'b', 'b');
@@ -331,7 +345,8 @@ describe('asyncContextStrategy', () => {
331345
expect(scope1).not.toBe(initialScope);
332346
expect(isolationScope1).not.toBe(initialIsolationScope);
333347

334-
expect(scope1.getScopeData()).toEqual(initialScope.getScopeData());
348+
expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope));
349+
expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId);
335350
expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData());
336351

337352
scope1.setExtra('b2', 'b');

packages/profiling-node/src/integration.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,8 +541,7 @@ class ContinuousProfiler {
541541
return;
542542
}
543543

544-
const traceId =
545-
getCurrentScope().getPropagationContext().traceId || getIsolationScope().getPropagationContext().traceId;
544+
const traceId = getCurrentScope().getPropagationContext().traceId;
546545
const chunk = this._initializeChunk(traceId);
547546

548547
CpuProfilerBindings.startProfiling(chunk.id);

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { Scope } from '@sentry/core';
33
import {
44
_INTERNAL_createTracingChannelBinding,
55
getAsyncContextStrategy,
6+
_INTERNAL_safeMathRandom,
7+
generateTraceId,
68
getDefaultCurrentScope,
79
getDefaultIsolationScope,
810
getMainCarrier,
@@ -58,6 +60,18 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void {
5860
const scope = getScopes().scope;
5961
const isolationScope = getScopes().isolationScope.clone();
6062

63+
// When forking an isolation scope, unless we are continuing an incoming trace (identified by a `parentSpanId`),
64+
// we give the freshly forked scope its own trace.
65+
// This way, new root spans in an isolation scope will get separate traces.
66+
const propagationContext = scope.getPropagationContext();
67+
if (!propagationContext.parentSpanId) {
68+
scope.setPropagationContext({
69+
...propagationContext,
70+
traceId: generateTraceId(),
71+
sampleRand: _INTERNAL_safeMathRandom(),
72+
});
73+
}
74+
6175
return asyncStorage.run({ scope, isolationScope }, () => {
6276
return callback(isolationScope);
6377
});

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,32 @@ describe('withIsolationScope()', () => {
156156
done();
157157
});
158158
}));
159+
160+
it('gives each forked isolation scope its own trace id when not continuing an incoming trace', () => {
161+
const traceIds: string[] = [];
162+
163+
withIsolationScope(() => {
164+
traceIds.push(getCurrentScope().getPropagationContext().traceId);
165+
});
166+
withIsolationScope(() => {
167+
traceIds.push(getCurrentScope().getPropagationContext().traceId);
168+
});
169+
170+
expect(traceIds[0]).toMatch(/^[a-f0-9]{32}$/);
171+
expect(traceIds[1]).toMatch(/^[a-f0-9]{32}$/);
172+
expect(traceIds[0]).not.toBe(traceIds[1]);
173+
});
174+
175+
it('keeps the trace id when continuing an incoming trace (parentSpanId set)', () => {
176+
const incomingTraceId = 'cafecafecafecafecafecafecafecafe';
177+
getCurrentScope().setPropagationContext({
178+
traceId: incomingTraceId,
179+
parentSpanId: '1234567890abcdef',
180+
sampleRand: 0.42,
181+
});
182+
183+
withIsolationScope(() => {
184+
expect(getCurrentScope().getPropagationContext().traceId).toBe(incomingTraceId);
185+
});
186+
});
159187
});

packages/vercel-edge/test/middlewareTraceIsolation.test.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { context, propagation, ROOT_CONTEXT, trace } from '@opentelemetry/api';
2-
import { getCurrentScope, getGlobalScope, getIsolationScope, GLOBAL_OBJ, spanToJSON } from '@sentry/core';
2+
import {
3+
getCurrentScope,
4+
getGlobalScope,
5+
getIsolationScope,
6+
GLOBAL_OBJ,
7+
spanToJSON,
8+
withIsolationScope,
9+
} from '@sentry/core';
310
import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry';
411
import { AsyncLocalStorage } from 'async_hooks';
512
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -59,24 +66,28 @@ async function runMiddlewareRequest(
5966
delay = 0,
6067
): Promise<{ traceId: string; childTraceId: string }> {
6168
const request = new Request(url, { method: 'GET', headers });
62-
return withPropagatedContext(request.headers, () =>
63-
nextMiddlewareTrace(new URL(url).pathname, async () => {
64-
const rootSpan = trace.getActiveSpan()!;
65-
const traceId = spanToJSON(rootSpan).trace_id!;
66-
67-
await new Promise(resolve => setTimeout(resolve, delay));
68-
69-
// A child span started within the request must parent onto this request's root span (same trace id), not onto a
70-
// concurrent request's span.
71-
const childTracer = trace.getTracer('user');
72-
const childSpan = childTracer.startSpan('user-work');
73-
const childTraceId = spanToJSON(childSpan as unknown as Parameters<typeof spanToJSON>[0]).trace_id!;
74-
childSpan.end();
75-
76-
await new Promise(resolve => setTimeout(resolve, delay));
77-
78-
return { traceId, childTraceId };
79-
}),
69+
// Mirror `wrapMiddlewareWithSentry`: fork an isolation scope per request, then run Next's
70+
// `withPropagatedContext` + `Middleware.execute` trace inside it.
71+
return withIsolationScope(() =>
72+
withPropagatedContext(request.headers, () =>
73+
nextMiddlewareTrace(new URL(url).pathname, async () => {
74+
const rootSpan = trace.getActiveSpan()!;
75+
const traceId = spanToJSON(rootSpan).trace_id!;
76+
77+
await new Promise(resolve => setTimeout(resolve, delay));
78+
79+
// A child span started within the request must parent onto this request's root span (same trace id), not onto a
80+
// concurrent request's span.
81+
const childTracer = trace.getTracer('user');
82+
const childSpan = childTracer.startSpan('user-work');
83+
const childTraceId = spanToJSON(childSpan as unknown as Parameters<typeof spanToJSON>[0]).trace_id!;
84+
childSpan.end();
85+
86+
await new Promise(resolve => setTimeout(resolve, delay));
87+
88+
return { traceId, childTraceId };
89+
}),
90+
),
8091
);
8192
}
8293

0 commit comments

Comments
 (0)