Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when beforeSend throws',
{
category: 'error',
quantity: 1,
reason: 'before_send',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no extra error event when an event processor t
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand All@@ -32,7 +32,7 @@ test('records a client report and no extra error event when an async event proce
{
category: 'error',
quantity: 1,
reason: 'event_processor',
reason: 'callback_error',
},
],
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ test('records a client report and no error event when tracesSampler throws', asy
{
category: 'span',
quantity: 1,
reason: 'sample_rate',
reason: 'callback_error',
},
],
},
Expand Down
49 changes: 28 additions & 21 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { safeMathRandom } from './utils/randomSafeContext';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
Expand DownExpand Up@@ -1522,6 +1522,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
const dataCategory = getDataCategoryByType(event.type);

const recordDroppedEvent = (reason: EventDropReason): void => {
this.recordDroppedEvent(reason, dataCategory);
if (isTransaction) {
// the transaction itself counts as one span, plus all the child spans that are added
this.recordDroppedEvent(reason, 'span', 1 + (event.spans || []).length);
}
};

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
if (prepared === null) {
Expand All@@ -1539,13 +1547,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
})
.then(processedEvent => {
if (processedEvent === null) {
this.recordDroppedEvent('before_send', dataCategory);
if (isTransaction) {
const spans = event.spans || [];
// the transaction itself counts as one span, plus all the child spans that are added
const spanCount = 1 + spans.length;
this.recordDroppedEvent('before_send', 'span', spanCount);
}
recordDroppedEvent('before_send');
throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`);
}

Expand DownExpand Up@@ -1587,6 +1589,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return processedEvent;
})
.then(null, reason => {
if (reason === CALLBACK_ERROR) {
recordDroppedEvent('callback_error');
throw _makeDoNotSendEventError('A user callback threw an error, will not send event.');
}

if (_isDoNotSendEventError(reason) || _isInternalError(reason)) {
throw reason;
}
Expand DownExpand Up@@ -1702,17 +1709,13 @@ function _validateBeforeSendResult(
): PromiseLike<Event | null> | Event | null {
const invalidValueError = `${beforeSendLabel} must return \`null\` or a valid event.`;
if (isThenable(beforeSendResult)) {
return beforeSendResult.then(
event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
},
e => {
throw _makeInternalError(`${beforeSendLabel} rejected with ${e}`);
},
);
// A rejection can only be `CALLBACK_ERROR` here, as `safeCallback` already handled the user callback rejecting
return beforeSendResult.then(event => {
if (!isPlainObject(event) && event !== null) {
throw _makeInternalError(invalidValueError);
}
return event;
});
} else if (!isPlainObject(beforeSendResult) && beforeSendResult !== null) {
throw _makeInternalError(invalidValueError);
}
Expand DownExpand Up@@ -1743,7 +1746,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
() => beforeSend(errorEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}

Expand DownExpand Up@@ -1818,7 +1823,9 @@ function processBeforeSend(
return safeCallback(
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
() => null,
() => {
throw CALLBACK_ERROR;
},
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/eventProcessors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,11 +3,12 @@ import type { Event, EventHint } from './types/event';
import type { EventProcessor } from './types/eventprocessor';
import { debug } from './utils/debug-logger';
import { isThenable } from './utils/is';
import { safeCallback } from './utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from './utils/safeCallback';
import { rejectedSyncPromise, resolvedSyncPromise } from './utils/syncpromise';

/**
* Process an array of event processors, returning the processed event (or `null` if the event was dropped).
* Rejects with `CALLBACK_ERROR` if a processor throws.
*/
export function notifyEventProcessors(
processors: EventProcessor[],
Expand DownExpand Up@@ -40,7 +41,9 @@ function _notifyEventProcessors(
const result = safeCallback(
DEBUG_BUILD ? `${processorName} threw an error, dropping event:` : '',
() => processor({ ...event }, hint),
() => null,
() => {
throw CALLBACK_ERROR;

@Lms24Lms24Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: throwing here and in client.ts made me a bit suspicious because we have to be really careful to also catch our throws. Looks like there's a case in replay where we also call prepareEvent which doesn't try/catch the call. I think this only concerns event processor throws though. This leads to us trying to re-send the replay because we assume a network error. Ultimately, we stop recording.

I think we have two options how to avoid this:

  1. we try/catch in Replay so that this doesn't happen
  2. we don't throw at all. Instead, we could directly record an dropped event in notifyEventProcessors and distinguish there.

Tbqh: I haven't thought this through end-to-end, so maybe 1 is easier. I don't see us reusing prepareEvent much in the foreseeable future. But happy to let you make the call (also feel free to come up with something else).

},
);

DEBUG_BUILD && result === null && debug.log(`${processorName} dropped event`);
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/logs/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Log, SerializedLog } from '../types/log';
import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -144,13 +144,17 @@ export function _INTERNAL_captureLog(
client.emit('beforeCaptureLog', processedLog);

const log = beforeSendLog
? safeCallback(
? safeCallback<Log | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendLog` callback threw an error, dropping the log:' : '',
// We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog`
() => consoleSandbox(() => beforeSendLog(processedLog)),
() => null,
() => CALLBACK_ERROR,
)
: processedLog;
if (log === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'log_item', 1);
return;
}
if (!log) {
client.recordDroppedEvent('before_send', 'log_item', 1);
DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.');
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/metrics/internal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ import type { Integration } from '../types/integration';
import type { Metric, SerializedMetric } from '../types/metric';
import type { User } from '../types/user';
import { debug } from '../utils/debug-logger';
import { safeCallback } from '../utils/safeCallback';
import { CALLBACK_ERROR, safeCallback } from '../utils/safeCallback';
import { getCombinedScopeData } from '../utils/scopeData';
import { getActiveSpan } from '../utils/spanUtils';
import { timestampInSeconds } from '../utils/time';
Expand DownExpand Up@@ -183,13 +183,18 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
client.emit('processMetric', enrichedMetric);

const processedMetric = beforeSendMetric
? safeCallback(
? safeCallback<Metric | null | typeof CALLBACK_ERROR>(
DEBUG_BUILD ? 'The `beforeSendMetric` callback threw an error, dropping the metric:' : '',
() => beforeSendMetric(enrichedMetric),
() => null,
() => CALLBACK_ERROR,
)
: enrichedMetric;

if (processedMetric === CALLBACK_ERROR) {
client.recordDroppedEvent('callback_error', 'metric', 1);
return;
}

if (!processedMetric) {
client.recordDroppedEvent('before_send', 'metric', 1);
DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/tracing/sampling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,14 @@ import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';
import { safeCallback } from '../utils/safeCallback';

interface SamplingDecision {
sampled: boolean;
sampleRate?: number;
localSampleRateWasApplied?: boolean;
/** Set when the span was dropped for a reason other than the sampling decision itself. */
dropReason?: 'callback_error';
}

/**
* Makes a sampling decision for the given options.
*
Expand All@@ -16,15 +24,17 @@ export function sampleSpan(
options: Pick<CoreOptions, 'tracesSampleRate' | 'tracesSampler'>,
samplingContext: SamplingContext,
sampleRand: number,
): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] {
): SamplingDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: this is API breaking, since sampleSpan is exported. I agree that the object is easier to read though . IIRC, we used the named tuple here for bundle size minimization but this is less readable than the refactor.

Breaking here isn't a big deal though, so we can also do it if the tuple no longer works. But if we do, let's add a note in the migration guide.

// nothing to do if span recording is not enabled
if (!hasSpansEnabled(options)) {
return [false];
return { sampled: false };
}

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

Expand All@@ -39,7 +49,7 @@ export function sampleSpan(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return [false];
return { sampled: false };
}

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

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

return [shouldSample, parsedSampleRate, localSampleRateWasApplied];
return { sampled: shouldSample, sampleRate: parsedSampleRate, localSampleRateWasApplied };
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tracing/trace.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,8 +495,8 @@ function _startRootSpan(
const currentPropagationContext = scope.getPropagationContext();
const _isTracingSuppressed = isTracingSuppressed(scope);

const [sampled, sampleRate, localSampleRateWasApplied] = _isTracingSuppressed
? [false]
const { sampled, sampleRate, localSampleRateWasApplied, dropReason } = _isTracingSuppressed
? { sampled: false }
: sampleSpan(
options,
{
Expand All@@ -522,7 +522,7 @@ 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('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
client.recordDroppedEvent(dropReason || 'sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction');
}

setCapturedScopesOnSpan(rootSpan, scope, isolationScope);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/clientreport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { DataCategory } from './datacategory';

export type EventDropReason =
| 'before_send'
| 'callback_error'
| 'event_processor'
| 'network_error'
| 'queue_overflow'
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/safeCallback.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { debug } from './debug-logger';
import { isThenable } from './is';

/**
* Lets a `safeCallback` fallback signal "the callback failed" as opposed to "the callback returned `null`",
* so the call site can report the drop as `callback_error`. Return it from synchronous call sites; throw it
* to abort a promise chain.
*/
export const CALLBACK_ERROR = Symbol.for('SentryCallbackError');

/**
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2222,7 +2222,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand All@@ -2244,7 +2244,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith('Event processor "?" threw an error, dropping event:', exception);
});

Expand DownExpand Up@@ -2277,7 +2277,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('a rejecting event processor stops the processor chain', async () => {
Expand DownExpand Up@@ -2312,7 +2312,7 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('event_processor', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
});

test('client-level event processor that throws on all events does not capture a new event', () => {
Expand DownExpand Up@@ -2348,7 +2348,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(1);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
Expand All@@ -2373,7 +2373,7 @@ describe('Client', () => {
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'error');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'error');
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSend` callback threw an error, dropping the event:',
exception,
Expand DownExpand Up@@ -2416,8 +2416,8 @@ describe('Client', () => {

expect(TestClient.instance!.event).toBeUndefined();
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span', 3);
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'transaction');
expect(recordDroppedEventSpy).toHaveBeenCalledWith('callback_error', 'span', 3);
expect(debugErrorSpy).toHaveBeenCalledWith(
'The `beforeSendTransaction` callback threw an error, dropping the event:',
exception,
Expand Down
Loading
Loading