From 15d496c8fed4a84b67f2c486473086560d438801 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 5 Oct 2026 13:35:29 +0200 Subject: [PATCH] fix(core): Record span outcomes for spans dropped when building a transaction Unfinished child spans are now reported as `invalid` and spans cut off by the 1000 span cap as `buffer_overflow`. Unfinished children are not counted when the deferred capture strategy tracks captured spans, since those are sent later as orphan transactions. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/tracing/sentrySpan.ts | 13 ++++ .../tracing/deferSegmentSpanCapture.test.ts | 33 +++++++++ .../core/test/lib/tracing/sentrySpan.test.ts | 71 +++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index d9646e6b7078..3d25af4c18cf 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -458,18 +458,31 @@ export class SentrySpan implements Span { // TODO(standalone): drop the `isStandaloneSpan(descendant)` check once the static trace lifecycle is gone. options.onSpanCaptured?.(this); const spans: SpanJSON[] = []; + let droppedUnfinishedSpanCount = 0; for (const descendant of getSpanDescendants(this)) { if (descendant === this || isStandaloneSpan(descendant) || options.isSpanAlreadyCaptured?.(descendant)) { continue; } const spanJSON = spanToStaticSpanJSON(descendant); if (!isFullFinishedSpan(spanJSON)) { + // A strategy tracking captured spans re-emits this child as an orphan transaction once it ends. + if (!options.onSpanCaptured) { + droppedUnfinishedSpanCount++; + } continue; } options.onSpanCaptured?.(descendant); spans.push(spanJSON); } + const client = capturedSpanScope?.getClient() || getClient(); + if (droppedUnfinishedSpanCount) { + client?.recordDroppedEvent('invalid', 'span', droppedUnfinishedSpanCount); + } + if (spans.length > MAX_SPAN_COUNT) { + client?.recordDroppedEvent('buffer_overflow', 'span', spans.length - MAX_SPAN_COUNT); + } + const source = this._attributes[SENTRY_SEGMENT_NAME_SOURCE]; // remove internal root span attributes we don't need to send. diff --git a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts index 445e1294657d..9a4409b9c8f7 100644 --- a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -100,6 +100,39 @@ describe('deferred segment-span capture', () => { expect(transactions[1]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); }); + it('does not record a span outcome for a child that is later sent as an orphan transaction', () => { + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const root = startInactiveSpan({ name: 'root' }); + const child = withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); + + root.end(); + vi.advanceTimersByTime(100); + child.end(); + vi.advanceTimersByTime(100); + + expect(transactions).toHaveLength(2); + expect(recordDroppedEventSpy).not.toHaveBeenCalledWith('invalid', 'span', expect.anything()); + }); + + it('records a `buffer_overflow` span outcome for spans beyond the 1000 span limit once', () => { + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const root = startInactiveSpan({ name: 'root' }); + withActiveSpan(root, () => { + for (let i = 0; i < 1003; i++) { + startInactiveSpan({ name: `child-${i}` }).end(); + } + }); + root.end(); + vi.advanceTimersByTime(100); + + expect(transactions).toHaveLength(1); + expect(transactions[0]!.spans).toHaveLength(1000); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('buffer_overflow', 'span', 3); + expect(recordDroppedEventSpy).not.toHaveBeenCalledWith('invalid', 'span', expect.anything()); + }); + it('drains pending captures synchronously on flush', () => { const root = startInactiveSpan({ name: 'root' }); root.end(); diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 15ebe588d4c1..ad49179d7ce2 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -456,6 +456,77 @@ describe('SentrySpan', () => { type: 'transaction', }); }); + + test('records an `invalid` span outcome for unfinished child spans', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const captureEventSpy = vi.spyOn(getCurrentScope(), 'captureEvent').mockImplementation(() => 'testId'); + + const root = startInactiveSpan({ name: 'root' }); + withActiveSpan(root, () => { + startInactiveSpan({ name: 'unfinished-1' }); + startInactiveSpan({ name: 'unfinished-2' }); + startInactiveSpan({ name: 'finished' }).end(); + }); + root.end(); + + expect(captureEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ spans: [expect.objectContaining({ description: 'finished' })] }), + ); + expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('invalid', 'span', 2); + }); + + test('records a `buffer_overflow` span outcome for spans beyond the 1000 span limit', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const captureEventSpy = vi.spyOn(getCurrentScope(), 'captureEvent').mockImplementation(() => 'testId'); + + const root = startInactiveSpan({ name: 'root' }); + withActiveSpan(root, () => { + for (let i = 0; i < 1005; i++) { + startInactiveSpan({ name: `child-${i}` }).end(); + } + }); + root.end(); + + expect(captureEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ spans: expect.objectContaining({ length: 1000 }) }), + ); + expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('buffer_overflow', 'span', 5); + }); + + test('does not record span outcomes for an unsampled root span', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const root = new SentrySpan({ name: 'root', sampled: false }); + withActiveSpan(root, () => { + startInactiveSpan({ name: 'unfinished' }); + }); + root.end(); + + expect(recordDroppedEventSpy).not.toHaveBeenCalledWith('invalid', 'span', expect.anything()); + }); + + test('does not record span outcomes for unfinished standalone child spans', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + vi.spyOn(getCurrentScope(), 'captureEvent').mockImplementation(() => 'testId'); + + const root = startInactiveSpan({ name: 'root' }); + withActiveSpan(root, () => { + startInactiveSpan({ name: 'inp', experimental: { standalone: true } }); + }); + root.end(); + + expect(recordDroppedEventSpy).not.toHaveBeenCalled(); + }); }); describe('setAttribute', () => {