Skip to content

Commit f4c59c3

Browse files
committed
Emit late-ending child spans as orphan transactions instead of dropping them
1 parent 2fb6e21 commit f4c59c3

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
@@ -62,6 +62,11 @@ const MAX_SPAN_COUNT = 1000;
6262
// that close after it. Every other setup keeps its synchronous capture.
6363
const DEFERRED_SEGMENT_SPAN_CAPTURES = new WeakMap<Client, (capture: () => void) => void>();
6464

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

@@ -433,7 +453,7 @@ export class SentrySpan implements Span {
433453
const deferCapture = client && DEFERRED_SEGMENT_SPAN_CAPTURES.get(client);
434454
if (client && deferCapture && !isBrowser()) {
435455
deferCapture(() => {
436-
const transactionEvent = this._convertSpanToTransaction();
456+
const transactionEvent = this._convertSpanToTransaction({ orphanedFromSentParent: isOrphanSegment });
437457
if (transactionEvent) {
438458
// Capture through the client resolved when the span ended, not the scope: a capture that
439459
// 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 {
453473
/**
454474
* Finish the transaction & prepare the event to send to Sentry.
455475
*/
456-
private _convertSpanToTransaction(): TransactionEvent | undefined {
476+
private _convertSpanToTransaction(options: { orphanedFromSentParent?: boolean } = {}): TransactionEvent | undefined {
457477
// We can only convert finished spans
458478
if (!isFullFinishedSpan(spanToJSON(this))) {
459479
return undefined;
@@ -472,10 +492,22 @@ export class SentrySpan implements Span {
472492
return undefined;
473493
}
474494

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

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

@@ -519,6 +551,12 @@ export class SentrySpan implements Span {
519551
}),
520552
};
521553

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

0 commit comments

Comments
 (0)