diff --git a/packages/core/src/asyncContext/tracing-channel-binding.ts b/packages/core/src/asyncContext/tracing-channel-binding.ts index d692616870b7..6bff45dd7690 100644 --- a/packages/core/src/asyncContext/tracing-channel-binding.ts +++ b/packages/core/src/asyncContext/tracing-channel-binding.ts @@ -1,6 +1,8 @@ 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'; @@ -51,7 +53,13 @@ export function _INTERNAL_createTracingChannelBinding( getStoreWithActiveSpan: span => { const { scope, isolationScope } = getScopes(); const activeScope = scope.clone(); - _setSpanForScope(activeScope, 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); + } 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..9cbfb715413f 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,16 @@ 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, _setSpanForScope } from '../../../src/utils/spanOnScope'; +import { addChildSpanToSpan } from '../../../src/utils/spanUtils'; const FAKE_BINDING: TracingChannelBinding = { asyncLocalStorage: {}, @@ -121,3 +129,48 @@ 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('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 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 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', () => { + 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); + }); +});