Skip to content

Commit d93efba

Browse files
committed
Route deferred captures through the span's captured scope
1 parent 1d7f095 commit d93efba

4 files changed

Lines changed: 124 additions & 22 deletions

File tree

packages/core/src/tracing/deferSegmentSpanCapture.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Client } from '../client';
2-
import { getClient } from '../currentScopes';
2+
import type { Scope } from '../scope';
33
import type { Span } from '../types/span';
44
import { debounce } from '../utils/debounce';
55
import { getSegmentSpanCaptureStrategy, setSegmentSpanCaptureStrategy } from './segmentSpanCaptureStrategy';
@@ -15,8 +15,9 @@ const markSpanCaptured = (span: Span): void => {
1515

1616
// One debounced queue per client, drained on the client's `flush`/`close`. Mirrors the OpenTelemetry
1717
// span exporter, which holds one such buffer per instance, and the debounce window matches it. The
18-
// capturing client is bound when the span ends (not re-resolved at drain time), so a deferred capture
19-
// lands on the client that created the span even if a different client became current in the meantime.
18+
// capturing client is resolved from the span's captured scope and bound when the span ends, not
19+
// re-resolved at drain time, so a deferred transaction lands on the client that created the span even if
20+
// the current client (or the captured scope's own client) is reassigned before the debounce fires.
2021
const CLIENT_QUEUES = new WeakMap<Client, (capture: () => void) => void>();
2122

2223
/**
@@ -64,11 +65,11 @@ export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void {
6465
}
6566

6667
const deferredSegmentSpanCaptureStrategy = {
67-
onSegmentSpanEnded(convert: SegmentSpanConverter): void {
68-
const client = getClient();
68+
onSegmentSpanEnded(convert: SegmentSpanConverter, scope: Scope): void {
69+
const client = scope.getClient();
6970
const enqueue = client && CLIENT_QUEUES.get(client);
7071
if (!enqueue) {
71-
// The current client didn't enable deferral: capture synchronously.
72+
// The capturing client didn't enable deferral: capture synchronously.
7273
const transactionEvent = convert();
7374
if (transactionEvent) {
7475
client?.captureEvent(transactionEvent);
@@ -84,28 +85,32 @@ const deferredSegmentSpanCaptureStrategy = {
8485
});
8586
},
8687

87-
onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void {
88+
onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter, scope: Scope): void {
8889
// Only a late child of an already-captured segment is an orphan. Inert under span streaming, where
8990
// `CAPTURED_SPANS` is never populated.
9091
if (CAPTURED_SPANS.has(span) || !CAPTURED_SPANS.has(rootSpan)) {
9192
return;
9293
}
9394

94-
const client = getClient();
95+
const client = scope.getClient();
9596
const enqueue = client && CLIENT_QUEUES.get(client);
96-
if (!enqueue) {
97-
return;
98-
}
9997

100-
enqueue(() => {
98+
const captureOrphan = (): void => {
10199
const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured });
102100
if (transactionEvent?.contexts?.trace?.data) {
103101
// Tag orphans so they're distinguishable downstream (mirrors the OTel span exporter).
104102
transactionEvent.contexts.trace.data['sentry.parent_span_already_sent'] = true;
105103
}
106104
if (transactionEvent) {
107-
client.captureEvent(transactionEvent);
105+
client?.captureEvent(transactionEvent);
108106
}
109-
});
107+
};
108+
109+
// Defer when the capturing client batches; otherwise emit now so the orphan isn't dropped.
110+
if (enqueue) {
111+
enqueue(captureOrphan);
112+
} else {
113+
captureOrphan();
114+
}
110115
},
111116
};

packages/core/src/tracing/segmentSpanCaptureStrategy.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getMainCarrier, getSentryCarrier } from '../carrier';
2+
import type { Scope } from '../scope';
23
import type { TransactionEvent } from '../types/event';
34
import type { Span } from '../types/span';
45

@@ -21,10 +22,10 @@ export type SegmentSpanConverter = (options?: SegmentSpanCaptureConvertOptions)
2122
* behind this seam tree-shakes the deferral machinery out of SDKs that never register one (e.g. browser).
2223
*/
2324
export interface SegmentSpanCaptureStrategy {
24-
/** Assemble and capture a segment (root or standalone-root) span's transaction. */
25-
onSegmentSpanEnded(convert: SegmentSpanConverter): void;
25+
/** Assemble and capture a segment (root or standalone-root) span's transaction through its captured scope. */
26+
onSegmentSpanEnded(convert: SegmentSpanConverter, scope: Scope): void;
2627
/** Consider a child that ended after its segment for emission as its own orphan transaction. */
27-
onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void;
28+
onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter, scope: Scope): void;
2829
}
2930

3031
/**

packages/core/src/tracing/sentrySpan.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,11 @@ export class SentrySpan implements Span {
364364
// Non-segment children aren't captured on their own. A registered strategy may re-emit a late child
365365
// as its own orphan transaction; without one, it's dropped.
366366
if (!isSegmentSpan) {
367-
getSegmentSpanCaptureStrategy()?.onChildSpanEnded(this, rootSpan, options =>
368-
this._convertSpanToTransaction(options),
369-
);
367+
const strategy = getSegmentSpanCaptureStrategy();
368+
if (strategy) {
369+
const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();
370+
strategy.onChildSpanEnded(this, rootSpan, options => this._convertSpanToTransaction(options), scope);
371+
}
370372
return;
371373
}
372374

@@ -378,13 +380,13 @@ export class SentrySpan implements Span {
378380

379381
// A registered strategy defers the snapshot so children closing just after the segment still land
380382
// (and late ones can orphan); without one, assemble synchronously from the live tree.
383+
const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();
381384
const strategy = getSegmentSpanCaptureStrategy();
382385
if (strategy) {
383-
strategy.onSegmentSpanEnded(options => this._convertSpanToTransaction(options));
386+
strategy.onSegmentSpanEnded(options => this._convertSpanToTransaction(options), scope);
384387
} else {
385388
const transactionEvent = this._convertSpanToTransaction();
386389
if (transactionEvent) {
387-
const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();
388390
scope.captureEvent(transactionEvent);
389391
}
390392
}

packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
setCurrentClient,
77
startInactiveSpan,
88
withActiveSpan,
9+
withScope,
910
} from '../../../src';
1011
import { _INTERNAL_setDeferSegmentSpanCapture } from '../../../src/tracing/deferSegmentSpanCapture';
1112
import {
@@ -119,4 +120,97 @@ describe('deferred segment-span capture', () => {
119120

120121
expect(transactions).toHaveLength(1);
121122
});
123+
124+
it("routes a deferred segment to the span's own client, not whichever client is current at end", () => {
125+
const otherTransactions: Event[] = [];
126+
const otherClient = new TestClient(
127+
getDefaultTestClientOptions({
128+
dsn,
129+
tracesSampleRate: 1,
130+
beforeSendTransaction: event => {
131+
otherTransactions.push(event);
132+
return null;
133+
},
134+
}),
135+
);
136+
otherClient.init();
137+
_INTERNAL_setDeferSegmentSpanCapture(otherClient);
138+
139+
// Created while `client` is current, so its captured scope belongs to `client`.
140+
const root = startInactiveSpan({ name: 'root' });
141+
142+
// A different client becomes current before the span ends.
143+
withScope(scope => {
144+
scope.setClient(otherClient);
145+
root.end();
146+
});
147+
148+
vi.advanceTimersByTime(100);
149+
150+
expect(transactions).toHaveLength(1);
151+
expect(otherTransactions).toHaveLength(0);
152+
});
153+
154+
it('emits a late orphan synchronously when its client has no defer queue', () => {
155+
const orphanTransactions: Event[] = [];
156+
const noQueueClient = new TestClient(
157+
getDefaultTestClientOptions({
158+
dsn,
159+
tracesSampleRate: 1,
160+
beforeSendTransaction: event => {
161+
orphanTransactions.push(event);
162+
return null;
163+
},
164+
}),
165+
);
166+
noQueueClient.init();
167+
// Deliberately not enabling deferral on `noQueueClient`, so it has no queue.
168+
169+
// Root is captured via `client` (which defers), so it lands in `CAPTURED_SPANS`.
170+
const root = startInactiveSpan({ name: 'root' });
171+
// The child's captured scope belongs to the queue-less client.
172+
const child = withScope(scope => {
173+
scope.setClient(noQueueClient);
174+
return withActiveSpan(root, () => startInactiveSpan({ name: 'child' }));
175+
});
176+
177+
root.end();
178+
vi.advanceTimersByTime(100);
179+
expect(transactions).toHaveLength(1);
180+
expect(orphanTransactions).toHaveLength(0);
181+
182+
// Late child on a queue-less client: emitted right away instead of dropped.
183+
child.end();
184+
185+
expect(orphanTransactions).toHaveLength(1);
186+
expect(orphanTransactions[0]!.transaction).toBe('child');
187+
expect(orphanTransactions[0]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true);
188+
});
189+
190+
it('binds the capturing client at span end, ignoring later reassignment of the scope client', () => {
191+
const laterTransactions: Event[] = [];
192+
const laterClient = new TestClient(
193+
getDefaultTestClientOptions({
194+
dsn,
195+
tracesSampleRate: 1,
196+
beforeSendTransaction: event => {
197+
laterTransactions.push(event);
198+
return null;
199+
},
200+
}),
201+
);
202+
laterClient.init();
203+
_INTERNAL_setDeferSegmentSpanCapture(laterClient);
204+
205+
const root = startInactiveSpan({ name: 'root' });
206+
root.end(); // enqueued and bound to `client` (the captured scope's client at span end)
207+
208+
// The captured scope's own client is reassigned before the debounce fires.
209+
getCurrentScope().setClient(laterClient);
210+
211+
vi.advanceTimersByTime(100);
212+
213+
expect(transactions).toHaveLength(1);
214+
expect(laterTransactions).toHaveLength(0);
215+
});
122216
});

0 commit comments

Comments
 (0)