Skip to content

Commit 1f95be2

Browse files
Lms24claude
andauthored
fix(core): Record span client report outcomes when discarding transactions (#25006)
The [client report spec](https://develop.sentry.dev/sdk/telemetry/client-reports/#span-outcomes) calls for recording category `span` client outcomes, also if streaming is disabled. This PR makes the change to record outcomes for each span, including one for the transaction itself when a transaction is sampled negatively, as well as when it's dropped. The transaction/event processing pipeline had a bunch of holes for discarded span counts, since any combination of `ignoreSpans`, event processors and `beforeSendTransaction` could drop child spans in any of those callbacks. So for example, a transaction that originally had 10 child spans and drops - 2 spans in ignoreSpans - 1 span in an event processor - the whole transaction in beforeSendTransaction now all together reports 11 `span`s and 1 `transaction` outcome, with the correct reasons. No span drops are double-counted. Related bugfix: Spans dropped by `ignoreSpans` without span streaming are now recorded as `ignored` instead of `before_send`. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 153f4f8 commit 1f95be2

12 files changed

Lines changed: 555 additions & 59 deletions

File tree

‎dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-static/consistent-sampling/meta-negative/test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains
9191
quantity: 4,
9292
reason: 'sample_rate',
9393
},
94+
{
95+
category: 'span',
96+
quantity: expect.any(Number),
97+
reason: 'sample_rate',
98+
},
9499
],
95100
});
96101
});

‎dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-static/consistent-sampling/meta-precedence/test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains
7575
quantity: 2,
7676
reason: 'sample_rate',
7777
},
78+
{
79+
category: 'span',
80+
quantity: expect.any(Number),
81+
reason: 'sample_rate',
82+
},
7883
],
7984
});
8085
});

‎dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-static/consistent-sampling/tracesSampler-precedence/test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
5959
quantity: 1,
6060
reason: 'sample_rate',
6161
},
62+
{
63+
category: 'span',
64+
quantity: expect.any(Number),
65+
reason: 'sample_rate',
66+
},
6267
],
6368
});
6469
});
@@ -80,6 +85,11 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
8085
quantity: 1,
8186
reason: 'sample_rate',
8287
},
88+
{
89+
category: 'span',
90+
quantity: expect.any(Number),
91+
reason: 'sample_rate',
92+
},
8393
],
8494
});
8595
});

‎dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ describe('negative sampling (static)', () => {
77
});
88

99
createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
10-
test('records sample_rate outcome for root span/transaction', async () => {
10+
test('records sample_rate outcomes for the transaction and all of its spans', async () => {
11+
// `/health` and `/ok` go through the same middleware, so the `/ok` transaction tells us how many
12+
// spans the dropped `/health` transaction would have had. The count differs per runtime (e.g. Bun creates
13+
// no Express spans), so we derive it instead of hardcoding it.
14+
let okSpanCount: number | undefined;
15+
let droppedSpanCount: number | undefined;
16+
1117
const runner = createRunner()
1218
.unignore('client_report')
1319
// The `GET /ok` transaction is sent as soon as its span ends, while the negatively-sampled
@@ -16,19 +22,18 @@ describe('negative sampling (static)', () => {
1622
// match unordered instead of asserting a fixed sequence.
1723
.unordered()
1824
.expect({
19-
transaction: {
20-
transaction: 'GET /ok',
25+
transaction: transaction => {
26+
expect(transaction.transaction).toBe('GET /ok');
27+
okSpanCount = 1 + (transaction.spans?.length ?? 0);
2128
},
2229
})
2330
.expect({
24-
client_report: {
25-
discarded_events: [
26-
{
27-
category: 'transaction',
28-
quantity: 1,
29-
reason: 'sample_rate',
30-
},
31-
],
31+
client_report: clientReport => {
32+
expect(clientReport.discarded_events).toEqual([
33+
{ category: 'transaction', quantity: 1, reason: 'sample_rate' },
34+
{ category: 'span', quantity: expect.any(Number), reason: 'sample_rate' },
35+
]);
36+
droppedSpanCount = clientReport.discarded_events[1]!.quantity;
3237
},
3338
})
3439
.start();
@@ -40,6 +45,8 @@ describe('negative sampling (static)', () => {
4045
expect((res2 as { status: string }).status).toBe('ok');
4146

4247
await runner.completed();
48+
49+
expect(droppedSpanCount).toBe(okSpanCount);
4350
});
4451
});
4552
});

‎packages/core/src/client.ts‎

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,39 +1532,59 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
15321532
const isError = isErrorEvent(event);
15331533
const eventType = event.type || 'error';
15341534
const beforeSendLabel = `before send for type \`${eventType}\``;
1535-
let beforeSendDropReason: 'before_send' | 'callback_error' = 'before_send';
1535+
let beforeSendDropReason: BeforeSendDropReason = 'before_send';
1536+
let ignoredSpanCount = 0;
15361537

15371538
// 1.0 === 100% events are sent
15381539
// 0.0 === 0% events are sent
15391540
// Sampling for transaction happens somewhere else
15401541
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
15411542
const dataCategory = getDataCategoryByType(event.type);
1543+
// Spans that event processors removed are already recorded in `prepareEvent`, so all later
1544+
// span outcomes are relative to the span count of the prepared event.
1545+
let preparedSpanCount = 0;
15421546

15431547
return this._prepareEvent(event, hint, currentScope, isolationScope)
15441548
.then(prepared => {
15451549
if (prepared === null) {
15461550
throw _makeDoNotSendEventError('An event processor returned `null`, will not send event.');
15471551
}
15481552

1553+
preparedSpanCount = prepared.spans?.length || 0;
1554+
15491555
if (isInternalException(hint)) {
15501556
return prepared;
15511557
}
15521558

1553-
const result = processBeforeSend(this, options, prepared, hint, () => {
1554-
beforeSendDropReason = 'callback_error';
1555-
});
1559+
const result = processBeforeSend(
1560+
options,
1561+
prepared,
1562+
hint,
1563+
reason => {
1564+
beforeSendDropReason = reason;
1565+
},
1566+
count => {
1567+
ignoredSpanCount = count;
1568+
},
1569+
);
15561570
return _validateBeforeSendResult(result, beforeSendLabel);
15571571
})
15581572
.then(processedEvent => {
1573+
if (ignoredSpanCount) {
1574+
this.recordDroppedEvent('ignored', 'span', ignoredSpanCount);
1575+
}
1576+
15591577
if (processedEvent === null) {
15601578
this.recordDroppedEvent(beforeSendDropReason, dataCategory);
15611579
if (isTransaction) {
1562-
const spans = event.spans || [];
1563-
// the transaction itself counts as one span, plus all the child spans that are added
1564-
this.recordDroppedEvent(beforeSendDropReason, 'span', 1 + spans.length);
1580+
// the transaction itself counts as one span, plus all the child spans that weren't ignored before
1581+
this.recordDroppedEvent(beforeSendDropReason, 'span', 1 + preparedSpanCount - ignoredSpanCount);
15651582
}
1566-
const dropMessage = beforeSendDropReason === 'callback_error' ? 'threw an error' : 'returned `null`';
1567-
throw _makeDoNotSendEventError(`${beforeSendLabel} ${dropMessage}, will not send event.`);
1583+
const dropMessage =
1584+
beforeSendDropReason === 'ignored'
1585+
? 'Transaction matched `ignoreSpans`'
1586+
: `${beforeSendLabel} ${beforeSendDropReason === 'callback_error' ? 'threw an error' : 'returned `null`'}`;
1587+
throw _makeDoNotSendEventError(`${dropMessage}, will not send event.`);
15681588
}
15691589

15701590
const session = currentScope.getSession() || isolationScope.getSession();
@@ -1580,10 +1600,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
15801600
}
15811601

15821602
if (isTransaction) {
1583-
const spanCountBefore = processedEvent.sdkProcessingMetadata?.spanCountBeforeProcessing || 0;
1584-
const spanCountAfter = processedEvent.spans ? processedEvent.spans.length : 0;
1585-
1586-
const droppedSpanCount = spanCountBefore - spanCountAfter;
1603+
const droppedSpanCount = preparedSpanCount - ignoredSpanCount - (processedEvent.spans?.length || 0);
15871604
if (droppedSpanCount > 0) {
15881605
this.recordDroppedEvent('before_send', 'span', droppedSpanCount);
15891606
}
@@ -1733,15 +1750,17 @@ function _validateBeforeSendResult(
17331750
return beforeSendResult;
17341751
}
17351752

1753+
type BeforeSendDropReason = 'before_send' | 'callback_error' | 'ignored';
1754+
17361755
/**
17371756
* Process the matching `beforeSendXXX` callback.
17381757
*/
17391758
function processBeforeSend(
1740-
client: Client,
17411759
options: ClientOptions,
17421760
event: Event,
17431761
hint: EventHint,
1744-
onCallbackError: () => void,
1762+
onDrop: (reason: Exclude<BeforeSendDropReason, 'before_send'>) => void,
1763+
onIgnoredSpans: (count: number) => void,
17451764
): PromiseLike<Event | null> | Event | null {
17461765
const {
17471766
beforeSend,
@@ -1759,7 +1778,7 @@ function processBeforeSend(
17591778
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
17601779
() => beforeSend(errorEvent, hint),
17611780
() => {
1762-
onCallbackError();
1781+
onDrop('callback_error');
17631782
return null;
17641783
},
17651784
);
@@ -1779,7 +1798,7 @@ function processBeforeSend(
17791798
ignoreSpans,
17801799
)
17811800
) {
1782-
// dropping the whole transaction!
1801+
onDrop('ignored');
17831802
return null;
17841803
}
17851804

@@ -1814,30 +1833,17 @@ function processBeforeSend(
18141833
}
18151834
}
18161835

1817-
const droppedSpans = processedEvent.spans.length - processedSpans.length;
1818-
if (droppedSpans) {
1819-
client.recordDroppedEvent('before_send', 'span', droppedSpans);
1820-
}
1821-
1836+
onIgnoredSpans(initialSpans.length - processedSpans.length);
18221837
processedEvent.spans = processedSpans;
18231838
}
18241839
}
18251840

18261841
if (beforeSendTransaction) {
1827-
if (processedEvent.spans) {
1828-
// We store the # of spans before processing in SDK metadata,
1829-
// so we can compare it afterwards to determine how many spans were dropped
1830-
const spanCountBefore = processedEvent.spans.length;
1831-
processedEvent.sdkProcessingMetadata = {
1832-
...event.sdkProcessingMetadata,
1833-
spanCountBeforeProcessing: spanCountBefore,
1834-
};
1835-
}
18361842
return safeCallback(
18371843
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
18381844
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
18391845
() => {
1840-
onCallbackError();
1846+
onDrop('callback_error');
18411847
return null;
18421848
},
18431849
);

‎packages/core/src/scope.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export interface SdkProcessingMetadata {
6262
dynamicSamplingContext?: Partial<DynamicSamplingContext>;
6363
capturedSpanScope?: Scope;
6464
capturedSpanIsolationScope?: Scope;
65+
/**
66+
* @deprecated This is no longer set or read by the SDK and will be removed in the next major version.
67+
*/
6568
spanCountBeforeProcessing?: number;
6669
ipAddress?: string;
6770
}

‎packages/core/src/tracing/sentrySpan.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,6 @@ export class SentrySpan implements Span {
392392
}
393393

394394
DEBUG_BUILD && debug.log('[Tracing] Discarding standalone span because its trace was not chosen to be sampled.');
395-
client.recordDroppedEvent('sample_rate', 'span');
396-
397395
return;
398396
}
399397

‎packages/core/src/tracing/trace.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,11 @@ export function startNewTrace<T>(callback: () => T): T {
308308
*/
309309
function startMissingRequiredParentSpan(scope: Scope, client: Client | undefined): SentryNonRecordingSpan {
310310
client?.recordDroppedEvent('no_parent_span', 'span');
311-
const span = new SentryNonRecordingSpan({ traceId: scope.getPropagationContext().traceId });
311+
// Spans nested inside the placeholder inherit its drop reason in `_startChildSpan`
312+
const span = new SentryNonRecordingSpan({
313+
dropReason: 'no_parent_span',
314+
traceId: scope.getPropagationContext().traceId,
315+
});
312316
setCapturedScopesOnSpan(span, scope, getIsolationScope());
313317
return span;
314318
}
@@ -523,7 +527,14 @@ function _startRootSpan(
523527

524528
if (!sampled && client && !_isTracingSuppressed) {
525529
DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.');
526-
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
530+
const outcomeReason = dropReason || 'sample_rate';
531+
// A standalone span is sent on its own and never becomes a transaction.
532+
// TODO(v12): Drop the `isStandalone` check once the static trace lifecycle is gone.
533+
if (!hasSpanStreamingEnabled(client) && !spanArguments.isStandalone) {
534+
client.recordDroppedEvent(outcomeReason, 'transaction');
535+
}
536+
// Child spans of this root record their own `span` outcome in `_startChildSpan`.
537+
client.recordDroppedEvent(outcomeReason, 'span');
527538
}
528539

529540
setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
@@ -568,11 +579,10 @@ function _startChildSpan(
568579
return childSpan;
569580
}
570581

571-
if (hasSpanStreamingEnabled(client) && spanIsNonRecordingSpan(childSpan)) {
582+
if (spanIsNonRecordingSpan(childSpan)) {
572583
if (spanIsNonRecordingSpan(parentSpan) && parentSpan.dropReason) {
573-
// We land here if the parent span was a segment span that was ignored (`ignoreSpans`).
574-
// In this case, the child was also ignored (see `sampled` above) but we need to
575-
// record a client outcome for the child.
584+
// The parent was dropped for a reason other than sampling (e.g. an ignored segment span or
585+
// an `onlyIfParent` placeholder), so the child is dropped for the same reason.
576586
childSpan.dropReason = parentSpan.dropReason;
577587
client.recordDroppedEvent(parentSpan.dropReason, 'span');
578588
} else if (!_isTracingSuppressed) {

‎packages/core/src/utils/prepareEvent.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ export function prepareEvent(
109109
];
110110

111111
// Skip event processors for internal exceptions to prevent recursion
112+
const isTransaction = event.type === 'transaction';
113+
// Snapshot the count rather than reading `event.spans` later: processors get a shallow copy of the
114+
// event, so one that mutates `spans` in place also mutates the original array.
115+
const spanCountBeforeProcessing = event.spans?.length || 0;
112116
const result: PromiseLike<Event | null> = isInternalException(hint)
113117
? resolvedSyncPromise(prepared)
114118
: notifyEventProcessors(eventProcessors, prepared, hint, 0, reason => {
@@ -117,8 +121,8 @@ export function prepareEvent(
117121
}
118122

119123
client.recordDroppedEvent(reason, getDataCategoryByType(event.type));
120-
if (event.type === 'transaction') {
121-
client.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
124+
if (isTransaction) {
125+
client.recordDroppedEvent(reason, 'span', 1 + spanCountBeforeProcessing);
122126
}
123127
});
124128

@@ -127,6 +131,13 @@ export function prepareEvent(
127131
return null;
128132
}
129133

134+
if (isTransaction && client) {
135+
const droppedSpanCount = spanCountBeforeProcessing - (evt.spans?.length || 0);
136+
if (droppedSpanCount > 0) {
137+
client.recordDroppedEvent('event_processor', 'span', droppedSpanCount);
138+
}
139+
}
140+
130141
// We apply the debug_meta field only after all event processors have ran, so that if any event processors modified
131142
// file names (e.g.the RewriteFrames integration) the filename -> debug ID relationship isn't destroyed.
132143
// This should not cause any PII issues, since we're only moving data that is already on the event and not adding

0 commit comments

Comments
 (0)