@@ -62,6 +62,11 @@ const MAX_SPAN_COUNT = 1000;
6262// that close after it. Every other setup keeps its synchronous capture.
6363const 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