From 6d2bee4599e435729dd76be2b91c5378b8942b23 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 9 Jun 2026 14:34:23 +0200 Subject: [PATCH] fix(core): Capture scopes on span before emitting `spanStart` event Move `setCapturedScopesOnSpan` into `_startRootSpan` and `_startChildSpan` so the captured scope/isolation scope are present on the span when `spanStart` listeners fire. Previously they were set after `createChildOrRootSpan` returned, so listeners reading `getCapturedScopesOnSpan` (e.g. to fork the isolation scope) saw stale data and their changes were overwritten. --- packages/core/src/tracing/trace.ts | 29 +++++++++--- packages/core/test/lib/tracing/trace.test.ts | 46 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 63f8c05b6a7c..7a31217fe51a 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -378,15 +378,18 @@ function createChildOrRootSpan({ client?.recordDroppedEvent('ignored', 'span'); } - return new SentryNonRecordingSpan({ + const ignoredSpan = new SentryNonRecordingSpan({ dropReason: 'ignored', traceId: parentSpan?.spanContext().traceId ?? scope.getPropagationContext().traceId, }); + setCapturedScopesOnSpan(ignoredSpan, scope, isolationScope); + + return ignoredSpan; } let span: Span; if (parentSpan && !forceTransaction) { - span = _startChildSpan(parentSpan, scope, spanArguments); + span = _startChildSpan(parentSpan, scope, spanArguments, isolationScope); addChildSpanToSpan(parentSpan, span); } else if (parentSpan) { // If we forced a transaction but have a parent span, make sure to continue from the parent span, not the scope @@ -401,6 +404,7 @@ function createChildOrRootSpan({ ...spanArguments, }, scope, + isolationScope, parentSampled, ); @@ -423,6 +427,7 @@ function createChildOrRootSpan({ ...spanArguments, }, scope, + isolationScope, parentSampled, ); @@ -433,8 +438,6 @@ function createChildOrRootSpan({ logSpanStart(span); - setCapturedScopesOnSpan(span, scope, isolationScope); - return span; } @@ -465,7 +468,12 @@ function getAcs(): AsyncContextStrategy { return getAsyncContextStrategy(carrier); } -function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parentSampled?: boolean): SentrySpan { +function _startRootSpan( + spanArguments: SentrySpanArguments, + scope: Scope, + isolationScope: Scope, + parentSampled?: boolean, +): SentrySpan { const client = getClient(); const options: Partial = client?.getOptions() || {}; @@ -512,6 +520,8 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent client.recordDroppedEvent('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction'); } + setCapturedScopesOnSpan(rootSpan, scope, isolationScope); + if (client) { client.emit('spanStart', rootSpan); } @@ -523,7 +533,12 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent * Creates a new `Span` while setting the current `Span.id` as `parentSpanId`. * This inherits the sampling decision from the parent span. */ -function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySpanArguments): Span { +function _startChildSpan( + parentSpan: Span, + scope: Scope, + spanArguments: SentrySpanArguments, + isolationScope: Scope, +): Span { const { spanId, traceId } = parentSpan.spanContext(); const isTracingSuppressed = _isTracingSuppressed(scope); const sampled = isTracingSuppressed ? false : spanIsSampled(parentSpan); @@ -539,6 +554,8 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp addChildSpanToSpan(parentSpan, childSpan); + setCapturedScopesOnSpan(childSpan, scope, isolationScope); + const client = getClient(); if (!client) { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 138dc3b2d0ee..f2e605a7e4de 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2398,6 +2398,36 @@ describe('span hooks', () => { expect(startedSpans).toEqual(['span1', 'span2', 'span3', 'span5', 'span4']); expect(endedSpans).toEqual(['span5', 'span3', 'span2', 'span1']); }); + + it('captures scopes on a root span before the spanStart event fires', () => { + let scopeAtSpanStart: Scope | undefined; + let isolationScopeAtSpanStart: Scope | undefined; + client.on('spanStart', span => { + scopeAtSpanStart = getCapturedScopesOnSpan(span).scope; + isolationScopeAtSpanStart = getCapturedScopesOnSpan(span).isolationScope; + }); + + startInactiveSpan({ name: 'root span' }); + + expect(scopeAtSpanStart).toBe(getCurrentScope()); + expect(isolationScopeAtSpanStart).toBe(getIsolationScope()); + }); + + it('captures scopes on a child span before the spanStart event fires', () => { + let scopeAtSpanStart: Scope | undefined; + let isolationScopeAtSpanStart: Scope | undefined; + client.on('spanStart', span => { + scopeAtSpanStart = getCapturedScopesOnSpan(span).scope; + isolationScopeAtSpanStart = getCapturedScopesOnSpan(span).isolationScope; + }); + + startSpan({ name: 'parent span' }, () => { + startInactiveSpan({ name: 'child span' }); + + expect(scopeAtSpanStart).toBe(getCurrentScope()); + expect(isolationScopeAtSpanStart).toBe(getIsolationScope()); + }); + }); }); describe('suppressTracing', () => { @@ -2887,4 +2917,20 @@ describe('ignoreSpans (core path, streaming)', () => { expect(span.spanContext().traceId).toBe(getCurrentScope().getPropagationContext().traceId); expect(span.spanContext().traceId).toBe('abc'); }); + + it('captures scopes on an ignored streamed span so its DSC can be resolved from the scope', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['ignored'], + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + const span = startInactiveSpan({ name: 'ignored' }); + + expect(getCapturedScopesOnSpan(span).scope).toBe(getCurrentScope()); + expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); + }); });