From cdb427c2227f70fd9862e6de4850dbdfd5816d9a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 29 Jun 2026 13:16:33 +0200 Subject: [PATCH 1/9] Defer segment-span transaction capture with a debounced timer Add per-client deferral of the segment-span transaction capture. The transaction is otherwise assembled synchronously from the live span tree when the root span ends, dropping child spans whose instrumentation closes them after it - in the same tick (diagnostics-channel `asyncEnd`) or on a later tick (e.g. prisma engine spans). When a client opts in via `_INTERNAL_setDeferSegmentSpanCapture`, a debounced timer (the one the OpenTelemetry span exporter uses) delays the snapshot so those children land first, and drains on the client `flush` hook so `Sentry.flush()` / `close()` stays safe. The browser keeps its synchronous capture. The opt-in call is wired separately (the Node SDK enables it on the SentryTracerProvider path). --- packages/core/src/tracing/index.ts | 2 +- packages/core/src/tracing/sentrySpan.ts | 84 +++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index abdb104e33d5..fd5391c71bb5 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -10,7 +10,7 @@ export { spanIsTracerProviderSpan, } from './utils'; export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; -export { SentrySpan } from './sentrySpan'; +export { SentrySpan, _INTERNAL_setDeferSegmentSpanCapture } from './sentrySpan'; export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; export { setHttpStatus, getSpanStatusFromHttpCode } from './spanstatus'; export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstatus'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8bf052ff5b64..2080f013f949 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import type { Client } from '../client'; import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { createSpanEnvelope } from '../envelope'; @@ -26,7 +27,9 @@ import type { } from '../types/span'; import type { SpanStatus } from '../types/spanStatus'; import type { TimedEvent } from '../types/timedEvent'; +import { debounce } from '../utils/debounce'; import { debug } from '../utils/debug-logger'; +import { isBrowser } from '../utils/isBrowser'; import { generateSpanId, generateTraceId } from '../utils/propagationContext'; import { addStatusMessageAttribute, @@ -51,6 +54,58 @@ import { getCapturedScopesOnSpan, markSpanSourceAsExplicit, spanShouldInferOtelS const MAX_SPAN_COUNT = 1000; +// Clients whose segment-span transaction capture should be deferred (rather than run synchronously on +// span end), mapped to the function that queues a deferred capture. Tracked per client rather than as +// a process-wide flag so pending captures and their timer cannot leak across `Sentry.init()` calls — +// a client that never opts in is simply absent. Enabled per client by SDKs that assemble transactions +// from the live span tree on root-span end (e.g. the Node SDK), which would otherwise drop children +// that close after it. Every other setup keeps its synchronous capture. +const DEFERRED_SEGMENT_SPAN_CAPTURES = new WeakMap void) => void>(); + +/** + * Opt a client into (or out of) deferring its segment-span transaction capture. + * Set by the SDK client during setup (e.g. the Node SDK); see {@link DEFERRED_SEGMENT_SPAN_CAPTURES}. + * + * The transaction is otherwise assembled from the live span tree the instant a root span ends, which + * drops children whose async instrumentation closes them later (a diagnostics-channel `asyncEnd` + * callback in the same tick, or engine spans replayed on a later tick). A debounced timer — the same + * one the OpenTelemetry span exporter uses — delays the snapshot just enough for those later span ends + * to land first. Pending captures are drained synchronously on the client's `flush` hook so + * `Sentry.flush()` / `client.close()` cannot resolve before they run. + */ +export function _INTERNAL_setDeferSegmentSpanCapture(client: Client, defer: boolean): void { + if (!defer) { + DEFERRED_SEGMENT_SPAN_CAPTURES.delete(client); + return; + } + + if (DEFERRED_SEGMENT_SPAN_CAPTURES.has(client)) { + return; + } + + const pendingCaptures = new Set<() => void>(); + const debouncedDrain = debounce( + () => { + const captures = [...pendingCaptures]; + pendingCaptures.clear(); + for (const capture of captures) { + capture(); + } + }, + 1, + { maxWait: 100 }, + ); + + client.on('flush', () => { + debouncedDrain.flush(); + }); + + DEFERRED_SEGMENT_SPAN_CAPTURES.set(client, capture => { + pendingCaptures.add(capture); + debouncedDrain(); + }); +} + /** * Span contains all data about a span */ @@ -367,10 +422,31 @@ export class SentrySpan implements Span { return; } - const transactionEvent = this._convertSpanToTransaction(); - if (transactionEvent) { - const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); - scope.captureEvent(transactionEvent); + const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); + + // The transaction is assembled synchronously from the live span tree the instant the root span + // ends, dropping children whose async instrumentation closes them after it (a diagnostics-channel + // `asyncEnd` callback in the same tick, or engine spans replayed on a later tick). Clients that + // opted in defer the snapshot via a debounced timer so those later span ends land first; every + // other setup keeps its synchronous capture. Never deferred in the browser, where there is no such + // pattern and a deferred capture could be lost on page unload. + const deferCapture = client && DEFERRED_SEGMENT_SPAN_CAPTURES.get(client); + if (client && deferCapture && !isBrowser()) { + deferCapture(() => { + const transactionEvent = this._convertSpanToTransaction(); + if (transactionEvent) { + // Capture through the client resolved when the span ended, not the scope: a capture that + // fires on a later tick must reach the client active at span end and never whatever client + // is current when the timer fires (e.g. a different client after re-init), and the scope's + // client reference can be reassigned. Only the snapshot is deferred, so late children land. + client.captureEvent(transactionEvent, undefined, scope); + } + }); + } else { + const transactionEvent = this._convertSpanToTransaction(); + if (transactionEvent) { + scope.captureEvent(transactionEvent); + } } } From aff471181d2aa08cf6d11c01f24639566b1273a3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Sat, 27 Jun 2026 18:11:03 +0200 Subject: [PATCH 2/9] Emit late-ending child spans as orphan transactions instead of dropping them --- packages/core/src/tracing/sentrySpan.ts | 56 +++++++++++++++++++++---- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 2080f013f949..69f436c490d3 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -62,6 +62,11 @@ const MAX_SPAN_COUNT = 1000; // that close after it. Every other setup keeps its synchronous capture. const DEFERRED_SEGMENT_SPAN_CAPTURES = new WeakMap void) => void>(); +// Spans already included in a captured transaction. Used so a child that ends after its root segment +// was captured can be emitted as its own orphan transaction (see `_onSpanEnded`) without any span ever +// being sent in more than one transaction. +const CAPTURED_SPANS = new WeakSet(); + /** * Opt a client into (or out of) deferring its segment-span transaction capture. * Set by the SDK client during setup (e.g. the Node SDK); see {@link DEFERRED_SEGMENT_SPAN_CAPTURES}. @@ -398,9 +403,24 @@ export class SentrySpan implements Span { // A segment span is basically the root span of a local span tree. // So for now, this is either what we previously refer to as the root span, // or a standalone span. - const isSegmentSpan = this._isStandaloneSpan || this === getRootSpan(this); - - if (!isSegmentSpan) { + const rootSpan = getRootSpan(this); + const isSegmentSpan = this._isStandaloneSpan || this === rootSpan; + + // A child span that ends after its root segment's transaction was already captured can no longer be + // part of it. Mirror the OpenTelemetry span exporter, which emits such a late child as its own + // (orphan) transaction in the same trace instead of dropping it. Only for clients that defer the + // segment capture (the SentryTracerProvider, the no-exporter native-assembly path); other setups + // keep the synchronous drop. `CAPTURED_SPANS` is only populated during a non-streaming capture, so + // this stays inert under span streaming (where late children stream individually). + const isOrphanSegment = + !isSegmentSpan && + !!client && + !!DEFERRED_SEGMENT_SPAN_CAPTURES.get(client) && + !isBrowser() && + !CAPTURED_SPANS.has(this) && + CAPTURED_SPANS.has(rootSpan); + + if (!isSegmentSpan && !isOrphanSegment) { return; } @@ -433,7 +453,7 @@ export class SentrySpan implements Span { const deferCapture = client && DEFERRED_SEGMENT_SPAN_CAPTURES.get(client); if (client && deferCapture && !isBrowser()) { deferCapture(() => { - const transactionEvent = this._convertSpanToTransaction(); + const transactionEvent = this._convertSpanToTransaction({ orphanedFromSentParent: isOrphanSegment }); if (transactionEvent) { // Capture through the client resolved when the span ended, not the scope: a capture that // fires on a later tick must reach the client active at span end and never whatever client @@ -453,7 +473,7 @@ export class SentrySpan implements Span { /** * Finish the transaction & prepare the event to send to Sentry. */ - private _convertSpanToTransaction(): TransactionEvent | undefined { + private _convertSpanToTransaction(options: { orphanedFromSentParent?: boolean } = {}): TransactionEvent | undefined { // We can only convert finished spans if (!isFullFinishedSpan(spanToJSON(this))) { return undefined; @@ -472,10 +492,22 @@ export class SentrySpan implements Span { return undefined; } - // The transaction span itself as well as any potential standalone spans should be filtered out - const finishedSpans = getSpanDescendants(this).filter(span => span !== this && !isStandaloneSpan(span)); - - const spans = finishedSpans.map(span => spanToJSON(span)).filter(isFullFinishedSpan); + // Skip the transaction span itself, standalone spans, and spans already sent in another transaction. + // Marking everything we send as captured lets a child that ends later be emitted as its own orphan + // transaction (see `_onSpanEnded`) instead of being dropped or sent twice. + CAPTURED_SPANS.add(this); + const spans: SpanJSON[] = []; + for (const descendant of getSpanDescendants(this)) { + if (descendant === this || isStandaloneSpan(descendant) || CAPTURED_SPANS.has(descendant)) { + continue; + } + const spanJSON = spanToJSON(descendant); + if (!isFullFinishedSpan(spanJSON)) { + continue; + } + CAPTURED_SPANS.add(descendant); + spans.push(spanJSON); + } const source = this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; @@ -519,6 +551,12 @@ export class SentrySpan implements Span { }), }; + // Mirror the OpenTelemetry span exporter: tag a transaction whose parent span was already sent (an + // orphan emitted from `_onSpanEnded`) so it can be distinguished downstream. + if (options.orphanedFromSentParent && transaction.contexts?.trace?.data) { + transaction.contexts.trace.data['sentry.parent_span_already_sent'] = true; + } + const measurements = timedEventsToMeasurements(this._events); const hasMeasurements = measurements && Object.keys(measurements).length; From f3cc97dd4d7d14ad02e70c29e5ab00d09295a75b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 29 Jun 2026 16:09:47 +0200 Subject: [PATCH 3/9] Make segment-span deferral enable-only --- packages/core/src/tracing/sentrySpan.ts | 18 +++++++----------- .../core/test/lib/tracing/sentrySpan.test.ts | 14 +++++++++++++- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 69f436c490d3..a33638488fc2 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -68,22 +68,18 @@ const DEFERRED_SEGMENT_SPAN_CAPTURES = new WeakMap void) const CAPTURED_SPANS = new WeakSet(); /** - * Opt a client into (or out of) deferring its segment-span transaction capture. - * Set by the SDK client during setup (e.g. the Node SDK); see {@link DEFERRED_SEGMENT_SPAN_CAPTURES}. + * Defer a client's segment-span transaction capture. Set once by the SDK during setup (e.g. the Node + * SDK); see {@link DEFERRED_SEGMENT_SPAN_CAPTURES}. Idempotent, and deferral stays on for the client's + * lifetime (there is no opt-out: deferral is a set-once-at-setup property, never toggled mid-session). * * The transaction is otherwise assembled from the live span tree the instant a root span ends, which * drops children whose async instrumentation closes them later (a diagnostics-channel `asyncEnd` - * callback in the same tick, or engine spans replayed on a later tick). A debounced timer — the same - * one the OpenTelemetry span exporter uses — delays the snapshot just enough for those later span ends - * to land first. Pending captures are drained synchronously on the client's `flush` hook so + * callback in the same tick, or engine spans replayed on a later tick). A debounced timer (the same one + * the OpenTelemetry span exporter uses) delays the snapshot just enough for those later span ends to + * land first. Pending captures are drained synchronously on the client's `flush` hook so * `Sentry.flush()` / `client.close()` cannot resolve before they run. */ -export function _INTERNAL_setDeferSegmentSpanCapture(client: Client, defer: boolean): void { - if (!defer) { - DEFERRED_SEGMENT_SPAN_CAPTURES.delete(client); - return; - } - +export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { if (DEFERRED_SEGMENT_SPAN_CAPTURES.has(client)) { return; } diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 26acdb660e53..7cc0af3d63a0 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, test, vi } from 'vitest'; import { getCurrentScope } from '../../../src/currentScopes'; import { setCurrentClient } from '../../../src/sdk'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../../../src/semanticAttributes'; -import { SentrySpan } from '../../../src/tracing/sentrySpan'; +import { _INTERNAL_setDeferSegmentSpanCapture, SentrySpan } from '../../../src/tracing/sentrySpan'; import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus'; import { markSpanForOtelSourceInference, spanSourceWasExplicitlySet } from '../../../src/tracing/utils'; import type { SpanJSON } from '../../../src/types/span'; @@ -132,6 +132,18 @@ describe('SentrySpan', () => { }); }); + describe('_INTERNAL_setDeferSegmentSpanCapture', () => { + it('registers the flush listener once and is idempotent on repeated enable', () => { + const client = new TestClient(getDefaultTestClientOptions()); + const onSpy = vi.spyOn(client, 'on'); + + _INTERNAL_setDeferSegmentSpanCapture(client); + _INTERNAL_setDeferSegmentSpanCapture(client); + + expect(onSpy.mock.calls.filter(([hook]) => hook === 'flush')).toHaveLength(1); + }); + }); + describe('end', () => { test('simple', () => { const span = new SentrySpan({}); From 6889d846f764507d5445be518e951fbb96f0a9e7 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 1 Jul 2026 12:54:20 +0200 Subject: [PATCH 4/9] Move deferred segment-span capture behind a tree-shakeable strategy seam Extract the defer/orphan machinery (per-client queues, debounced drain, flush wiring, orphan detection, the CAPTURED_SPANS set) out of SentrySpan into a node-only deferSegmentSpanCapture module, registered through a carrier-based strategy seam that mirrors set/getAsyncContextStrategy. SentrySpan reads the seam and captures synchronously when none is registered, so browser bundles that never register the strategy tree-shake the machinery away. --- packages/core/src/carrier.ts | 4 + .../src/tracing/deferSegmentSpanCapture.ts | 125 +++++++++++++++++ packages/core/src/tracing/index.ts | 3 +- .../src/tracing/segmentSpanCaptureStrategy.ts | 42 ++++++ packages/core/src/tracing/sentrySpan.ts | 132 ++++-------------- .../tracing/deferSegmentSpanCapture.test.ts | 31 ++++ .../core/test/lib/tracing/sentrySpan.test.ts | 14 +- 7 files changed, 231 insertions(+), 120 deletions(-) create mode 100644 packages/core/src/tracing/deferSegmentSpanCapture.ts create mode 100644 packages/core/src/tracing/segmentSpanCaptureStrategy.ts create mode 100644 packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 70905e69ab94..94ca87dc1ef1 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -2,6 +2,7 @@ import type { AsyncContextStack } from './asyncContext/stackStrategy'; import type { AsyncContextStrategy } from './asyncContext/types'; import type { Client } from './client'; import type { Scope } from './scope'; +import type { SegmentSpanCaptureStrategy } from './tracing/segmentSpanCaptureStrategy'; import type { SerializedLog } from './types/log'; import type { SerializedMetric } from './types/metric'; import { SDK_VERSION } from './utils/version'; @@ -39,6 +40,9 @@ export interface SentryCarrier { */ clientToMetricBufferMap?: WeakMap>; + /** Strategy for assembling segment spans into transactions; set by SDKs that defer capture. */ + segmentSpanCaptureStrategy?: SegmentSpanCaptureStrategy; + /** Overwrites TextEncoder used in `@sentry/core`, need for `react-native@0.73` and older */ encodePolyfill?: (input: string) => Uint8Array; /** Overwrites TextDecoder used in `@sentry/core`, need for `react-native@0.73` and older */ diff --git a/packages/core/src/tracing/deferSegmentSpanCapture.ts b/packages/core/src/tracing/deferSegmentSpanCapture.ts new file mode 100644 index 000000000000..7585efbef63f --- /dev/null +++ b/packages/core/src/tracing/deferSegmentSpanCapture.ts @@ -0,0 +1,125 @@ +import type { Client } from '../client'; +import { getCurrentScope } from '../currentScopes'; +import type { Scope } from '../scope'; +import type { Span } from '../types/span'; +import { debounce } from '../utils/debounce'; +import { + getSegmentSpanCaptureStrategy, + type SegmentSpanConverter, + setSegmentSpanCaptureStrategy, +} from './segmentSpanCaptureStrategy'; +import { getCapturedScopesOnSpan } from './utils'; + +// Spans already sent in a transaction, so a child ending after its segment can be emitted as its own +// orphan transaction instead of being dropped or sent twice. +const CAPTURED_SPANS = new WeakSet(); + +const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPANS.has(span); +const markSpanCaptured = (span: Span): void => { + CAPTURED_SPANS.add(span); +}; + +// Per-client so each client's flush/close drains only its own captures: one client's flush must not +// snapshot another's transaction early. Mirrors the per-client log/metric buffers. +const CLIENT_QUEUES = new WeakMap(); + +interface DeferredCaptureQueue { + enqueue: (capture: () => void) => void; + flush: () => void; +} + +/** + * @private Private API with no semver guarantees! + * + * Enable deferred segment-span transaction capture for a client (idempotent per client). Deferring the + * snapshot lets children that close just after their segment still land in the transaction; pending + * captures drain on `flush`, so `Sentry.flush()` / `client.close()` cannot resolve before they run. + */ +export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { + if (!getSegmentSpanCaptureStrategy()) { + setSegmentSpanCaptureStrategy(deferredSegmentSpanCaptureStrategy); + } + // A client that never opts in has no queue and falls back to synchronous capture below. + getClientQueue(client); +} + +const deferredSegmentSpanCaptureStrategy = { + onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void { + const queue = CLIENT_QUEUES.get(client); + if (!queue) { + // Client never opted into deferral: capture synchronously, exactly as if no strategy existed. + const transactionEvent = convert(); + if (transactionEvent) { + scope.captureEvent(transactionEvent); + } + return; + } + + queue.enqueue(() => { + const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); + if (transactionEvent) { + // Capture via the client active at span end (passing its scope for context), so a later-tick + // capture reaches that client even if the current client changed since (e.g. after re-init). + client.captureEvent(transactionEvent, undefined, scope); + } + }); + }, + + onChildSpanEnded(span: Span, rootSpan: Span, client: Client, convert: SegmentSpanConverter): void { + const queue = CLIENT_QUEUES.get(client); + // Only a late child of an already-captured segment is an orphan. Inert under span streaming, where + // `CAPTURED_SPANS` is never populated. + if (!queue || CAPTURED_SPANS.has(span) || !CAPTURED_SPANS.has(rootSpan)) { + return; + } + + const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); + queue.enqueue(() => { + const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); + if (transactionEvent?.contexts?.trace?.data) { + // Tag orphans so they're distinguishable downstream (mirrors the OTel span exporter). + transactionEvent.contexts.trace.data['sentry.parent_span_already_sent'] = true; + } + if (transactionEvent) { + client.captureEvent(transactionEvent, undefined, scope); + } + }); + }, +}; + +function getClientQueue(client: Client): DeferredCaptureQueue { + const existing = CLIENT_QUEUES.get(client); + if (existing) { + return existing; + } + + const pendingCaptures = new Set<() => void>(); + const debouncedDrain = debounce( + () => { + const captures = [...pendingCaptures]; + pendingCaptures.clear(); + for (const capture of captures) { + capture(); + } + }, + 1, + { maxWait: 100 }, + ); + + const queue: DeferredCaptureQueue = { + enqueue: capture => { + pendingCaptures.add(capture); + debouncedDrain(); + }, + flush: () => { + debouncedDrain.flush(); + }, + }; + + client.on('flush', () => { + queue.flush(); + }); + + CLIENT_QUEUES.set(client, queue); + return queue; +} diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index fd5391c71bb5..c06373d61f56 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -10,7 +10,8 @@ export { spanIsTracerProviderSpan, } from './utils'; export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; -export { SentrySpan, _INTERNAL_setDeferSegmentSpanCapture } from './sentrySpan'; +export { SentrySpan } from './sentrySpan'; +export { _INTERNAL_setDeferSegmentSpanCapture } from './deferSegmentSpanCapture'; export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; export { setHttpStatus, getSpanStatusFromHttpCode } from './spanstatus'; export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstatus'; diff --git a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts new file mode 100644 index 000000000000..1feae770a28d --- /dev/null +++ b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts @@ -0,0 +1,42 @@ +import { getMainCarrier, getSentryCarrier } from '../carrier'; +import type { Client } from '../client'; +import type { Scope } from '../scope'; +import type { TransactionEvent } from '../types/event'; +import type { Span } from '../types/span'; + +/** + * Optional hooks a deferring strategy passes when converting: skip spans already sent, record the ones + * it sends (for orphan tracking). The synchronous default passes neither. + */ +export interface SegmentSpanCaptureConvertOptions { + isSpanAlreadyCaptured?: (span: Span) => boolean; + onSpanCaptured?: (span: Span) => void; +} + +export type SegmentSpanConverter = (options?: SegmentSpanCaptureConvertOptions) => TransactionEvent | undefined; + +/** + * Assembles segment spans into transactions. Registered by SDKs that defer capture (see + * `_INTERNAL_setDeferSegmentSpanCapture`); when unset, `SentrySpan` captures synchronously. Living + * behind this seam tree-shakes the deferral machinery out of SDKs that never register one (e.g. browser). + */ +export interface SegmentSpanCaptureStrategy { + /** Assemble and capture a segment (root or standalone-root) span's transaction. */ + onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void; + /** Consider a child that ended after its segment for emission as its own orphan transaction. */ + onChildSpanEnded(span: Span, rootSpan: Span, client: Client, convert: SegmentSpanConverter): void; +} + +/** + * @private Private API with no semver guarantees! + * + * Set the global segment-span capture strategy (or clear it with `undefined`). + */ +export function setSegmentSpanCaptureStrategy(strategy: SegmentSpanCaptureStrategy | undefined): void { + getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy = strategy; +} + +/** Get the global segment-span capture strategy, or `undefined` when none is registered. */ +export function getSegmentSpanCaptureStrategy(): SegmentSpanCaptureStrategy | undefined { + return getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy; +} diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index a33638488fc2..2934ba39853f 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines */ -import type { Client } from '../client'; import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { createSpanEnvelope } from '../envelope'; @@ -27,9 +26,7 @@ import type { } from '../types/span'; import type { SpanStatus } from '../types/spanStatus'; import type { TimedEvent } from '../types/timedEvent'; -import { debounce } from '../utils/debounce'; import { debug } from '../utils/debug-logger'; -import { isBrowser } from '../utils/isBrowser'; import { generateSpanId, generateTraceId } from '../utils/propagationContext'; import { addStatusMessageAttribute, @@ -49,64 +46,12 @@ import { timestampInSeconds } from '../utils/time'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; +import { getSegmentSpanCaptureStrategy, type SegmentSpanCaptureConvertOptions } from './segmentSpanCaptureStrategy'; import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { getCapturedScopesOnSpan, markSpanSourceAsExplicit, spanShouldInferOtelSource } from './utils'; const MAX_SPAN_COUNT = 1000; -// Clients whose segment-span transaction capture should be deferred (rather than run synchronously on -// span end), mapped to the function that queues a deferred capture. Tracked per client rather than as -// a process-wide flag so pending captures and their timer cannot leak across `Sentry.init()` calls — -// a client that never opts in is simply absent. Enabled per client by SDKs that assemble transactions -// from the live span tree on root-span end (e.g. the Node SDK), which would otherwise drop children -// that close after it. Every other setup keeps its synchronous capture. -const DEFERRED_SEGMENT_SPAN_CAPTURES = new WeakMap void) => void>(); - -// Spans already included in a captured transaction. Used so a child that ends after its root segment -// was captured can be emitted as its own orphan transaction (see `_onSpanEnded`) without any span ever -// being sent in more than one transaction. -const CAPTURED_SPANS = new WeakSet(); - -/** - * Defer a client's segment-span transaction capture. Set once by the SDK during setup (e.g. the Node - * SDK); see {@link DEFERRED_SEGMENT_SPAN_CAPTURES}. Idempotent, and deferral stays on for the client's - * lifetime (there is no opt-out: deferral is a set-once-at-setup property, never toggled mid-session). - * - * The transaction is otherwise assembled from the live span tree the instant a root span ends, which - * drops children whose async instrumentation closes them later (a diagnostics-channel `asyncEnd` - * callback in the same tick, or engine spans replayed on a later tick). A debounced timer (the same one - * the OpenTelemetry span exporter uses) delays the snapshot just enough for those later span ends to - * land first. Pending captures are drained synchronously on the client's `flush` hook so - * `Sentry.flush()` / `client.close()` cannot resolve before they run. - */ -export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { - if (DEFERRED_SEGMENT_SPAN_CAPTURES.has(client)) { - return; - } - - const pendingCaptures = new Set<() => void>(); - const debouncedDrain = debounce( - () => { - const captures = [...pendingCaptures]; - pendingCaptures.clear(); - for (const capture of captures) { - capture(); - } - }, - 1, - { maxWait: 100 }, - ); - - client.on('flush', () => { - debouncedDrain.flush(); - }); - - DEFERRED_SEGMENT_SPAN_CAPTURES.set(client, capture => { - pendingCaptures.add(capture); - debouncedDrain(); - }); -} - /** * Span contains all data about a span */ @@ -402,24 +347,6 @@ export class SentrySpan implements Span { const rootSpan = getRootSpan(this); const isSegmentSpan = this._isStandaloneSpan || this === rootSpan; - // A child span that ends after its root segment's transaction was already captured can no longer be - // part of it. Mirror the OpenTelemetry span exporter, which emits such a late child as its own - // (orphan) transaction in the same trace instead of dropping it. Only for clients that defer the - // segment capture (the SentryTracerProvider, the no-exporter native-assembly path); other setups - // keep the synchronous drop. `CAPTURED_SPANS` is only populated during a non-streaming capture, so - // this stays inert under span streaming (where late children stream individually). - const isOrphanSegment = - !isSegmentSpan && - !!client && - !!DEFERRED_SEGMENT_SPAN_CAPTURES.get(client) && - !isBrowser() && - !CAPTURED_SPANS.has(this) && - CAPTURED_SPANS.has(rootSpan); - - if (!isSegmentSpan && !isOrphanSegment) { - return; - } - // if this is a standalone span, we send it immediately if (this._isStandaloneSpan) { if (this._sampled) { @@ -432,7 +359,20 @@ export class SentrySpan implements Span { } } return; - } else if (client && hasSpanStreamingEnabled(client)) { + } + + // Non-segment children aren't captured on their own. A registered strategy may re-emit a late child + // as its own orphan transaction; without one, it's dropped. + if (!isSegmentSpan) { + if (client) { + getSegmentSpanCaptureStrategy()?.onChildSpanEnded(this, rootSpan, client, options => + this._convertSpanToTransaction(options), + ); + } + return; + } + + if (client && hasSpanStreamingEnabled(client)) { // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans client.emit('afterSegmentSpanEnd', this); return; @@ -440,24 +380,11 @@ export class SentrySpan implements Span { const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); - // The transaction is assembled synchronously from the live span tree the instant the root span - // ends, dropping children whose async instrumentation closes them after it (a diagnostics-channel - // `asyncEnd` callback in the same tick, or engine spans replayed on a later tick). Clients that - // opted in defer the snapshot via a debounced timer so those later span ends land first; every - // other setup keeps its synchronous capture. Never deferred in the browser, where there is no such - // pattern and a deferred capture could be lost on page unload. - const deferCapture = client && DEFERRED_SEGMENT_SPAN_CAPTURES.get(client); - if (client && deferCapture && !isBrowser()) { - deferCapture(() => { - const transactionEvent = this._convertSpanToTransaction({ orphanedFromSentParent: isOrphanSegment }); - if (transactionEvent) { - // Capture through the client resolved when the span ended, not the scope: a capture that - // fires on a later tick must reach the client active at span end and never whatever client - // is current when the timer fires (e.g. a different client after re-init), and the scope's - // client reference can be reassigned. Only the snapshot is deferred, so late children land. - client.captureEvent(transactionEvent, undefined, scope); - } - }); + // A registered strategy defers the snapshot so children closing just after the segment still land + // (and late ones can orphan); without one, assemble synchronously from the live tree. + const strategy = client && getSegmentSpanCaptureStrategy(); + if (strategy) { + strategy.onSegmentSpanEnded(scope, client, options => this._convertSpanToTransaction(options)); } else { const transactionEvent = this._convertSpanToTransaction(); if (transactionEvent) { @@ -469,7 +396,7 @@ export class SentrySpan implements Span { /** * Finish the transaction & prepare the event to send to Sentry. */ - private _convertSpanToTransaction(options: { orphanedFromSentParent?: boolean } = {}): TransactionEvent | undefined { + private _convertSpanToTransaction(options: SegmentSpanCaptureConvertOptions = {}): TransactionEvent | undefined { // We can only convert finished spans if (!isFullFinishedSpan(spanToJSON(this))) { return undefined; @@ -488,20 +415,19 @@ export class SentrySpan implements Span { return undefined; } - // Skip the transaction span itself, standalone spans, and spans already sent in another transaction. - // Marking everything we send as captured lets a child that ends later be emitted as its own orphan - // transaction (see `_onSpanEnded`) instead of being dropped or sent twice. - CAPTURED_SPANS.add(this); + // Skip the span itself, standalone spans, and (when a strategy tracks it) spans already sent. The + // synchronous default passes no hooks, so this bookkeeping stays out of SDKs that don't defer. + options.onSpanCaptured?.(this); const spans: SpanJSON[] = []; for (const descendant of getSpanDescendants(this)) { - if (descendant === this || isStandaloneSpan(descendant) || CAPTURED_SPANS.has(descendant)) { + if (descendant === this || isStandaloneSpan(descendant) || options.isSpanAlreadyCaptured?.(descendant)) { continue; } const spanJSON = spanToJSON(descendant); if (!isFullFinishedSpan(spanJSON)) { continue; } - CAPTURED_SPANS.add(descendant); + options.onSpanCaptured?.(descendant); spans.push(spanJSON); } @@ -547,12 +473,6 @@ export class SentrySpan implements Span { }), }; - // Mirror the OpenTelemetry span exporter: tag a transaction whose parent span was already sent (an - // orphan emitted from `_onSpanEnded`) so it can be distinguished downstream. - if (options.orphanedFromSentParent && transaction.contexts?.trace?.data) { - transaction.contexts.trace.data['sentry.parent_span_already_sent'] = true; - } - const measurements = timedEventsToMeasurements(this._events); const hasMeasurements = measurements && Object.keys(measurements).length; diff --git a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts new file mode 100644 index 000000000000..1daf3835bb0d --- /dev/null +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { _INTERNAL_setDeferSegmentSpanCapture } from '../../../src/tracing/deferSegmentSpanCapture'; +import { + getSegmentSpanCaptureStrategy, + setSegmentSpanCaptureStrategy, +} from '../../../src/tracing/segmentSpanCaptureStrategy'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('_INTERNAL_setDeferSegmentSpanCapture', () => { + afterEach(() => { + setSegmentSpanCaptureStrategy(undefined); + }); + + it('registers the global capture strategy', () => { + expect(getSegmentSpanCaptureStrategy()).toBeUndefined(); + + _INTERNAL_setDeferSegmentSpanCapture(new TestClient(getDefaultTestClientOptions())); + + expect(getSegmentSpanCaptureStrategy()).toBeDefined(); + }); + + it('registers the flush listener once and is idempotent on repeated enable', () => { + const client = new TestClient(getDefaultTestClientOptions()); + const onSpy = vi.spyOn(client, 'on'); + + _INTERNAL_setDeferSegmentSpanCapture(client); + _INTERNAL_setDeferSegmentSpanCapture(client); + + expect(onSpy.mock.calls.filter(([hook]) => hook === 'flush')).toHaveLength(1); + }); +}); diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 7cc0af3d63a0..26acdb660e53 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, test, vi } from 'vitest'; import { getCurrentScope } from '../../../src/currentScopes'; import { setCurrentClient } from '../../../src/sdk'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../../../src/semanticAttributes'; -import { _INTERNAL_setDeferSegmentSpanCapture, SentrySpan } from '../../../src/tracing/sentrySpan'; +import { SentrySpan } from '../../../src/tracing/sentrySpan'; import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus'; import { markSpanForOtelSourceInference, spanSourceWasExplicitlySet } from '../../../src/tracing/utils'; import type { SpanJSON } from '../../../src/types/span'; @@ -132,18 +132,6 @@ describe('SentrySpan', () => { }); }); - describe('_INTERNAL_setDeferSegmentSpanCapture', () => { - it('registers the flush listener once and is idempotent on repeated enable', () => { - const client = new TestClient(getDefaultTestClientOptions()); - const onSpy = vi.spyOn(client, 'on'); - - _INTERNAL_setDeferSegmentSpanCapture(client); - _INTERNAL_setDeferSegmentSpanCapture(client); - - expect(onSpy.mock.calls.filter(([hook]) => hook === 'flush')).toHaveLength(1); - }); - }); - describe('end', () => { test('simple', () => { const span = new SentrySpan({}); From cadd6681f956a88ca80ad8c692473042e5917d6a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 1 Jul 2026 13:06:37 +0200 Subject: [PATCH 5/9] Unit-test deferred segment capture: late-child inclusion, orphan emission, flush draining Covers the three behaviors behind the strategy, driven through SentrySpan.end() with fake timers: a child ending before the debounce fires lands in the deferred transaction; a child ending after the snapshot is emitted as its own orphan transaction tagged sentry.parent_span_already_sent; and pending captures drain synchronously on the client's flush hook. --- .../tracing/deferSegmentSpanCapture.test.ts | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts index 1daf3835bb0d..70fd49a8751f 100644 --- a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -1,11 +1,22 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + startInactiveSpan, + withActiveSpan, +} from '../../../src'; import { _INTERNAL_setDeferSegmentSpanCapture } from '../../../src/tracing/deferSegmentSpanCapture'; import { getSegmentSpanCaptureStrategy, setSegmentSpanCaptureStrategy, } from '../../../src/tracing/segmentSpanCaptureStrategy'; +import type { Event } from '../../../src/types/event'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +const dsn = 'https://123@sentry.io/42'; + describe('_INTERNAL_setDeferSegmentSpanCapture', () => { afterEach(() => { setSegmentSpanCaptureStrategy(undefined); @@ -29,3 +40,83 @@ describe('_INTERNAL_setDeferSegmentSpanCapture', () => { expect(onSpy.mock.calls.filter(([hook]) => hook === 'flush')).toHaveLength(1); }); }); + +describe('deferred segment-span capture', () => { + let transactions: Event[]; + let client: TestClient; + + beforeEach(() => { + vi.useFakeTimers(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + transactions = []; + const options = getDefaultTestClientOptions({ + dsn, + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + _INTERNAL_setDeferSegmentSpanCapture(client); + }); + + afterEach(() => { + setSegmentSpanCaptureStrategy(undefined); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('includes a child that ends after the segment but before the debounce fires', () => { + const root = startInactiveSpan({ name: 'root' }); + const child = withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); + + root.end(); + child.end(); + + // The snapshot is deferred, so nothing is captured until the debounce fires. + expect(transactions).toHaveLength(0); + + vi.advanceTimersByTime(100); + + expect(transactions).toHaveLength(1); + expect(transactions[0]!.spans).toEqual([expect.objectContaining({ description: 'child' })]); + }); + + it('emits a child that ends after the snapshot as its own orphan transaction', () => { + const root = startInactiveSpan({ name: 'root' }); + const child = withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); + + root.end(); + vi.advanceTimersByTime(100); + + // Segment transaction assembled without the still-open child. + expect(transactions).toHaveLength(1); + expect(transactions[0]!.spans).toEqual([]); + + child.end(); + vi.advanceTimersByTime(100); + + expect(transactions).toHaveLength(2); + expect(transactions[1]!.transaction).toBe('child'); + expect(transactions[1]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); + }); + + it('drains pending captures synchronously on flush', () => { + const root = startInactiveSpan({ name: 'root' }); + root.end(); + + // Still queued behind the debounce timer. + expect(transactions).toHaveLength(0); + + client.emit('flush'); + + expect(transactions).toHaveLength(1); + }); +}); From 330e8f1840635f85c1f30362c62a6819db5fe5cf Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 1 Jul 2026 14:05:14 +0200 Subject: [PATCH 6/9] Clarify SegmentSpanCaptureConvertOptions doc comment --- packages/core/src/tracing/segmentSpanCaptureStrategy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts index 1feae770a28d..d3b9fea8dd2a 100644 --- a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts +++ b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts @@ -5,11 +5,13 @@ import type { TransactionEvent } from '../types/event'; import type { Span } from '../types/span'; /** - * Optional hooks a deferring strategy passes when converting: skip spans already sent, record the ones - * it sends (for orphan tracking). The synchronous default passes neither. + * Callbacks the deferred-capture strategy hands to `_convertSpanToTransaction` when assembling a + * transaction. The synchronous (browser) path calls the converter with no options, so neither runs. */ export interface SegmentSpanCaptureConvertOptions { + /** Skip a descendant already sent in an earlier transaction, so it isn't sent twice. */ isSpanAlreadyCaptured?: (span: Span) => boolean; + /** Record each span included here, so a child that ends after the snapshot can be emitted as an orphan. */ onSpanCaptured?: (span: Span) => void; } From 9b086ccfbe612c1b0f0ae94c848954e769335546 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 1 Jul 2026 14:42:22 +0200 Subject: [PATCH 7/9] Route orphan transactions to the client that sent the segment --- .../src/tracing/deferSegmentSpanCapture.ts | 33 +++++++++++------- .../src/tracing/segmentSpanCaptureStrategy.ts | 2 +- packages/core/src/tracing/sentrySpan.ts | 8 ++--- .../tracing/deferSegmentSpanCapture.test.ts | 34 +++++++++++++++++++ 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/packages/core/src/tracing/deferSegmentSpanCapture.ts b/packages/core/src/tracing/deferSegmentSpanCapture.ts index 7585efbef63f..1029cc18a0d9 100644 --- a/packages/core/src/tracing/deferSegmentSpanCapture.ts +++ b/packages/core/src/tracing/deferSegmentSpanCapture.ts @@ -10,14 +10,12 @@ import { } from './segmentSpanCaptureStrategy'; import { getCapturedScopesOnSpan } from './utils'; -// Spans already sent in a transaction, so a child ending after its segment can be emitted as its own -// orphan transaction instead of being dropped or sent twice. -const CAPTURED_SPANS = new WeakSet(); +// Spans already sent in a transaction, mapped to the client that sent them. A child ending after its +// segment can then be emitted as its own orphan transaction (instead of dropped or sent twice), routed +// to the same client that sent the segment rather than whatever client is current when the child ends. +const CAPTURED_SPAN_CLIENTS = new WeakMap(); -const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPANS.has(span); -const markSpanCaptured = (span: Span): void => { - CAPTURED_SPANS.add(span); -}; +const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPAN_CLIENTS.has(span); // Per-client so each client's flush/close drains only its own captures: one client's flush must not // snapshot another's transaction early. Mirrors the per-client log/metric buffers. @@ -56,7 +54,10 @@ const deferredSegmentSpanCaptureStrategy = { } queue.enqueue(() => { - const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); + const transactionEvent = convert({ + isSpanAlreadyCaptured, + onSpanCaptured: span => CAPTURED_SPAN_CLIENTS.set(span, client), + }); if (transactionEvent) { // Capture via the client active at span end (passing its scope for context), so a later-tick // capture reaches that client even if the current client changed since (e.g. after re-init). @@ -65,17 +66,23 @@ const deferredSegmentSpanCaptureStrategy = { }); }, - onChildSpanEnded(span: Span, rootSpan: Span, client: Client, convert: SegmentSpanConverter): void { - const queue = CLIENT_QUEUES.get(client); + onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void { + // Route the orphan to the client that sent its segment, not the current one — which may have + // changed since (e.g. after re-init) — so it lands with its segment and survives a client swap. + const client = CAPTURED_SPAN_CLIENTS.get(rootSpan); + const queue = client && CLIENT_QUEUES.get(client); // Only a late child of an already-captured segment is an orphan. Inert under span streaming, where - // `CAPTURED_SPANS` is never populated. - if (!queue || CAPTURED_SPANS.has(span) || !CAPTURED_SPANS.has(rootSpan)) { + // no client is recorded. + if (!client || !queue || CAPTURED_SPAN_CLIENTS.has(span)) { return; } const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); queue.enqueue(() => { - const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); + const transactionEvent = convert({ + isSpanAlreadyCaptured, + onSpanCaptured: capturedSpan => CAPTURED_SPAN_CLIENTS.set(capturedSpan, client), + }); if (transactionEvent?.contexts?.trace?.data) { // Tag orphans so they're distinguishable downstream (mirrors the OTel span exporter). transactionEvent.contexts.trace.data['sentry.parent_span_already_sent'] = true; diff --git a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts index d3b9fea8dd2a..f06d1c1bb950 100644 --- a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts +++ b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts @@ -26,7 +26,7 @@ export interface SegmentSpanCaptureStrategy { /** Assemble and capture a segment (root or standalone-root) span's transaction. */ onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void; /** Consider a child that ended after its segment for emission as its own orphan transaction. */ - onChildSpanEnded(span: Span, rootSpan: Span, client: Client, convert: SegmentSpanConverter): void; + onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void; } /** diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 2934ba39853f..a6647bb9ab1a 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -364,11 +364,9 @@ export class SentrySpan implements Span { // Non-segment children aren't captured on their own. A registered strategy may re-emit a late child // as its own orphan transaction; without one, it's dropped. if (!isSegmentSpan) { - if (client) { - getSegmentSpanCaptureStrategy()?.onChildSpanEnded(this, rootSpan, client, options => - this._convertSpanToTransaction(options), - ); - } + getSegmentSpanCaptureStrategy()?.onChildSpanEnded(this, rootSpan, options => + this._convertSpanToTransaction(options), + ); return; } diff --git a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts index 70fd49a8751f..3605163b4acc 100644 --- a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -108,6 +108,40 @@ describe('deferred segment-span capture', () => { expect(transactions[1]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); }); + it('routes an orphan to the client that sent the segment, not the current client after re-init', () => { + const root = startInactiveSpan({ name: 'root' }); + const child = withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); + + root.end(); + vi.advanceTimersByTime(100); + expect(transactions).toHaveLength(1); // segment sent on the first client + + // A second `Sentry.init()` swaps in a new client mid-trace, before the late child ends. + const reinitTransactions: Event[] = []; + const reinitClient = new TestClient( + getDefaultTestClientOptions({ + dsn, + tracesSampleRate: 1, + beforeSendTransaction: event => { + reinitTransactions.push(event); + return null; + }, + }), + ); + setCurrentClient(reinitClient); + reinitClient.init(); + _INTERNAL_setDeferSegmentSpanCapture(reinitClient); + + child.end(); + vi.advanceTimersByTime(100); + + // The orphan lands on the segment's client, not the now-current one. + expect(reinitTransactions).toHaveLength(0); + expect(transactions).toHaveLength(2); + expect(transactions[1]!.transaction).toBe('child'); + expect(transactions[1]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); + }); + it('drains pending captures synchronously on flush', () => { const root = startInactiveSpan({ name: 'root' }); root.end(); From 1d7f095a8b49bba8feb6bd1f91bf712a1ee5335c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 1 Jul 2026 19:43:48 +0200 Subject: [PATCH 8/9] Simplify deferred segment capture to a per-client queue with bound capture Drops the CAPTURED_SPAN_CLIENTS routing map and the scope/client params threaded through the strategy. Each client gets one debounced queue (mirroring the OpenTelemetry span exporter's per-instance buffer); the capturing client is bound when the span ends and used at drain, so a deferred transaction always lands on the client that created the span. The strategy interface is now just the convert callback. --- .../src/tracing/deferSegmentSpanCapture.ts | 159 ++++++++---------- .../src/tracing/segmentSpanCaptureStrategy.ts | 4 +- packages/core/src/tracing/sentrySpan.ts | 7 +- .../tracing/deferSegmentSpanCapture.test.ts | 34 ---- 4 files changed, 73 insertions(+), 131 deletions(-) diff --git a/packages/core/src/tracing/deferSegmentSpanCapture.ts b/packages/core/src/tracing/deferSegmentSpanCapture.ts index 1029cc18a0d9..c050384cf424 100644 --- a/packages/core/src/tracing/deferSegmentSpanCapture.ts +++ b/packages/core/src/tracing/deferSegmentSpanCapture.ts @@ -1,132 +1,111 @@ import type { Client } from '../client'; -import { getCurrentScope } from '../currentScopes'; -import type { Scope } from '../scope'; +import { getClient } from '../currentScopes'; import type { Span } from '../types/span'; import { debounce } from '../utils/debounce'; -import { - getSegmentSpanCaptureStrategy, - type SegmentSpanConverter, - setSegmentSpanCaptureStrategy, -} from './segmentSpanCaptureStrategy'; -import { getCapturedScopesOnSpan } from './utils'; +import { getSegmentSpanCaptureStrategy, setSegmentSpanCaptureStrategy } from './segmentSpanCaptureStrategy'; +import type { SegmentSpanConverter } from './segmentSpanCaptureStrategy'; -// Spans already sent in a transaction, mapped to the client that sent them. A child ending after its -// segment can then be emitted as its own orphan transaction (instead of dropped or sent twice), routed -// to the same client that sent the segment rather than whatever client is current when the child ends. -const CAPTURED_SPAN_CLIENTS = new WeakMap(); - -const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPAN_CLIENTS.has(span); - -// Per-client so each client's flush/close drains only its own captures: one client's flush must not -// snapshot another's transaction early. Mirrors the per-client log/metric buffers. -const CLIENT_QUEUES = new WeakMap(); +// Spans already sent in a transaction, so a child ending after its segment can be emitted as its own +// orphan transaction instead of being dropped or sent twice. +const CAPTURED_SPANS = new WeakSet(); +const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPANS.has(span); +const markSpanCaptured = (span: Span): void => { + CAPTURED_SPANS.add(span); +}; -interface DeferredCaptureQueue { - enqueue: (capture: () => void) => void; - flush: () => void; -} +// One debounced queue per client, drained on the client's `flush`/`close`. Mirrors the OpenTelemetry +// span exporter, which holds one such buffer per instance, and the debounce window matches it. The +// capturing client is bound when the span ends (not re-resolved at drain time), so a deferred capture +// lands on the client that created the span even if a different client became current in the meantime. +const CLIENT_QUEUES = new WeakMap void) => void>(); /** * @private Private API with no semver guarantees! * - * Enable deferred segment-span transaction capture for a client (idempotent per client). Deferring the - * snapshot lets children that close just after their segment still land in the transaction; pending - * captures drain on `flush`, so `Sentry.flush()` / `client.close()` cannot resolve before they run. + * Enable deferred segment-span transaction capture for a client: create its debounced queue and + * register the strategy (idempotent). + * + * `SentrySpan` otherwise assembles the transaction synchronously the instant a segment span ends, which + * drops children whose async instrumentation closes them later (a diagnostics-channel `asyncEnd` + * callback in the same tick, or engine spans replayed on a later tick). The debounced snapshot delays + * capture just enough for those later span ends to land first; a child that still ends after it is + * emitted as its own orphan transaction. Pending captures drain on the client's `flush` hook, so + * `Sentry.flush()` / `client.close()` cannot resolve before they run. */ export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { if (!getSegmentSpanCaptureStrategy()) { setSegmentSpanCaptureStrategy(deferredSegmentSpanCaptureStrategy); } - // A client that never opts in has no queue and falls back to synchronous capture below. - getClientQueue(client); + if (CLIENT_QUEUES.has(client)) { + return; + } + + const pendingCaptures = new Set<() => void>(); + const debouncedDrain = debounce( + () => { + const captures = [...pendingCaptures]; + pendingCaptures.clear(); + for (const capture of captures) { + capture(); + } + }, + 1, + { maxWait: 100 }, + ); + + client.on('flush', () => { + debouncedDrain.flush(); + }); + + CLIENT_QUEUES.set(client, capture => { + pendingCaptures.add(capture); + debouncedDrain(); + }); } const deferredSegmentSpanCaptureStrategy = { - onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void { - const queue = CLIENT_QUEUES.get(client); - if (!queue) { - // Client never opted into deferral: capture synchronously, exactly as if no strategy existed. + onSegmentSpanEnded(convert: SegmentSpanConverter): void { + const client = getClient(); + const enqueue = client && CLIENT_QUEUES.get(client); + if (!enqueue) { + // The current client didn't enable deferral: capture synchronously. const transactionEvent = convert(); if (transactionEvent) { - scope.captureEvent(transactionEvent); + client?.captureEvent(transactionEvent); } return; } - queue.enqueue(() => { - const transactionEvent = convert({ - isSpanAlreadyCaptured, - onSpanCaptured: span => CAPTURED_SPAN_CLIENTS.set(span, client), - }); + enqueue(() => { + const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); if (transactionEvent) { - // Capture via the client active at span end (passing its scope for context), so a later-tick - // capture reaches that client even if the current client changed since (e.g. after re-init). - client.captureEvent(transactionEvent, undefined, scope); + client.captureEvent(transactionEvent); } }); }, onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void { - // Route the orphan to the client that sent its segment, not the current one — which may have - // changed since (e.g. after re-init) — so it lands with its segment and survives a client swap. - const client = CAPTURED_SPAN_CLIENTS.get(rootSpan); - const queue = client && CLIENT_QUEUES.get(client); // Only a late child of an already-captured segment is an orphan. Inert under span streaming, where - // no client is recorded. - if (!client || !queue || CAPTURED_SPAN_CLIENTS.has(span)) { + // `CAPTURED_SPANS` is never populated. + if (CAPTURED_SPANS.has(span) || !CAPTURED_SPANS.has(rootSpan)) { + return; + } + + const client = getClient(); + const enqueue = client && CLIENT_QUEUES.get(client); + if (!enqueue) { return; } - const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); - queue.enqueue(() => { - const transactionEvent = convert({ - isSpanAlreadyCaptured, - onSpanCaptured: capturedSpan => CAPTURED_SPAN_CLIENTS.set(capturedSpan, client), - }); + enqueue(() => { + const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); if (transactionEvent?.contexts?.trace?.data) { // Tag orphans so they're distinguishable downstream (mirrors the OTel span exporter). transactionEvent.contexts.trace.data['sentry.parent_span_already_sent'] = true; } if (transactionEvent) { - client.captureEvent(transactionEvent, undefined, scope); + client.captureEvent(transactionEvent); } }); }, }; - -function getClientQueue(client: Client): DeferredCaptureQueue { - const existing = CLIENT_QUEUES.get(client); - if (existing) { - return existing; - } - - const pendingCaptures = new Set<() => void>(); - const debouncedDrain = debounce( - () => { - const captures = [...pendingCaptures]; - pendingCaptures.clear(); - for (const capture of captures) { - capture(); - } - }, - 1, - { maxWait: 100 }, - ); - - const queue: DeferredCaptureQueue = { - enqueue: capture => { - pendingCaptures.add(capture); - debouncedDrain(); - }, - flush: () => { - debouncedDrain.flush(); - }, - }; - - client.on('flush', () => { - queue.flush(); - }); - - CLIENT_QUEUES.set(client, queue); - return queue; -} diff --git a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts index f06d1c1bb950..00fdece5784b 100644 --- a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts +++ b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts @@ -1,6 +1,4 @@ import { getMainCarrier, getSentryCarrier } from '../carrier'; -import type { Client } from '../client'; -import type { Scope } from '../scope'; import type { TransactionEvent } from '../types/event'; import type { Span } from '../types/span'; @@ -24,7 +22,7 @@ export type SegmentSpanConverter = (options?: SegmentSpanCaptureConvertOptions) */ export interface SegmentSpanCaptureStrategy { /** Assemble and capture a segment (root or standalone-root) span's transaction. */ - onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void; + onSegmentSpanEnded(convert: SegmentSpanConverter): void; /** Consider a child that ended after its segment for emission as its own orphan transaction. */ onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void; } diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index a6647bb9ab1a..527a19dafe3e 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -376,16 +376,15 @@ export class SentrySpan implements Span { return; } - const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); - // A registered strategy defers the snapshot so children closing just after the segment still land // (and late ones can orphan); without one, assemble synchronously from the live tree. - const strategy = client && getSegmentSpanCaptureStrategy(); + const strategy = getSegmentSpanCaptureStrategy(); if (strategy) { - strategy.onSegmentSpanEnded(scope, client, options => this._convertSpanToTransaction(options)); + strategy.onSegmentSpanEnded(options => this._convertSpanToTransaction(options)); } else { const transactionEvent = this._convertSpanToTransaction(); if (transactionEvent) { + const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); scope.captureEvent(transactionEvent); } } diff --git a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts index 3605163b4acc..70fd49a8751f 100644 --- a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -108,40 +108,6 @@ describe('deferred segment-span capture', () => { expect(transactions[1]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); }); - it('routes an orphan to the client that sent the segment, not the current client after re-init', () => { - const root = startInactiveSpan({ name: 'root' }); - const child = withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); - - root.end(); - vi.advanceTimersByTime(100); - expect(transactions).toHaveLength(1); // segment sent on the first client - - // A second `Sentry.init()` swaps in a new client mid-trace, before the late child ends. - const reinitTransactions: Event[] = []; - const reinitClient = new TestClient( - getDefaultTestClientOptions({ - dsn, - tracesSampleRate: 1, - beforeSendTransaction: event => { - reinitTransactions.push(event); - return null; - }, - }), - ); - setCurrentClient(reinitClient); - reinitClient.init(); - _INTERNAL_setDeferSegmentSpanCapture(reinitClient); - - child.end(); - vi.advanceTimersByTime(100); - - // The orphan lands on the segment's client, not the now-current one. - expect(reinitTransactions).toHaveLength(0); - expect(transactions).toHaveLength(2); - expect(transactions[1]!.transaction).toBe('child'); - expect(transactions[1]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); - }); - it('drains pending captures synchronously on flush', () => { const root = startInactiveSpan({ name: 'root' }); root.end(); From 220ade4e730c01a2500f712714002f0c44c6c246 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 2 Jul 2026 00:04:25 +0200 Subject: [PATCH 9/9] Route deferred captures through the span's captured scope --- .../src/tracing/deferSegmentSpanCapture.ts | 41 ++++++------ .../src/tracing/segmentSpanCaptureStrategy.ts | 7 +- packages/core/src/tracing/sentrySpan.ts | 12 ++-- .../tracing/deferSegmentSpanCapture.test.ts | 67 +++++++++++++++++++ 4 files changed, 100 insertions(+), 27 deletions(-) diff --git a/packages/core/src/tracing/deferSegmentSpanCapture.ts b/packages/core/src/tracing/deferSegmentSpanCapture.ts index c050384cf424..5a081babebff 100644 --- a/packages/core/src/tracing/deferSegmentSpanCapture.ts +++ b/packages/core/src/tracing/deferSegmentSpanCapture.ts @@ -1,5 +1,5 @@ import type { Client } from '../client'; -import { getClient } from '../currentScopes'; +import type { Scope } from '../scope'; import type { Span } from '../types/span'; import { debounce } from '../utils/debounce'; import { getSegmentSpanCaptureStrategy, setSegmentSpanCaptureStrategy } from './segmentSpanCaptureStrategy'; @@ -14,9 +14,9 @@ const markSpanCaptured = (span: Span): void => { }; // One debounced queue per client, drained on the client's `flush`/`close`. Mirrors the OpenTelemetry -// span exporter, which holds one such buffer per instance, and the debounce window matches it. The -// capturing client is bound when the span ends (not re-resolved at drain time), so a deferred capture -// lands on the client that created the span even if a different client became current in the meantime. +// span exporter, which holds one such buffer per instance, and the debounce window matches it. Captures +// run through the span's captured scope (as the synchronous path does), so a deferred transaction lands +// on the client that created the span even if a different client became current in the meantime. const CLIENT_QUEUES = new WeakMap void) => void>(); /** @@ -64,14 +64,14 @@ export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { } const deferredSegmentSpanCaptureStrategy = { - onSegmentSpanEnded(convert: SegmentSpanConverter): void { - const client = getClient(); + onSegmentSpanEnded(convert: SegmentSpanConverter, scope: Scope): void { + const client = scope.getClient(); const enqueue = client && CLIENT_QUEUES.get(client); if (!enqueue) { - // The current client didn't enable deferral: capture synchronously. + // The capturing client didn't enable deferral: capture synchronously. const transactionEvent = convert(); if (transactionEvent) { - client?.captureEvent(transactionEvent); + scope.captureEvent(transactionEvent); } return; } @@ -79,33 +79,36 @@ const deferredSegmentSpanCaptureStrategy = { enqueue(() => { const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); if (transactionEvent) { - client.captureEvent(transactionEvent); + scope.captureEvent(transactionEvent); } }); }, - onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void { + onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter, scope: Scope): void { // Only a late child of an already-captured segment is an orphan. Inert under span streaming, where // `CAPTURED_SPANS` is never populated. if (CAPTURED_SPANS.has(span) || !CAPTURED_SPANS.has(rootSpan)) { return; } - const client = getClient(); - const enqueue = client && CLIENT_QUEUES.get(client); - if (!enqueue) { - return; - } - - enqueue(() => { + const captureOrphan = (): void => { const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); if (transactionEvent?.contexts?.trace?.data) { // Tag orphans so they're distinguishable downstream (mirrors the OTel span exporter). transactionEvent.contexts.trace.data['sentry.parent_span_already_sent'] = true; } if (transactionEvent) { - client.captureEvent(transactionEvent); + scope.captureEvent(transactionEvent); } - }); + }; + + const client = scope.getClient(); + const enqueue = client && CLIENT_QUEUES.get(client); + // Defer when the capturing client batches; otherwise emit now so the orphan isn't dropped. + if (enqueue) { + enqueue(captureOrphan); + } else { + captureOrphan(); + } }, }; diff --git a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts index 00fdece5784b..1176b821d5fe 100644 --- a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts +++ b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts @@ -1,4 +1,5 @@ import { getMainCarrier, getSentryCarrier } from '../carrier'; +import type { Scope } from '../scope'; import type { TransactionEvent } from '../types/event'; import type { Span } from '../types/span'; @@ -21,10 +22,10 @@ export type SegmentSpanConverter = (options?: SegmentSpanCaptureConvertOptions) * behind this seam tree-shakes the deferral machinery out of SDKs that never register one (e.g. browser). */ export interface SegmentSpanCaptureStrategy { - /** Assemble and capture a segment (root or standalone-root) span's transaction. */ - onSegmentSpanEnded(convert: SegmentSpanConverter): void; + /** Assemble and capture a segment (root or standalone-root) span's transaction through its captured scope. */ + onSegmentSpanEnded(convert: SegmentSpanConverter, scope: Scope): void; /** Consider a child that ended after its segment for emission as its own orphan transaction. */ - onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void; + onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter, scope: Scope): void; } /** diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 527a19dafe3e..4e1666779ca6 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -364,9 +364,11 @@ export class SentrySpan implements Span { // Non-segment children aren't captured on their own. A registered strategy may re-emit a late child // as its own orphan transaction; without one, it's dropped. if (!isSegmentSpan) { - getSegmentSpanCaptureStrategy()?.onChildSpanEnded(this, rootSpan, options => - this._convertSpanToTransaction(options), - ); + const strategy = getSegmentSpanCaptureStrategy(); + if (strategy) { + const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); + strategy.onChildSpanEnded(this, rootSpan, options => this._convertSpanToTransaction(options), scope); + } return; } @@ -378,13 +380,13 @@ export class SentrySpan implements Span { // A registered strategy defers the snapshot so children closing just after the segment still land // (and late ones can orphan); without one, assemble synchronously from the live tree. + const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); const strategy = getSegmentSpanCaptureStrategy(); if (strategy) { - strategy.onSegmentSpanEnded(options => this._convertSpanToTransaction(options)); + strategy.onSegmentSpanEnded(options => this._convertSpanToTransaction(options), scope); } else { const transactionEvent = this._convertSpanToTransaction(); if (transactionEvent) { - const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); scope.captureEvent(transactionEvent); } } diff --git a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts index 70fd49a8751f..b38bac667f55 100644 --- a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -6,6 +6,7 @@ import { setCurrentClient, startInactiveSpan, withActiveSpan, + withScope, } from '../../../src'; import { _INTERNAL_setDeferSegmentSpanCapture } from '../../../src/tracing/deferSegmentSpanCapture'; import { @@ -119,4 +120,70 @@ describe('deferred segment-span capture', () => { expect(transactions).toHaveLength(1); }); + + it("routes a deferred segment to the span's own client, not whichever client is current at end", () => { + const otherTransactions: Event[] = []; + const otherClient = new TestClient( + getDefaultTestClientOptions({ + dsn, + tracesSampleRate: 1, + beforeSendTransaction: event => { + otherTransactions.push(event); + return null; + }, + }), + ); + otherClient.init(); + _INTERNAL_setDeferSegmentSpanCapture(otherClient); + + // Created while `client` is current, so its captured scope belongs to `client`. + const root = startInactiveSpan({ name: 'root' }); + + // A different client becomes current before the span ends. + withScope(scope => { + scope.setClient(otherClient); + root.end(); + }); + + vi.advanceTimersByTime(100); + + expect(transactions).toHaveLength(1); + expect(otherTransactions).toHaveLength(0); + }); + + it('emits a late orphan synchronously when its client has no defer queue', () => { + const orphanTransactions: Event[] = []; + const noQueueClient = new TestClient( + getDefaultTestClientOptions({ + dsn, + tracesSampleRate: 1, + beforeSendTransaction: event => { + orphanTransactions.push(event); + return null; + }, + }), + ); + noQueueClient.init(); + // Deliberately not enabling deferral on `noQueueClient`, so it has no queue. + + // Root is captured via `client` (which defers), so it lands in `CAPTURED_SPANS`. + const root = startInactiveSpan({ name: 'root' }); + // The child's captured scope belongs to the queue-less client. + const child = withScope(scope => { + scope.setClient(noQueueClient); + return withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); + }); + + root.end(); + vi.advanceTimersByTime(100); + expect(transactions).toHaveLength(1); + expect(orphanTransactions).toHaveLength(0); + + // Late child on a queue-less client: emitted right away instead of dropped. + child.end(); + + expect(orphanTransactions).toHaveLength(1); + expect(orphanTransactions[0]!.transaction).toBe('child'); + expect(orphanTransactions[0]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); + }); });