Skip to content

Commit bb9a102

Browse files
msonnbclaude
andcommitted
ref(core): Add safeCallback helper for isolating user-provided callbacks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9d0a6f2 commit bb9a102

5 files changed

Lines changed: 137 additions & 38 deletions

File tree

‎packages/core/src/shared-exports.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export {
9494
export { safeSetSpanJSONAttributes } from './tracing/spans/captureSpan';
9595
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
9696
export { handleCallbackErrors } from './utils/handleCallbackErrors';
97+
export { safeCallback } from './utils/safeCallback';
9798
export { parameterize, fmt } from './utils/parameterize';
9899
export type { HandleTunnelRequestOptions } from './utils/tunnel';
99100
export { handleTunnelRequest } from './utils/tunnel';

‎packages/core/src/tracing/spans/beforeSendSpan.ts‎

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { DEBUG_BUILD } from '../../debug-build';
21
import type { BeforeSendStaticSpanCallback, BeforeSendStreamedSpanCallback } from '../../types/options';
32
import type { SpanJSON, StreamedSpanJSON } from '../../types/span';
43
import { addNonEnumerableProperty } from '../../utils/object';
5-
import { consoleSandbox, debug } from '../../utils/debug-logger';
4+
import { consoleSandbox } from '../../utils/debug-logger';
5+
import { safeCallback } from '../../utils/safeCallback';
66

77
/**
88
* A wrapper to use the static, transaction-based span format in your `beforeSendSpan` callback.
@@ -64,25 +64,25 @@ export function applyBeforeSendSpanCallback<T extends StreamedSpanJSON | SpanJSO
6464
span: T,
6565
beforeSendSpan: (span: T) => T,
6666
): T {
67-
try {
68-
const modifedSpan = beforeSendSpan(span);
69-
if (!modifedSpan) {
70-
if (!hasShownSpanDropWarning) {
71-
consoleSandbox(() => {
72-
// eslint-disable-next-line no-console
73-
console.warn(
74-
'[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.',
75-
);
76-
});
77-
hasShownSpanDropWarning = true;
78-
}
79-
return span;
80-
}
81-
return modifedSpan;
82-
} catch (error) {
83-
// Spans are captured synchronously when they end, so a throwing callback would otherwise
84-
// propagate into whatever user code ended the span.
85-
DEBUG_BUILD && debug.error('The `beforeSendSpan` callback threw an error, sending the span unmodified:', error);
86-
return span;
67+
// Spans are captured synchronously when they end, so a throwing callback would otherwise
68+
// propagate into whatever user code ended the span.
69+
const modifiedSpan = safeCallback(
70+
'The `beforeSendSpan` callback threw an error, sending the span unmodified:',
71+
() => beforeSendSpan(span),
72+
() => span,
73+
);
74+
if (modifiedSpan) {
75+
return modifiedSpan;
8776
}
77+
78+
if (!hasShownSpanDropWarning) {
79+
consoleSandbox(() => {
80+
// eslint-disable-next-line no-console
81+
console.warn(
82+
'[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.',
83+
);
84+
});
85+
hasShownSpanDropWarning = true;
86+
}
87+
return span;
8888
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { DEBUG_BUILD } from '../debug-build';
2+
import { debug } from './debug-logger';
3+
import { isThenable } from './is';
4+
5+
/**
6+
* Invokes a user-provided callback (e.g. `beforeSend`, `tracesSampler`, an integration hook) so that
7+
* neither a synchronous throw nor a rejected promise escapes into the caller. On failure the error is
8+
* logged and `fallback(error)` supplies the result instead.
9+
*
10+
* Not for `startSpan` bodies: those must re-throw and are handled by `handleCallbackErrors`.
11+
*
12+
* @param message - Logged via `debug.error` together with the error, e.g. "The `beforeSend` callback threw an error, dropping the event:".
13+
* @param fn - Invokes the callback.
14+
* @param fallback - Produces the result to use when the callback throws or rejects.
15+
*/
16+
export function safeCallback<T>(message: string, fn: () => T, fallback: (error: unknown) => T): T {
17+
let result: T;
18+
try {
19+
result = fn();
20+
} catch (error) {
21+
return recover(message, error, fallback);
22+
}
23+
24+
if (isThenable(result)) {
25+
return result.then(undefined, (error: unknown) => recover(message, error, fallback)) as T;
26+
}
27+
28+
return result;
29+
}
30+
31+
function recover<T>(message: string, error: unknown, fallback: (error: unknown) => T): T {
32+
DEBUG_BUILD && debug.error(message, error);
33+
return fallback(error);
34+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { debug } from '../../../src/utils/debug-logger';
3+
import { safeCallback } from '../../../src/utils/safeCallback';
4+
5+
describe('safeCallback', () => {
6+
const debugErrorSpy = vi.spyOn(debug, 'error').mockImplementation(() => undefined);
7+
8+
afterEach(() => {
9+
debugErrorSpy.mockClear();
10+
});
11+
12+
it('returns the result of a sync callback', () => {
13+
const fallback = vi.fn(() => 'fallback');
14+
15+
expect(safeCallback('callback threw:', () => 'value', fallback)).toBe('value');
16+
expect(fallback).not.toHaveBeenCalled();
17+
expect(debugErrorSpy).not.toHaveBeenCalled();
18+
});
19+
20+
it('returns the fallback and logs when a sync callback throws', () => {
21+
const error = new Error('boom');
22+
const fallback = vi.fn(() => 'fallback');
23+
24+
expect(
25+
safeCallback(
26+
'callback threw:',
27+
() => {
28+
throw error;
29+
},
30+
fallback,
31+
),
32+
).toBe('fallback');
33+
expect(fallback).toHaveBeenCalledWith(error);
34+
expect(debugErrorSpy).toHaveBeenCalledWith('callback threw:', error);
35+
});
36+
37+
it('resolves to the result of an async callback', async () => {
38+
const fallback = vi.fn(async () => 'fallback');
39+
40+
const result = safeCallback('callback threw:', async () => 'value', fallback);
41+
42+
expect(result).toBeInstanceOf(Promise);
43+
await expect(result).resolves.toBe('value');
44+
expect(fallback).not.toHaveBeenCalled();
45+
expect(debugErrorSpy).not.toHaveBeenCalled();
46+
});
47+
48+
it('resolves to the fallback and logs when an async callback rejects', async () => {
49+
const error = new Error('boom');
50+
const fallback = vi.fn(async () => 'fallback');
51+
52+
const result = safeCallback('callback threw:', () => Promise.reject(error), fallback);
53+
54+
await expect(result).resolves.toBe('fallback');
55+
expect(fallback).toHaveBeenCalledWith(error);
56+
expect(debugErrorSpy).toHaveBeenCalledWith('callback threw:', error);
57+
});
58+
59+
it('does not treat non-thenable objects as promises', () => {
60+
const value = { then: 'not a function' };
61+
62+
expect(
63+
safeCallback(
64+
'callback threw:',
65+
() => value,
66+
() => ({ then: 'fallback' }),
67+
),
68+
).toBe(value);
69+
});
70+
});

‎packages/node/src/integrations/node-fetch/undici-instrumentation.ts‎

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
isTracingSuppressed,
3030
LRUMap,
3131
parseUrl,
32+
safeCallback,
3233
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
3334
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
3435
SPAN_STATUS_ERROR,
@@ -110,16 +111,6 @@ export function instrumentUndici(config: NodeFetchOptions = {}): void {
110111
subscribeToChannel('undici:request:error', message => onError(message as RequestErrorMessage));
111112
}
112113

113-
/** Replaces OTel's `safeExecuteInTheMiddle`: run `fn`, route any error to `onError`, and swallow it. */
114-
function safeExecute<T>(fn: () => T, onError: (error: unknown) => void): T | undefined {
115-
try {
116-
return fn();
117-
} catch (error) {
118-
onError(error);
119-
return undefined;
120-
}
121-
}
122-
123114
function subscribeToChannel(
124115
diagnosticChannel: string,
125116
onMessage: (message: unknown, name: string | symbol) => void,
@@ -177,9 +168,10 @@ function parseRequestHeaders(request: UndiciRequest): Map<string, string | strin
177168
function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage): void {
178169
const url = getAbsoluteUrl(request.origin, request.path);
179170

180-
const ignoredByCallback = safeExecute(
171+
const ignoredByCallback = safeCallback(
172+
'The `ignoreOutgoingRequests` callback threw an error, not ignoring the request:',
181173
() => !!config.ignoreOutgoingRequests?.(url),
182-
e => e && DEBUG_BUILD && debug.error('caught ignoreOutgoingRequests error: ', e),
174+
() => false,
183175
);
184176

185177
// Breadcrumbs & span-less trace propagation are additionally skipped when tracing is suppressed.
@@ -277,9 +269,10 @@ function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage)
277269
});
278270

279271
// Execute the request hook if defined
280-
safeExecute(
272+
safeCallback(
273+
'The `requestHook` callback threw an error:',
281274
() => config.requestHook?.(span, request),
282-
e => e && DEBUG_BUILD && debug.error('caught requestHook error: ', e),
275+
() => undefined,
283276
);
284277

285278
// Context propagation goes last so no hook can tamper the propagation headers.
@@ -345,9 +338,10 @@ function onResponseHeaders(config: NodeFetchOptions, { request, response }: Resp
345338
};
346339

347340
// Execute the response hook if defined
348-
safeExecute(
341+
safeCallback(
342+
'The `responseHook` callback threw an error:',
349343
() => config.responseHook?.(span, { request, response }),
350-
e => e && DEBUG_BUILD && debug.error('caught responseHook error: ', e),
344+
() => undefined,
351345
);
352346

353347
if (config.headersToSpanAttributes?.responseHeaders) {

0 commit comments

Comments
 (0)