From 3a14b67596d14a176429eb0bb258a82310ef008c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 11:19:15 +0200 Subject: [PATCH 1/5] fix(core): Do not activate ignored spans in the tracing-channel binding The AsyncLocalStorage tracing-channel binding planted every channel span as the active span, including ignored (`ignoreSpans`) placeholders. Because no span is emitted for an ignored span, its children and outgoing requests then propagated from it instead of the nearest emitted parent, dropping the continued sampling decision. Skip ignored spans so propagation falls back to that parent, matching the OpenTelemetry context manager. Co-Authored-By: Claude Opus 4.8 --- .../asyncContext/tracing-channel-binding.ts | 8 ++++- .../tracing-channel-binding.test.ts | 36 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/core/src/asyncContext/tracing-channel-binding.ts b/packages/core/src/asyncContext/tracing-channel-binding.ts index d692616870b7..d480a20fe2fa 100644 --- a/packages/core/src/asyncContext/tracing-channel-binding.ts +++ b/packages/core/src/asyncContext/tracing-channel-binding.ts @@ -1,5 +1,6 @@ import { getMainCarrier } from '../carrier'; import type { Scope } from '../scope'; +import { spanIsIgnored } from '../tracing/trace'; import { _setSpanForScope } from '../utils/spanOnScope'; import { safeUnref } from '../utils/timer'; import { getAsyncContextStrategy } from './index'; @@ -51,7 +52,12 @@ export function _INTERNAL_createTracingChannelBinding( getStoreWithActiveSpan: span => { const { scope, isolationScope } = getScopes(); const activeScope = scope.clone(); - _setSpanForScope(activeScope, span); + // Do not make an ignored span the active span: no span is emitted for it, so its children and + // outgoing requests must propagate from the nearest emitted parent instead. Mirrors the OTel + // context manager, which likewise skips ignored spans. + if (!spanIsIgnored(span)) { + _setSpanForScope(activeScope, span); + } return { scope: activeScope, isolationScope }; }, diff --git a/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts index a6113d9df2ad..cb1f56299f5c 100644 --- a/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts +++ b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts @@ -1,8 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAsyncContextStrategy, setAsyncContextStrategy } from '../../../src/asyncContext'; -import { waitForTracingChannelBinding } from '../../../src/asyncContext/tracing-channel-binding'; +import { + _INTERNAL_createTracingChannelBinding, + waitForTracingChannelBinding, +} from '../../../src/asyncContext/tracing-channel-binding'; import type { TracingChannelBinding } from '../../../src/asyncContext/types'; import { getMainCarrier } from '../../../src/carrier'; +import { Scope } from '../../../src/scope'; +import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan'; +import { SentrySpan } from '../../../src/tracing/sentrySpan'; +import { _getSpanForScope } from '../../../src/utils/spanOnScope'; const FAKE_BINDING: TracingChannelBinding = { asyncLocalStorage: {}, @@ -121,3 +128,30 @@ describe('waitForTracingChannelBinding', () => { expect(callback).not.toHaveBeenCalled(); }); }); + +describe('_INTERNAL_createTracingChannelBinding', () => { + const scope = new Scope(); + const isolationScope = new Scope(); + const getScopes = (): { scope: Scope; isolationScope: Scope } => ({ scope, isolationScope }); + + it('sets a normal span as the active span on the store scope', () => { + const binding = _INTERNAL_createTracingChannelBinding({}, getScopes); + const span = new SentrySpan({ name: 'test' }); + + const store = binding.getStoreWithActiveSpan(span) as { scope: Scope; isolationScope: Scope }; + + expect(_getSpanForScope(store.scope)).toBe(span); + expect(store.isolationScope).toBe(isolationScope); + }); + + it('does not set an ignored span as the active span', () => { + const binding = _INTERNAL_createTracingChannelBinding({}, getScopes); + const ignoredSpan = new SentryNonRecordingSpan({ dropReason: 'ignored' }); + + const store = binding.getStoreWithActiveSpan(ignoredSpan) as { scope: Scope; isolationScope: Scope }; + + // No span is emitted for an ignored span, so children/outgoing requests must propagate from the + // nearest emitted parent instead of the ignored placeholder. + expect(_getSpanForScope(store.scope)).toBeUndefined(); + }); +}); From 8090a463b318560caeb1a6a21d50835b700908fe Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 11:44:27 +0200 Subject: [PATCH 2/5] Only skip ignored child spans, keep ignored roots active An ignored root span must stay the active span so its whole subtree is dropped with it; only ignored children should be skipped (so their siblings/parent stay the propagation source). Match core startSpan and the OTel context manager with `getRootSpan(span) === span`, and cover both the ignored-child and ignored-root cases. --- .../asyncContext/tracing-channel-binding.ts | 10 +++++--- .../tracing-channel-binding.test.ts | 25 +++++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/core/src/asyncContext/tracing-channel-binding.ts b/packages/core/src/asyncContext/tracing-channel-binding.ts index d480a20fe2fa..6bff45dd7690 100644 --- a/packages/core/src/asyncContext/tracing-channel-binding.ts +++ b/packages/core/src/asyncContext/tracing-channel-binding.ts @@ -2,6 +2,7 @@ import { getMainCarrier } from '../carrier'; import type { Scope } from '../scope'; import { spanIsIgnored } from '../tracing/trace'; import { _setSpanForScope } from '../utils/spanOnScope'; +import { getRootSpan } from '../utils/spanUtils'; import { safeUnref } from '../utils/timer'; import { getAsyncContextStrategy } from './index'; import type { TracingChannelBinding } from './types'; @@ -52,10 +53,11 @@ export function _INTERNAL_createTracingChannelBinding( getStoreWithActiveSpan: span => { const { scope, isolationScope } = getScopes(); const activeScope = scope.clone(); - // Do not make an ignored span the active span: no span is emitted for it, so its children and - // outgoing requests must propagate from the nearest emitted parent instead. Mirrors the OTel - // context manager, which likewise skips ignored spans. - if (!spanIsIgnored(span)) { + // Do not make an ignored *child* the active span: no span is emitted for it, so its children and + // outgoing requests must propagate from the nearest emitted parent instead. An ignored *root* is + // still activated so its whole subtree is dropped with it. Mirrors core `startSpan` and the OTel + // context manager (`spanIsIgnored(span) && getRootSpan(span) !== span`). + if (!spanIsIgnored(span) || getRootSpan(span) === span) { _setSpanForScope(activeScope, span); } diff --git a/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts index cb1f56299f5c..c16831ab1a7d 100644 --- a/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts +++ b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts @@ -10,6 +10,7 @@ import { Scope } from '../../../src/scope'; import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan'; import { SentrySpan } from '../../../src/tracing/sentrySpan'; import { _getSpanForScope } from '../../../src/utils/spanOnScope'; +import { addChildSpanToSpan } from '../../../src/utils/spanUtils'; const FAKE_BINDING: TracingChannelBinding = { asyncLocalStorage: {}, @@ -144,14 +145,28 @@ describe('_INTERNAL_createTracingChannelBinding', () => { expect(store.isolationScope).toBe(isolationScope); }); - it('does not set an ignored span as the active span', () => { + it('does not set an ignored child span as the active span', () => { const binding = _INTERNAL_createTracingChannelBinding({}, getScopes); - const ignoredSpan = new SentryNonRecordingSpan({ dropReason: 'ignored' }); + const parentSpan = new SentrySpan({ name: 'parent' }); + const ignoredChild = new SentryNonRecordingSpan({ dropReason: 'ignored' }); + // Attach the ignored span under a parent so it is a child (its root span is the parent, not itself). + addChildSpanToSpan(parentSpan, ignoredChild); - const store = binding.getStoreWithActiveSpan(ignoredSpan) as { scope: Scope; isolationScope: Scope }; + const store = binding.getStoreWithActiveSpan(ignoredChild) as { scope: Scope; isolationScope: Scope }; - // No span is emitted for an ignored span, so children/outgoing requests must propagate from the - // nearest emitted parent instead of the ignored placeholder. + // No span is emitted for an ignored child, so nested spans/outgoing requests must propagate from + // the nearest emitted parent instead of the ignored placeholder. expect(_getSpanForScope(store.scope)).toBeUndefined(); }); + + it('still sets an ignored root span as the active span so its subtree is dropped', () => { + const binding = _INTERNAL_createTracingChannelBinding({}, getScopes); + // A root ignored span (no parent) stays active, matching core `startSpan`, so its whole subtree is + // dropped rather than its children escaping and being emitted on their own. + const ignoredRoot = new SentryNonRecordingSpan({ dropReason: 'ignored' }); + + const store = binding.getStoreWithActiveSpan(ignoredRoot) as { scope: Scope; isolationScope: Scope }; + + expect(_getSpanForScope(store.scope)).toBe(ignoredRoot); + }); }); From a527560f0045f6f7fde28759b36260132d1372b4 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 14:07:02 +0200 Subject: [PATCH 3/5] Assert the emitted parent stays active for an ignored child span The ignored-child test started from an empty scope and only checked the active span was unset, which does not distinguish "parent dropped" from "parent preserved". Seed the parent as the active span and assert it remains active, covering the actual regression: nested spans and outgoing requests must keep propagating from the parent. --- .../tracing-channel-binding.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts index c16831ab1a7d..9cbfb715413f 100644 --- a/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts +++ b/packages/core/test/lib/asyncContext/tracing-channel-binding.test.ts @@ -9,7 +9,7 @@ import { getMainCarrier } from '../../../src/carrier'; import { Scope } from '../../../src/scope'; import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan'; import { SentrySpan } from '../../../src/tracing/sentrySpan'; -import { _getSpanForScope } from '../../../src/utils/spanOnScope'; +import { _getSpanForScope, _setSpanForScope } from '../../../src/utils/spanOnScope'; import { addChildSpanToSpan } from '../../../src/utils/spanUtils'; const FAKE_BINDING: TracingChannelBinding = { @@ -145,18 +145,22 @@ describe('_INTERNAL_createTracingChannelBinding', () => { expect(store.isolationScope).toBe(isolationScope); }); - it('does not set an ignored child span as the active span', () => { - const binding = _INTERNAL_createTracingChannelBinding({}, getScopes); + it('keeps the emitted parent active for an ignored child span', () => { const parentSpan = new SentrySpan({ name: 'parent' }); + // The parent is already the active span on the scope the binding forks from. + const parentScope = new Scope(); + _setSpanForScope(parentScope, parentSpan); + const binding = _INTERNAL_createTracingChannelBinding({}, () => ({ scope: parentScope, isolationScope })); + const ignoredChild = new SentryNonRecordingSpan({ dropReason: 'ignored' }); - // Attach the ignored span under a parent so it is a child (its root span is the parent, not itself). + // Attach the ignored span under the parent so it is a child (its root span is the parent, not itself). addChildSpanToSpan(parentSpan, ignoredChild); const store = binding.getStoreWithActiveSpan(ignoredChild) as { scope: Scope; isolationScope: Scope }; - // No span is emitted for an ignored child, so nested spans/outgoing requests must propagate from - // the nearest emitted parent instead of the ignored placeholder. - expect(_getSpanForScope(store.scope)).toBeUndefined(); + // No span is emitted for an ignored child, so it must not become the active span; the emitted + // parent stays active so nested spans/outgoing requests keep propagating from it. + expect(_getSpanForScope(store.scope)).toBe(parentSpan); }); it('still sets an ignored root span as the active span so its subtree is dropped', () => { From cfea899717344cf7cd25cf6f990e811f75db4fee Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 14:22:42 +0200 Subject: [PATCH 4/5] Document the ignored-span rule and its parity with the tracer-provider path Explain why an ignored child keeps its parent active (avoids orphaned/misparented downstream spans) while an ignored root stays active (drops its subtree), and note this mirrors the OpenTelemetry `getStoreWithActiveSpan` check so ignoreSpans behaves the same with or without a Sentry tracer provider. --- .../asyncContext/tracing-channel-binding.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/core/src/asyncContext/tracing-channel-binding.ts b/packages/core/src/asyncContext/tracing-channel-binding.ts index 6bff45dd7690..eda4e21b15c3 100644 --- a/packages/core/src/asyncContext/tracing-channel-binding.ts +++ b/packages/core/src/asyncContext/tracing-channel-binding.ts @@ -53,10 +53,23 @@ export function _INTERNAL_createTracingChannelBinding( getStoreWithActiveSpan: span => { const { scope, isolationScope } = getScopes(); const activeScope = scope.clone(); - // Do not make an ignored *child* the active span: no span is emitted for it, so its children and - // outgoing requests must propagate from the nearest emitted parent instead. An ignored *root* is - // still activated so its whole subtree is dropped with it. Mirrors core `startSpan` and the OTel - // context manager (`spanIsIgnored(span) && getRootSpan(span) !== span`). + // Whether an ignored span becomes the active span decides what its descendants and outgoing + // requests propagate from, so it must follow the same rule everywhere: + // + // - Ignored *child*: keep the parent active. No span is emitted for the ignored child, so if it + // became active its descendants would parent to (and propagate a `sentry-trace` referencing) a + // span that never reaches Sentry, producing orphaned/misparented spans downstream. Leaving the + // parent active re-parents them onto the nearest emitted span instead. + // - Ignored *root*: keep it active so the whole subtree is dropped with it. That is the point of + // `ignoreSpans` on a root; not activating it would let its children escape and be emitted as + // standalone spans. + // + // This is the no-tracer-provider (AsyncLocalStorage) counterpart of the tracer-provider path in + // `@sentry/opentelemetry`'s `getStoreWithActiveSpan`, which applies the same + // `spanIsIgnored(span) && getRootSpan(span) !== span` check on the OTel context (it additionally + // consults trace state to carry the "child ignored" decision across process boundaries, which the + // channel binding does not need). Keeping the rule identical means `ignoreSpans` behaves the same + // whether or not Sentry owns an OpenTelemetry tracer provider. if (!spanIsIgnored(span) || getRootSpan(span) === span) { _setSpanForScope(activeScope, span); } From 6b1b3742204126d2f22fd7b9fe144aeb1c0dd264 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 4 Aug 2026 14:24:02 +0200 Subject: [PATCH 5/5] Trim the ignored-span comment; rationale lives in the PR description Keep the code comment to the essential rule and move the longer justification and tracer-provider comparison to the PR description. --- .../asyncContext/tracing-channel-binding.ts | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/core/src/asyncContext/tracing-channel-binding.ts b/packages/core/src/asyncContext/tracing-channel-binding.ts index eda4e21b15c3..6bff45dd7690 100644 --- a/packages/core/src/asyncContext/tracing-channel-binding.ts +++ b/packages/core/src/asyncContext/tracing-channel-binding.ts @@ -53,23 +53,10 @@ export function _INTERNAL_createTracingChannelBinding( getStoreWithActiveSpan: span => { const { scope, isolationScope } = getScopes(); const activeScope = scope.clone(); - // Whether an ignored span becomes the active span decides what its descendants and outgoing - // requests propagate from, so it must follow the same rule everywhere: - // - // - Ignored *child*: keep the parent active. No span is emitted for the ignored child, so if it - // became active its descendants would parent to (and propagate a `sentry-trace` referencing) a - // span that never reaches Sentry, producing orphaned/misparented spans downstream. Leaving the - // parent active re-parents them onto the nearest emitted span instead. - // - Ignored *root*: keep it active so the whole subtree is dropped with it. That is the point of - // `ignoreSpans` on a root; not activating it would let its children escape and be emitted as - // standalone spans. - // - // This is the no-tracer-provider (AsyncLocalStorage) counterpart of the tracer-provider path in - // `@sentry/opentelemetry`'s `getStoreWithActiveSpan`, which applies the same - // `spanIsIgnored(span) && getRootSpan(span) !== span` check on the OTel context (it additionally - // consults trace state to carry the "child ignored" decision across process boundaries, which the - // channel binding does not need). Keeping the rule identical means `ignoreSpans` behaves the same - // whether or not Sentry owns an OpenTelemetry tracer provider. + // Do not make an ignored *child* the active span: no span is emitted for it, so its children and + // outgoing requests must propagate from the nearest emitted parent instead. An ignored *root* is + // still activated so its whole subtree is dropped with it. Mirrors core `startSpan` and the OTel + // context manager (`spanIsIgnored(span) && getRootSpan(span) !== span`). if (!spanIsIgnored(span) || getRootSpan(span) === span) { _setSpanForScope(activeScope, span); }