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