Skip to content

Commit 1397906

Browse files
committed
Emit late-ending child spans as orphan transactions instead of dropping them
1 parent cafc551 commit 1397906

1 file changed

Lines changed: 47 additions & 9 deletions

File tree

packages/core/src/tracing/sentrySpan.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ const MAX_SPAN_COUNT = 1000;
6161
// that close after it. Every other setup keeps its synchronous capture.
6262
const DEFERRED_SEGMENT_SPAN_CAPTURES = new WeakMap<Client, (capture: () => void) => void>();
6363

64+
// Spans already included in a captured transaction. Used so a child that ends after its root segment
65+
// was captured can be emitted as its own orphan transaction (see `_onSpanEnded`) without any span ever
66+
// being sent in more than one transaction.
67+
const CAPTURED_SPANS = new WeakSet<Span>();
68+
6469
/**
6570
* Opt a client into (or out of) deferring its segment-span transaction capture.
6671
* Set by the SDK client during setup (e.g. the Node SDK); see {@link DEFERRED_SEGMENT_SPAN_CAPTURES}.
@@ -397,9 +402,24 @@ export class SentrySpan implements Span {
397402
// A segment span is basically the root span of a local span tree.
398403
// So for now, this is either what we previously refer to as the root span,
399404
// or a standalone span.
400-
const isSegmentSpan = this._isStandaloneSpan || this === getRootSpan(this);
401-
402-
if (!isSegmentSpan) {
405+
const rootSpan = getRootSpan(this);
406+
const isSegmentSpan = this._isStandaloneSpan || this === rootSpan;
407+
408+
// A child span that ends after its root segment's transaction was already captured can no longer be
409+
// part of it. Mirror the OpenTelemetry span exporter, which emits such a late child as its own
410+
// (orphan) transaction in the same trace instead of dropping it. Only for clients that defer the
411+
// segment capture (the SentryTracerProvider, the no-exporter native-assembly path); other setups
412+
// keep the synchronous drop. `CAPTURED_SPANS` is only populated during a non-streaming capture, so
413+
// this stays inert under span streaming (where late children stream individually).
414+
const isOrphanSegment =
415+
!isSegmentSpan &&
416+
!!client &&
417+
!!DEFERRED_SEGMENT_SPAN_CAPTURES.get(client) &&
418+
!isBrowser() &&
419+
!CAPTURED_SPANS.has(this) &&
420+
CAPTURED_SPANS.has(rootSpan);
421+
422+
if (!isSegmentSpan && !isOrphanSegment) {
403423
return;
404424
}
405425

@@ -432,7 +452,7 @@ export class SentrySpan implements Span {
432452
const deferCapture = client && DEFERRED_SEGMENT_SPAN_CAPTURES.get(client);
433453
if (client && deferCapture && !isBrowser()) {
434454
deferCapture(() => {
435-
const transactionEvent = this._convertSpanToTransaction();
455+
const transactionEvent = this._convertSpanToTransaction({ orphanedFromSentParent: isOrphanSegment });
436456
if (transactionEvent) {
437457
// Capture through the client resolved when the span ended, not the scope: a capture that
438458
// fires on a later tick must reach the client active at span end and never whatever client
@@ -452,7 +472,7 @@ export class SentrySpan implements Span {
452472
/**
453473
* Finish the transaction & prepare the event to send to Sentry.
454474
*/
455-
private _convertSpanToTransaction(): TransactionEvent | undefined {
475+
private _convertSpanToTransaction(options: { orphanedFromSentParent?: boolean } = {}): TransactionEvent | undefined {
456476
// We can only convert finished spans
457477
if (!isFullFinishedSpan(spanToJSON(this))) {
458478
return undefined;
@@ -471,10 +491,22 @@ export class SentrySpan implements Span {
471491
return undefined;
472492
}
473493

474-
// The transaction span itself as well as any potential standalone spans should be filtered out
475-
const finishedSpans = getSpanDescendants(this).filter(span => span !== this && !isStandaloneSpan(span));
476-
477-
const spans = finishedSpans.map(span => spanToJSON(span)).filter(isFullFinishedSpan);
494+
// Skip the transaction span itself, standalone spans, and spans already sent in another transaction.
495+
// Marking everything we send as captured lets a child that ends later be emitted as its own orphan
496+
// transaction (see `_onSpanEnded`) instead of being dropped or sent twice.
497+
CAPTURED_SPANS.add(this);
498+
const spans: SpanJSON[] = [];
499+
for (const descendant of getSpanDescendants(this)) {
500+
if (descendant === this || isStandaloneSpan(descendant) || CAPTURED_SPANS.has(descendant)) {
501+
continue;
502+
}
503+
const spanJSON = spanToJSON(descendant);
504+
if (!isFullFinishedSpan(spanJSON)) {
505+
continue;
506+
}
507+
CAPTURED_SPANS.add(descendant);
508+
spans.push(spanJSON);
509+
}
478510

479511
const source = this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
480512

@@ -518,6 +550,12 @@ export class SentrySpan implements Span {
518550
}),
519551
};
520552

553+
// Mirror the OpenTelemetry span exporter: tag a transaction whose parent span was already sent (an
554+
// orphan emitted from `_onSpanEnded`) so it can be distinguished downstream.
555+
if (options.orphanedFromSentParent && transaction.contexts?.trace?.data) {
556+
transaction.contexts.trace.data['sentry.parent_span_already_sent'] = true;
557+
}
558+
521559
const measurements = timedEventsToMeasurements(this._events);
522560
const hasMeasurements = measurements && Object.keys(measurements).length;
523561

0 commit comments

Comments
 (0)