From 7bb835f098028c4d86cbdd7781e47d982e4ba432 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:43:59 +0000 Subject: [PATCH 1/2] fix(v10/core): Record span outcomes for spans dropped when building a transaction Backport of: #25048 --- 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 8a951d58011d..be0f0742be04 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -462,18 +462,31 @@ export class SentrySpan implements Span { // synchronous default passes no hooks, so this bookkeeping stays out of SDKs that don't defer. 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 = spanToJSON(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[SEMANTIC_ATTRIBUTE_SENTRY_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 4f364747cc4c..e0f512e2538b 100644 --- a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -109,6 +109,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 b96f89fdb8f5..ecc070a4a49f 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -425,6 +425,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', () => { From 6a81e08580f6a6e381017309b755c8c7aa1a1083 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:08:30 +0000 Subject: [PATCH 2/2] chore(v10): Bump cloudflare minified size limit to 201 KiB The backported span outcome recording adds ~181 bytes, pushing the minified Cloudflare bundle just past 200 KiB. --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index c2e30b48f72f..7c0cc43c1c4a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -460,7 +460,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '200 KiB', + limit: '201 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) {