From a93fbff43d67b3f71e34b37bc4ce43bac5c7acd6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Oct 2026 16:50:23 +0200 Subject: [PATCH 1/5] fix(core): Record `span` client report outcomes if span streaming is disabled --- .../consistent-sampling/meta-negative/test.ts | 7 +++++++ .../consistent-sampling/meta-precedence/test.ts | 7 +++++++ .../tracesSampler-precedence/test.ts | 10 ++++++++++ .../suites/tracing/sampling-static/test.ts | 7 ++++++- packages/core/src/tracing/trace.ts | 9 +++++++-- packages/core/test/lib/tracing/idleSpan.test.ts | 2 ++ packages/core/test/lib/tracing/trace.test.ts | 15 +++++++++++---- 7 files changed, 50 insertions(+), 7 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts index c8faee2f5feb..96048f405b6c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts @@ -86,8 +86,15 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains quantity: 4, reason: 'sample_rate', }, + { + category: 'span', + quantity: expect.any(Number), + reason: 'sample_rate', + }, ], }); + // exact number depends on performance observer emissions + expect(clientReport.discarded_events[1].quantity).toBeGreaterThanOrEqual(10); }); await sentryTest.step('Wait for transactions to be discarded', async () => { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts index 3dab9594ba7c..374a1e81458b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts @@ -75,8 +75,15 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains quantity: 2, reason: 'sample_rate', }, + { + category: 'span', + quantity: expect.any(Number), + reason: 'sample_rate', + }, ], }); + // exact number depends on performance observer emissions + expect(clientReport.discarded_events[1].quantity).toBeGreaterThanOrEqual(3); }); await sentryTest.step('Navigate to another page with meta tags', async () => { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts index 2bb196c898fd..81ad107f62e3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts @@ -58,6 +58,11 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { quantity: 1, reason: 'sample_rate', }, + { + category: 'span', + quantity: 1, + reason: 'sample_rate', + }, ], }); }); @@ -81,6 +86,11 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { quantity: 1, reason: 'sample_rate', }, + { + category: 'span', + quantity: 1, + reason: 'sample_rate', + }, ], }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts index 0746817a92cc..4cb64a322a53 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts @@ -7,7 +7,7 @@ describe('negative sampling (static)', () => { }); createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { - test('records sample_rate outcome for root span/transaction', async () => { + test('records sample_rate outcomes for the transaction and all of its spans', async () => { const runner = createRunner() .unignore('client_report') // The `GET /ok` transaction is sent as soon as its span ends, while the negatively-sampled @@ -28,6 +28,11 @@ describe('negative sampling (static)', () => { quantity: 1, reason: 'sample_rate', }, + { + category: 'span', + quantity: 5, // 1 root span + 4 child spans + reason: 'sample_rate', + }, ], }, }) diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 1aa3818a9f1c..3ba177346bfd 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -523,7 +523,12 @@ function _startRootSpan( if (!sampled && client && !_isTracingSuppressed) { DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.'); - client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction'); + const outcomeReason = dropReason || 'sample_rate'; + if (!hasSpanStreamingEnabled(client)) { + client.recordDroppedEvent(outcomeReason, 'transaction'); + } + // Child spans of this root record their own `span` outcome in `_startChildSpan`. + client.recordDroppedEvent(outcomeReason, 'span'); } setCapturedScopesOnSpan(rootSpan, scope, isolationScope); @@ -568,7 +573,7 @@ function _startChildSpan( return childSpan; } - if (hasSpanStreamingEnabled(client) && spanIsNonRecordingSpan(childSpan)) { + if (spanIsNonRecordingSpan(childSpan)) { if (spanIsNonRecordingSpan(parentSpan) && parentSpan.dropReason) { // We land here if the parent span was a segment span that was ignored (`ignoreSpans`). // In this case, the child was also ignored (see `sampled` above) but we need to diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 3809db713668..a129c35e6a13 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -501,7 +501,9 @@ describe('startIdleSpan', () => { idleSpan.end(); + expect(recordDroppedEventSpy).toHaveBeenCalledTimes(2); expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'transaction'); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'span'); }); it('sets finish reason when span is ended manually', () => { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index e36effb86317..61d6bb0f0c7a 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2775,7 +2775,7 @@ describe('ignoreSpans (core path, streaming)', () => { expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(2, 'sample_rate', 'span'); }); - it('records sample_rate/transaction for unsampled root span on static path', () => { + it('records sample_rate outcomes for the transaction and all of its spans on static path', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 0, }); @@ -2785,11 +2785,18 @@ describe('ignoreSpans (core path, streaming)', () => { const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); startSpan({ name: 'GET /foo' }, () => { - startSpan({ name: 'db.query' }, () => {}); + startSpan({ name: 'db.query' }, () => { + startSpan({ name: 'cache.lookup' }, () => {}); + }); + startInactiveSpan({ name: 'http.client' }).end(); }); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); - expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(5); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(1, 'sample_rate', 'transaction'); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(2, 'sample_rate', 'span'); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(3, 'sample_rate', 'span'); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(4, 'sample_rate', 'span'); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(5, 'sample_rate', 'span'); }); it('records only one ignored outcome for directly ignored child span', () => { From 7780fe05ea5abd133c97c621058ad839f6fbe8cc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Oct 2026 17:08:47 +0200 Subject: [PATCH 2/5] fix count discrepancy with multiple evt. processors dropping child spans --- packages/core/src/utils/prepareEvent.ts | 15 +++++- packages/core/test/lib/client.test.ts | 67 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index f00e90932135..4588056c3a54 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -110,6 +110,10 @@ export function prepareEvent( // Skip event processors for internal exceptions to prevent recursion // oxlint-disable-next-line typescript/prefer-optional-chain const isInternalException = hint.data && (hint.data as { __sentry__: boolean }).__sentry__ === true; + const isTransaction = event.type === 'transaction'; + // Snapshot the count rather than reading `event.spans` later: processors get a shallow copy of the + // event, so one that mutates `spans` in place also mutates the original array. + const spanCountBeforeProcessing = event.spans?.length || 0; const result: PromiseLike = isInternalException ? resolvedSyncPromise(prepared) : notifyEventProcessors(eventProcessors, prepared, hint, 0, reason => { @@ -118,8 +122,8 @@ export function prepareEvent( } client.recordDroppedEvent(reason, getDataCategoryByType(event.type)); - if (event.type === 'transaction') { - client.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length); + if (isTransaction) { + client.recordDroppedEvent(reason, 'span', 1 + spanCountBeforeProcessing); } }); @@ -128,6 +132,13 @@ export function prepareEvent( return null; } + if (isTransaction && client) { + const droppedSpanCount = spanCountBeforeProcessing - (evt.spans?.length || 0); + if (droppedSpanCount > 0) { + client.recordDroppedEvent('event_processor', 'span', droppedSpanCount); + } + } + // We apply the debug_meta field only after all event processors have ran, so that if any event processors modified // file names (e.g.the RewriteFrames integration) the filename -> debug ID relationship isn't destroyed. // This should not cause any PII issues, since we're only moving data that is already on the event and not adding diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index e2c3269c4929..6f0994cf7b7a 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2161,6 +2161,73 @@ describe('Client', () => { expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'span', 2); }); + test('event processor records spans it removes from a sent transaction', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const spans = [ + { span_id: 'aaaaaaaaaaaaaaaa', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { span_id: 'bbbbbbbbbbbbbbbb', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { span_id: 'cccccccccccccccc', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + ]; + + const scope = new Scope(); + scope.addEventProcessor(event => ({ ...event, spans: event.spans?.slice(0, 1) })); + + client.captureEvent({ transaction: '/dogs/are/great', type: 'transaction', spans }, {}, scope); + + expect(TestClient.instance!.event?.spans).toHaveLength(1); + expect(recordLostEventSpy).toHaveBeenCalledTimes(1); + expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'span', 2); + }); + + test('event processor counts spans removed in place before a later processor drops the transaction', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const spans = [ + { span_id: 'aaaaaaaaaaaaaaaa', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { span_id: 'bbbbbbbbbbbbbbbb', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { span_id: 'cccccccccccccccc', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + ]; + + const scope = new Scope(); + scope.addEventProcessor(event => { + event.spans?.splice(0, 2); + return event; + }); + scope.addEventProcessor(() => null); + + client.captureEvent({ transaction: '/dogs/are/great', type: 'transaction', spans }, {}, scope); + + expect(recordLostEventSpy).toHaveBeenCalledTimes(2); + expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'transaction'); + expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'span', 4); + }); + + test('spans removed by event processors and `beforeSendTransaction` are each counted once', () => { + const beforeSendTransaction = vi.fn(event => ({ ...event, spans: [] })); + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendTransaction })); + const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const spans = [ + { span_id: 'aaaaaaaaaaaaaaaa', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { span_id: 'bbbbbbbbbbbbbbbb', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { span_id: 'cccccccccccccccc', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + ]; + + const scope = new Scope(); + scope.addEventProcessor(event => ({ ...event, spans: event.spans?.slice(0, 1) })); + + client.captureEvent({ transaction: '/dogs/are/great', type: 'transaction', spans }, {}, scope); + + expect(TestClient.instance!.event?.type).toBe('transaction'); + expect(TestClient.instance!.event?.spans).toEqual([]); + expect(recordLostEventSpy).toHaveBeenCalledTimes(2); + expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'span', 2); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'span', 1); + }); + test('mutating transaction name with event processors sets transaction-name-change metadata', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableSend: true }); const client = new TestClient(options); From 94a5d34010eeca03cf8ca73001d4414886004e37 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Oct 2026 17:58:59 +0200 Subject: [PATCH 3/5] fix(core): Count unsampled standalone spans once and not as transactions An unsampled standalone span (e.g. a late INP span) recorded a `transaction` outcome when it started, although it never becomes a transaction, and a second `span` outcome when it ended. Record a single `span` outcome on start instead. Derive the expected span count in the static sampling integration test from the `GET /ok` transaction, so it also holds on Bun, which creates no Express spans. Co-Authored-By: Claude --- .../suites/tracing/sampling-static/test.ts | 32 ++++++++++--------- packages/core/src/tracing/sentrySpan.ts | 3 +- packages/core/src/tracing/trace.ts | 4 ++- packages/core/test/lib/tracing/trace.test.ts | 16 ++++++++++ 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts index 4cb64a322a53..65ef8a945465 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts @@ -8,6 +8,12 @@ describe('negative sampling (static)', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { test('records sample_rate outcomes for the transaction and all of its spans', async () => { + // `/health` and `/ok` go through the same middleware, so the `/ok` transaction tells us how many + // spans the dropped `/health` transaction had. The count differs per runtime (e.g. Bun creates + // no Express spans), so derive it instead of hardcoding it. + let okSpanCount: number | undefined; + let droppedSpanCount: number | undefined; + const runner = createRunner() .unignore('client_report') // The `GET /ok` transaction is sent as soon as its span ends, while the negatively-sampled @@ -16,24 +22,18 @@ describe('negative sampling (static)', () => { // match unordered instead of asserting a fixed sequence. .unordered() .expect({ - transaction: { - transaction: 'GET /ok', + transaction: transaction => { + expect(transaction.transaction).toBe('GET /ok'); + okSpanCount = 1 + (transaction.spans?.length ?? 0); }, }) .expect({ - client_report: { - discarded_events: [ - { - category: 'transaction', - quantity: 1, - reason: 'sample_rate', - }, - { - category: 'span', - quantity: 5, // 1 root span + 4 child spans - reason: 'sample_rate', - }, - ], + client_report: clientReport => { + expect(clientReport.discarded_events).toEqual([ + { category: 'transaction', quantity: 1, reason: 'sample_rate' }, + { category: 'span', quantity: expect.any(Number), reason: 'sample_rate' }, + ]); + droppedSpanCount = clientReport.discarded_events[1]!.quantity; }, }) .start(); @@ -45,6 +45,8 @@ describe('negative sampling (static)', () => { expect((res2 as { status: string }).status).toBe('ok'); await runner.completed(); + + expect(droppedSpanCount).toBe(okSpanCount); }); }); }); diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index abd27b52edf1..f307649592d8 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -367,9 +367,8 @@ export class SentrySpan implements Span { return; } + // The `sample_rate` outcome was already recorded when the span was started (see `_startRootSpan`). DEBUG_BUILD && debug.log('[Tracing] Discarding standalone span because its trace was not chosen to be sampled.'); - client.recordDroppedEvent('sample_rate', 'span'); - return; } diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 3ba177346bfd..69ed4c9f5de1 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -524,7 +524,9 @@ function _startRootSpan( if (!sampled && client && !_isTracingSuppressed) { DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.'); const outcomeReason = dropReason || 'sample_rate'; - if (!hasSpanStreamingEnabled(client)) { + // A standalone span is sent on its own and never becomes a transaction. + // TODO(standalone): drop the `isStandalone` check once the static trace lifecycle is gone. + if (!hasSpanStreamingEnabled(client) && !spanArguments.isStandalone) { client.recordDroppedEvent(outcomeReason, 'transaction'); } // Child spans of this root record their own `span` outcome in `_startChildSpan`. diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 61d6bb0f0c7a..5d947419ebc6 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2799,6 +2799,22 @@ describe('ignoreSpans (core path, streaming)', () => { expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(5, 'sample_rate', 'span'); }); + it('records a single sample_rate/span outcome for an unsampled standalone root span on static path', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 0, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + // oxlint-disable-next-line typescript/no-deprecated + startInactiveSpan({ name: 'inp', experimental: { standalone: true } }).end(); + + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); + }); + it('records only one ignored outcome for directly ignored child span', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1, From fdb46e2fffec567a8c2d73b52f17fa1bf9fa0a99 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Oct 2026 18:08:58 +0200 Subject: [PATCH 4/5] more dropping combination fixes --- packages/core/src/client.ts | 31 ++-- packages/core/src/scope.ts | 1 - packages/core/test/lib/client.test.ts | 225 ++++++++++++++++++++++++-- 3 files changed, 225 insertions(+), 32 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 12d02b348bb2..e8ebc104d0c5 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1522,6 +1522,9 @@ export abstract class Client { // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); const dataCategory = getDataCategoryByType(event.type); + // Spans that event processors removed are already recorded in `prepareEvent`, so all later + // span outcomes are relative to the span count of the prepared event. + let preparedSpanCount = 0; return this._prepareEvent(event, hint, currentScope, isolationScope) .then(prepared => { @@ -1529,12 +1532,14 @@ export abstract class Client { throw _makeDoNotSendEventError('An event processor returned `null`, will not send event.'); } + preparedSpanCount = prepared.spans?.length || 0; + const isInternalException = (hint.data as { __sentry__: boolean })?.__sentry__ === true; if (isInternalException) { return prepared; } - const result = processBeforeSend(this, options, prepared, hint, () => { + const result = processBeforeSend(options, prepared, hint, () => { beforeSendDropReason = 'callback_error'; }); return _validateBeforeSendResult(result, beforeSendLabel); @@ -1543,9 +1548,8 @@ export abstract class Client { if (processedEvent === null) { this.recordDroppedEvent(beforeSendDropReason, dataCategory); if (isTransaction) { - const spans = event.spans || []; // the transaction itself counts as one span, plus all the child spans that are added - this.recordDroppedEvent(beforeSendDropReason, 'span', 1 + spans.length); + this.recordDroppedEvent(beforeSendDropReason, 'span', 1 + preparedSpanCount); } const dropMessage = beforeSendDropReason === 'callback_error' ? 'threw an error' : 'returned `null`'; throw _makeDoNotSendEventError(`${beforeSendLabel} ${dropMessage}, will not send event.`); @@ -1564,10 +1568,8 @@ export abstract class Client { } if (isTransaction) { - const spanCountBefore = processedEvent.sdkProcessingMetadata?.spanCountBeforeProcessing || 0; - const spanCountAfter = processedEvent.spans ? processedEvent.spans.length : 0; - - const droppedSpanCount = spanCountBefore - spanCountAfter; + // Covers child spans dropped by `ignoreSpans` as well as spans removed by `beforeSendTransaction` + const droppedSpanCount = preparedSpanCount - (processedEvent.spans?.length || 0); if (droppedSpanCount > 0) { this.recordDroppedEvent('before_send', 'span', droppedSpanCount); } @@ -1721,7 +1723,6 @@ function _validateBeforeSendResult( * Process the matching `beforeSendXXX` callback. */ function processBeforeSend( - client: Client, options: ClientOptions, event: Event, hint: EventHint, @@ -1798,25 +1799,11 @@ function processBeforeSend( } } - const droppedSpans = processedEvent.spans.length - processedSpans.length; - if (droppedSpans) { - client.recordDroppedEvent('before_send', 'span', droppedSpans); - } - processedEvent.spans = processedSpans; } } if (beforeSendTransaction) { - if (processedEvent.spans) { - // We store the # of spans before processing in SDK metadata, - // so we can compare it afterwards to determine how many spans were dropped - const spanCountBefore = processedEvent.spans.length; - processedEvent.sdkProcessingMetadata = { - ...event.sdkProcessingMetadata, - spanCountBeforeProcessing: spanCountBefore, - }; - } return safeCallback( DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '', () => beforeSendTransaction(processedEvent as TransactionEvent, hint), diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index d2fba52a8fc0..5ae6649c0a0c 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -62,7 +62,6 @@ export interface SdkProcessingMetadata { dynamicSamplingContext?: Partial; capturedSpanScope?: Scope; capturedSpanIsolationScope?: Scope; - spanCountBeforeProcessing?: number; ipAddress?: string; } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 6f0994cf7b7a..f0e47a5317b2 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2166,9 +2166,27 @@ describe('Client', () => { const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); const spans = [ - { span_id: 'aaaaaaaaaaaaaaaa', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, - { span_id: 'bbbbbbbbbbbbbbbb', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, - { span_id: 'cccccccccccccccc', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { + span_id: 'aaaaaaaaaaaaaaaa', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'bbbbbbbbbbbbbbbb', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'cccccccccccccccc', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, ]; const scope = new Scope(); @@ -2186,9 +2204,27 @@ describe('Client', () => { const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); const spans = [ - { span_id: 'aaaaaaaaaaaaaaaa', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, - { span_id: 'bbbbbbbbbbbbbbbb', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, - { span_id: 'cccccccccccccccc', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { + span_id: 'aaaaaaaaaaaaaaaa', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'bbbbbbbbbbbbbbbb', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'cccccccccccccccc', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, ]; const scope = new Scope(); @@ -2211,9 +2247,27 @@ describe('Client', () => { const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); const spans = [ - { span_id: 'aaaaaaaaaaaaaaaa', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, - { span_id: 'bbbbbbbbbbbbbbbb', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, - { span_id: 'cccccccccccccccc', start_timestamp: 1, trace_id: '86f39e84263a4de99c326acab3bfe3bd', data: {} }, + { + span_id: 'aaaaaaaaaaaaaaaa', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'bbbbbbbbbbbbbbbb', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'cccccccccccccccc', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, ]; const scope = new Scope(); @@ -2228,6 +2282,159 @@ describe('Client', () => { expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'span', 1); }); + test('spans removed by event processors are not counted again when `beforeSendTransaction` drops the transaction', () => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendTransaction: () => null }), + ); + const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const spans = [ + { + span_id: 'aaaaaaaaaaaaaaaa', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'bbbbbbbbbbbbbbbb', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'cccccccccccccccc', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + ]; + + const scope = new Scope(); + scope.addEventProcessor(event => ({ ...event, spans: event.spans?.slice(0, 1) })); + + client.captureEvent({ transaction: '/dogs/are/great', type: 'transaction', spans }, {}, scope); + + expect(TestClient.instance!.event).toBeUndefined(); + expect(recordLostEventSpy).toHaveBeenCalledTimes(3); + expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'span', 2); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'transaction'); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'span', 2); + }); + + test('spans removed by event processors are not counted again when `ignoreSpans` drops the root span', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN, ignoreSpans: ['/dogs/are/great'] })); + const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const spans = [ + { + span_id: 'aaaaaaaaaaaaaaaa', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'bbbbbbbbbbbbbbbb', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'cccccccccccccccc', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + ]; + + const scope = new Scope(); + scope.addEventProcessor(event => ({ ...event, spans: event.spans?.slice(0, 1) })); + + client.captureEvent({ transaction: '/dogs/are/great', type: 'transaction', spans }, {}, scope); + + expect(TestClient.instance!.event).toBeUndefined(); + expect(recordLostEventSpy).toHaveBeenCalledTimes(3); + expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'span', 2); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'transaction'); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'span', 2); + }); + + test('child spans dropped by `ignoreSpans` are not counted again when `beforeSendTransaction` drops the transaction', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + ignoreSpans: ['ignored-child'], + beforeSendTransaction: () => null, + }), + ); + const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const spans = [ + { + span_id: 'aaaaaaaaaaaaaaaa', + description: 'ignored-child', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'bbbbbbbbbbbbbbbb', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + ]; + + client.captureEvent({ transaction: '/dogs/are/great', type: 'transaction', spans }); + + expect(TestClient.instance!.event).toBeUndefined(); + expect(recordLostEventSpy).toHaveBeenCalledTimes(2); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'transaction'); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'span', 3); + }); + + test('child spans dropped by `ignoreSpans` and removed by `beforeSendTransaction` are counted once', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + ignoreSpans: ['ignored-child'], + beforeSendTransaction: event => ({ ...event, spans: [] }), + }), + ); + const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const spans = [ + { + span_id: 'aaaaaaaaaaaaaaaa', + description: 'ignored-child', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + { + span_id: 'bbbbbbbbbbbbbbbb', + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + }, + ]; + + client.captureEvent({ transaction: '/dogs/are/great', type: 'transaction', spans }); + + expect(TestClient.instance!.event?.spans).toEqual([]); + expect(recordLostEventSpy).toHaveBeenCalledTimes(1); + expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'span', 2); + }); + test('mutating transaction name with event processors sets transaction-name-change metadata', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableSend: true }); const client = new TestClient(options); From 886e600d2f7c51e5ad1aac17274231643a4d0242 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 2 Oct 2026 18:20:28 +0200 Subject: [PATCH 5/5] test(core): Assert span outcomes when all drop mechanisms combine Run event processors, `ignoreSpans`, `beforeSendSpan` and `beforeSendTransaction` on the same transaction and check that every span is either sent or counted exactly once, both when the transaction is sent and when `beforeSendTransaction` drops it. Co-Authored-By: Claude --- packages/core/test/lib/client.test.ts | 89 +++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index f0e47a5317b2..1107944b34e0 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -21,6 +21,7 @@ import { _INTERNAL_captureMetric } from '../../src/metrics/internal'; import * as traceModule from '../../src/tracing/trace'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base'; import type { Envelope } from '../../src/types/envelope'; +import type { Outcome } from '../../src/types/clientreport'; import type { ErrorEvent, Event, TransactionEvent } from '../../src/types/event'; import type { SpanJSON } from '../../src/types/span'; import * as debugLoggerModule from '../../src/utils/debug-logger'; @@ -2435,6 +2436,94 @@ describe('Client', () => { expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'span', 2); }); + describe('span outcomes when all span drop mechanisms apply to the same transaction', () => { + const childSpanDescriptions = [ + 'removed-by-event-processor-1', + 'removed-by-event-processor-2', + 'ignored-child', + 'removed-by-before-send-transaction', + 'kept-1', + 'kept-2', + ]; + + function captureTransaction( + beforeSendTransaction: (event: TransactionEvent) => TransactionEvent | null, + ): TestClient { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + ignoreSpans: ['ignored-child'], + beforeSendSpan: withStaticSpan(span => ({ ...span, data: { ...span.data, scrubbed: true } })), + beforeSendTransaction, + }), + ); + + const scope = new Scope(); + scope.addEventProcessor(event => ({ + ...event, + spans: event.spans?.filter(span => !span.description?.startsWith('removed-by-event-processor')), + })); + + client.captureEvent( + { + transaction: '/dogs/are/great', + type: 'transaction', + spans: childSpanDescriptions.map((description, i) => ({ + description, + span_id: `${i}`.padStart(16, '0'), + start_timestamp: 1, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + status: 'ok', + })), + }, + {}, + scope, + ); + + return client; + } + + function getOutcomes(client: TestClient): { outcomes: Outcome[]; spanOutcomeTotal: number } { + const outcomes = client._clearOutcomes(); + const spanOutcomeTotal = outcomes.filter(o => o.category === 'span').reduce((sum, o) => sum + o.quantity, 0); + return { outcomes, spanOutcomeTotal }; + } + + test('counts each dropped span exactly once when the transaction is sent', () => { + const client = captureTransaction(event => ({ + ...event, + spans: event.spans?.filter(span => span.description !== 'removed-by-before-send-transaction'), + })); + + const sentSpans = TestClient.instance!.event!.spans!; + expect(sentSpans.map(span => span.description)).toEqual(['kept-1', 'kept-2']); + + const { outcomes, spanOutcomeTotal } = getOutcomes(client); + expect(outcomes).toEqual([ + { reason: 'event_processor', category: 'span', quantity: 2 }, + { reason: 'before_send', category: 'span', quantity: 2 }, + ]); + // every child span is either sent or counted once; the root span is sent as the transaction + expect(spanOutcomeTotal + sentSpans.length).toBe(childSpanDescriptions.length); + }); + + test('counts each span exactly once when `beforeSendTransaction` drops the transaction', () => { + const client = captureTransaction(() => null); + + expect(TestClient.instance!.event).toBeUndefined(); + + const { outcomes, spanOutcomeTotal } = getOutcomes(client); + expect(outcomes).toEqual([ + { reason: 'event_processor', category: 'span', quantity: 2 }, + { reason: 'before_send', category: 'transaction', quantity: 1 }, + { reason: 'before_send', category: 'span', quantity: 5 }, + ]); + // all child spans plus the root span are counted once + expect(spanOutcomeTotal).toBe(childSpanDescriptions.length + 1); + }); + }); + test('mutating transaction name with event processors sets transaction-name-change metadata', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableSend: true }); const client = new TestClient(options);