Skip to content

Commit 449b642

Browse files
msonnbclaude
andauthored
feat(core): Isolate throwing user callbacks instead of capturing them as events (#23770)
Wraps `beforeSend`, `beforeSendTransaction`, event processors, `tracesSampler`, `beforeBreadcrumb`, `beforeSendLog` and `beforeSendMetric` in the `safeCallback` helper from #23760. A throwing or rejecting callback no longer escapes into the calling code and is no longer captured as an `internal` error event; the event/breadcrumb/log/metric is dropped, a client report is recorded where a category exists, and the error is logged in debug mode. part of #23755 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6011362 commit 449b642

19 files changed

Lines changed: 624 additions & 109 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
transport: loggingTransport,
7+
beforeSend() {
8+
throw new Error('beforeSend failed');
9+
},
10+
});
11+
12+
Sentry.captureException(new Error('this should get dropped because beforeSend throws'));
13+
14+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
15+
Sentry.flush();
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { afterAll, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('records a client report and no extra error event when beforeSend throws', async () => {
9+
await createRunner(__dirname, 'scenario.ts')
10+
.unignore('client_report')
11+
.expect({
12+
client_report: {
13+
discarded_events: [
14+
{
15+
category: 'error',
16+
quantity: 1,
17+
reason: 'before_send',
18+
},
19+
],
20+
},
21+
})
22+
.start()
23+
.completed();
24+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
traceLifecycle: 'static',
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
transport: loggingTransport,
8+
});
9+
10+
Sentry.addEventProcessor(async () => {
11+
throw new Error('async event processor failed');
12+
});
13+
14+
Sentry.captureException(new Error('this should get dropped because the async event processor rejects'));
15+
16+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
17+
Sentry.flush();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
transport: loggingTransport,
7+
});
8+
9+
Sentry.addEventProcessor(() => {
10+
throw new Error('event processor failed');
11+
});
12+
13+
Sentry.captureException(new Error('this should get dropped because the event processor throws'));
14+
15+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
16+
Sentry.flush();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { afterAll, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('records a client report and no extra error event when an event processor throws', async () => {
9+
await createRunner(__dirname, 'scenario.ts')
10+
.unignore('client_report')
11+
.expect({
12+
client_report: {
13+
discarded_events: [
14+
{
15+
category: 'error',
16+
quantity: 1,
17+
reason: 'event_processor',
18+
},
19+
],
20+
},
21+
})
22+
.start()
23+
.completed();
24+
});
25+
26+
test('records a client report and no extra error event when an async event processor rejects', async () => {
27+
await createRunner(__dirname, 'scenario-async.ts')
28+
.unignore('client_report')
29+
.expect({
30+
client_report: {
31+
discarded_events: [
32+
{
33+
category: 'error',
34+
quantity: 1,
35+
reason: 'event_processor',
36+
},
37+
],
38+
},
39+
})
40+
.start()
41+
.completed();
42+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
traceLifecycle: 'static',
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
transport: loggingTransport,
8+
tracesSampleRate: 1,
9+
tracesSampler: () => {
10+
throw new Error('tracesSampler failed');
11+
},
12+
});
13+
14+
Sentry.startSpan({ name: 'sampled via tracesSampleRate fallback' }, () => {
15+
// no-op
16+
});
17+
18+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
19+
Sentry.flush();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
transport: loggingTransport,
7+
tracesSampler: () => {
8+
throw new Error('tracesSampler failed');
9+
},
10+
});
11+
12+
Sentry.startSpan({ name: 'this should not be sampled because tracesSampler throws' }, () => {
13+
// no-op
14+
});
15+
16+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
17+
Sentry.flush();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { afterAll, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('records a client report and no error event when tracesSampler throws', async () => {
9+
await createRunner(__dirname, 'scenario.ts')
10+
.unignore('client_report')
11+
.expect({
12+
client_report: {
13+
discarded_events: [
14+
{
15+
category: 'span',
16+
quantity: 1,
17+
reason: 'sample_rate',
18+
},
19+
],
20+
},
21+
})
22+
.start()
23+
.completed();
24+
});
25+
26+
test('sends the span when tracesSampler throws but tracesSampleRate is 1', async () => {
27+
await createRunner(__dirname, 'scenario-fallback.ts')
28+
.expect({
29+
transaction: {
30+
transaction: 'sampled via tracesSampleRate fallback',
31+
},
32+
})
33+
.start()
34+
.completed();
35+
});

packages/core/src/breadcrumbs.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { getClient, getIsolationScope } from './currentScopes';
2+
import { DEBUG_BUILD } from './debug-build';
23
import type { Breadcrumb, BreadcrumbHint } from './types/breadcrumb';
34
import { consoleSandbox } from './utils/debug-logger';
5+
import { safeCallback } from './utils/safeCallback';
46
import { dateTimestampInSeconds } from './utils/time';
57

68
/**
@@ -28,7 +30,11 @@ export function addBreadcrumb(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): vo
2830
const timestamp = dateTimestampInSeconds();
2931
const mergedBreadcrumb = { timestamp, ...breadcrumb };
3032
const finalBreadcrumb = beforeBreadcrumb
31-
? consoleSandbox(() => beforeBreadcrumb(mergedBreadcrumb, hint))
33+
? safeCallback(
34+
DEBUG_BUILD ? 'The `beforeBreadcrumb` callback threw an error, dropping the breadcrumb:' : '',
35+
() => consoleSandbox(() => beforeBreadcrumb(mergedBreadcrumb, hint)),
36+
() => null,
37+
)
3238
: mergedBreadcrumb;
3339

3440
if (finalBreadcrumb === null) return;

packages/core/src/client.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { parseSampleRate } from './utils/parseSampleRate';
5050
import { prepareEvent } from './utils/prepareEvent';
5151
import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
5252
import { safeMathRandom } from './utils/randomSafeContext';
53+
import { safeCallback } from './utils/safeCallback';
5354
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
5455
import { safeUnref } from './utils/timer';
5556
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
@@ -1738,7 +1739,12 @@ function processBeforeSend(
17381739
let processedEvent = event;
17391740

17401741
if (isErrorEvent(processedEvent) && beforeSend) {
1741-
return beforeSend(processedEvent, hint);
1742+
const errorEvent = processedEvent;
1743+
return safeCallback(
1744+
DEBUG_BUILD ? 'The `beforeSend` callback threw an error, dropping the event:' : '',
1745+
() => beforeSend(errorEvent, hint),
1746+
() => null,
1747+
);
17421748
}
17431749

17441750
if (isTransactionEvent(processedEvent)) {
@@ -1809,7 +1815,11 @@ function processBeforeSend(
18091815
spanCountBeforeProcessing: spanCountBefore,
18101816
};
18111817
}
1812-
return beforeSendTransaction(processedEvent as TransactionEvent, hint);
1818+
return safeCallback(
1819+
DEBUG_BUILD ? 'The `beforeSendTransaction` callback threw an error, dropping the event:' : '',
1820+
() => beforeSendTransaction(processedEvent as TransactionEvent, hint),
1821+
() => null,
1822+
);
18131823
}
18141824
}
18151825

0 commit comments

Comments
 (0)