Skip to content

Commit 8f068cc

Browse files
msonnbclaude
andcommitted
feat(core): Record callback_error client reports for throwing user callbacks
Events, logs, metrics and root spans dropped because a user callback threw were reported with the same outcome as a legitimate filter (`before_send`, `event_processor`, `sample_rate`). A dedicated `callback_error` reason makes them distinguishable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 449b642 commit 8f068cc

16 files changed

Lines changed: 114 additions & 75 deletions

File tree

‎dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send-throws/test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
1414
{
1515
category: 'error',
1616
quantity: 1,
17-
reason: 'before_send',
17+
reason: 'callback_error',
1818
},
1919
],
2020
},

‎dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processor-throws/test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
1414
{
1515
category: 'error',
1616
quantity: 1,
17-
reason: 'event_processor',
17+
reason: 'callback_error',
1818
},
1919
],
2020
},
@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
3232
{
3333
category: 'error',
3434
quantity: 1,
35-
reason: 'event_processor',
35+
reason: 'callback_error',
3636
},
3737
],
3838
},

‎dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
1414
{
1515
category: 'span',
1616
quantity: 1,
17-
reason: 'sample_rate',
17+
reason: 'callback_error',
1818
},
1919
],
2020
},

‎packages/core/src/client.ts‎

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
5050
import { prepareEvent } from './utils/prepareEvent';
5151
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
5252
import { safeMathRandom } from './utils/randomSafeContext';
53-
import { safeCallback } from './utils/safeCallback';
53+
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
5454
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
5555
import { safeUnref } from './utils/timer';
5656
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
15221522
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
15231523
const dataCategory = getDataCategoryByType(event.type);
15241524

1525+
const recordDroppedEvent = (reason: EventDropReason): void => {
1526+
this.recordDroppedEvent(reason, dataCategory);
1527+
if (isTransaction) {
1528+
// the transaction itself counts as one span, plus all the child spans that are added
1529+
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
1530+
}
1531+
};
1532+
15251533
return this._prepareEvent(event, hint, currentScope, isolationScope)
15261534
.then(prepared => {
15271535
if (prepared === null) {
@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
15391547
})
15401548
.then(processedEvent => {
15411549
if (processedEvent === null) {
1542-
this.recordDroppedEvent('before_send', dataCategory);
1543-
if (isTransaction) {
1544-
const spans = event.spans || [];
1545-
// the transaction itself counts as one span, plus all the child spans that are added
1546-
const spanCount = 1 + spans.length;
1547-
this.recordDroppedEvent('before_send', 'span', spanCount);
1548-
}
1550+
recordDroppedEvent('before_send');
15491551
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
15501552
}
15511553

@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
15871589
return processedEvent;
15881590
})
15891591
.then(null, reason => {
1592+
if (reason === CALLBACK_ERROR) {
1593+
recordDroppedEvent('callback_error');
1594+
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
1595+
}
1596+
15901597
if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
15911598
throw reason;
15921599
}
@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
17021709
): PromiseLike<Event | null> | Event | null {
17031710
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
17041711
if (isThenable(beforeSendResult)) {
1705-
return beforeSendResult.then(
1706-
event => {
1707-
if (!isPlainObject(event) && event !== null) {
1708-
throw _makeInternalError(invalidValueError);
1709-
}
1710-
return event;
1711-
},
1712-
e => {
1713-
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
1714-
},
1715-
);
1712+
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
1713+
return beforeSendResult.then(event => {
1714+
if (!isPlainObject(event) && event !== null) {
1715+
throw _makeInternalError(invalidValueError);
1716+
}
1717+
return event;
1718+
});
17161719
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
17171720
throw _makeInternalError(invalidValueError);
17181721
}
@@ -1743,7 +1746,9 @@ function processBeforeSend(
17431746
return safeCallback(
17441747
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
17451748
() => beforeSend(errorEvent, hint),
1746-
() => null,
1749+
() => {
1750+
throw CALLBACK_ERROR;
1751+
},
17471752
);
17481753
}
17491754

@@ -1818,7 +1823,9 @@ function processBeforeSend(
18181823
return safeCallback(
18191824
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
18201825
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
1821-
() => null,
1826+
() => {
1827+
throw CALLBACK_ERROR;
1828+
},
18221829
);
18231830
}
18241831
}

‎packages/core/src/eventProcessors.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
33
import type { EventProcessor } from './types/eventprocessor';
44
import { debug } from './utils/debug-logger';
55
import { isThenable } from './utils/is';
6-
import { safeCallback } from './utils/safeCallback';
6+
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
77
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';
88

99
/**
1010
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
11+
* Rejects with `CALLBACK_ERROR` if a processor throws.
1112
*/
1213
export function notifyEventProcessors(
1314
processors: EventProcessor[],
@@ -40,7 +41,9 @@ function _notifyEventProcessors(
4041
const result = safeCallback(
4142
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
4243
() => processor({ ...event }, hint),
43-
() => null,
44+
() => {
45+
throw CALLBACK_ERROR;
46+
},
4447
);
4548

4649
DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);

‎packages/core/src/logs/internal.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
88
import type { Log, SerializedLog } from '../types/log';
99
import { consoleSandbox, debug } from '../utils/debug-logger';
1010
import { isParameterizedString } from '../utils/is';
11-
import { safeCallback } from '../utils/safeCallback';
11+
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
1212
import { getCombinedScopeData } from '../utils/scopeData';
1313
import { getActiveSpan } from '../utils/spanUtils';
1414
import { timestampInSeconds } from '../utils/time';
@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
144144
client.emit('beforeCaptureLog', processedLog);
145145

146146
const log = beforeSendLog
147-
? safeCallback(
147+
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
148148
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
149149
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
150150
() => consoleSandbox(() => beforeSendLog(processedLog)),
151-
() => null,
151+
() => CALLBACK_ERROR,
152152
)
153153
: processedLog;
154+
if (log === CALLBACK_ERROR) {
155+
client.recordDroppedEvent('callback_error', 'log_item', 1);
156+
return;
157+
}
154158
if (!log) {
155159
client.recordDroppedEvent('before_send', 'log_item', 1);
156160
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');

‎packages/core/src/metrics/internal.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
88
import type { Metric, SerializedMetric } from '../types/metric';
99
import type { User } from '../types/user';
1010
import { debug } from '../utils/debug-logger';
11-
import { safeCallback } from '../utils/safeCallback';
11+
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
1212
import { getCombinedScopeData } from '../utils/scopeData';
1313
import { getActiveSpan } from '../utils/spanUtils';
1414
import { timestampInSeconds } from '../utils/time';
@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
183183
client.emit('processMetric', enrichedMetric);
184184

185185
const processedMetric = beforeSendMetric
186-
? safeCallback(
186+
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
187187
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
188188
() => beforeSendMetric(enrichedMetric),
189-
() => null,
189+
() => CALLBACK_ERROR,
190190
)
191191
: enrichedMetric;
192192

193+
if (processedMetric === CALLBACK_ERROR) {
194+
client.recordDroppedEvent('callback_error', 'metric', 1);
195+
return;
196+
}
197+
193198
if (!processedMetric) {
194199
client.recordDroppedEvent('before_send', 'metric', 1);
195200
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
66
import { parseSampleRate } from '../utils/parseSampleRate';
77
import { safeCallback } from '../utils/safeCallback';
88

9+
interface SamplingDecision {
10+
sampled: boolean;
11+
sampleRate?: number;
12+
localSampleRateWasApplied?: boolean;
13+
/** Set when the span was dropped for a reason other than the sampling decision itself. */
14+
dropReason?: 'callback_error';
15+
}
16+
917
/**
1018
* Makes a sampling decision for the given options.
1119
*
@@ -16,15 +24,17 @@ export function sampleSpan(
1624
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
1725
samplingContext: SamplingContext,
1826
sampleRand: number,
19-
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
27+
): SamplingDecision {
2028
// nothing to do if span recording is not enabled
2129
if (!hasSpansEnabled(options)) {
22-
return [false];
30+
return { sampled: false };
2331
}
2432

2533
const resolved = resolveSampleRate(options, samplingContext);
2634
if (!resolved) {
27-
return [false];
35+
// `hasSpansEnabled` guarantees either `tracesSampleRate` or `tracesSampler` is set, so the only way to end up
36+
// without a sample rate is a throwing `tracesSampler` with nothing to fall back to.
37+
return { sampled: false, dropReason: 'callback_error' };
2838
}
2939
const [sampleRate, localSampleRateWasApplied] = resolved;
3040

@@ -39,7 +49,7 @@ export function sampleSpan(
3949
sampleRate,
4050
)} of type ${JSON.stringify(typeof sampleRate)}.`,
4151
);
42-
return [false];
52+
return { sampled: false };
4353
}
4454

4555
// if the function returned 0 (or false), or if `tracesSampleRate` is 0, it's a sign the transaction should be dropped
@@ -52,7 +62,7 @@ export function sampleSpan(
5262
: 'a negative sampling decision was inherited or tracesSampleRate is set to 0'
5363
}`,
5464
);
55-
return [false, parsedSampleRate, localSampleRateWasApplied];
65+
return { sampled: false, sampleRate: parsedSampleRate, localSampleRateWasApplied };
5666
}
5767

5868
// We always compare the sample rand for the current execution context against the chosen sample rate.
@@ -69,7 +79,7 @@ export function sampleSpan(
6979
);
7080
}
7181

72-
return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
82+
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
7383
}
7484

7585
/**

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -495,8 +495,8 @@ function _startRootSpan(
495495
const currentPropagationContext = scope.getPropagationContext();
496496
const _isTracingSuppressed = isTracingSuppressed(scope);
497497

498-
const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
499-
? [false]
498+
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
499+
? { sampled: false }
500500
: sampleSpan(
501501
options,
502502
{
@@ -522,7 +522,7 @@ function _startRootSpan(
522522

523523
if (!sampled && client && !_isTracingSuppressed) {
524524
DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.');
525-
client.recordDroppedEvent('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
525+
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
526526
}
527527

528528
setCapturedScopesOnSpan(rootSpan, scope, isolationScope);

‎packages/core/src/types/clientreport.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';
22

33
export type EventDropReason =
44
| 'before_send'
5+
| 'callback_error'
56
| 'event_processor'
67
| 'network_error'
78
| 'queue_overflow'

0 commit comments

Comments
 (0)