Skip to content

Commit 7569a3f

Browse files
committed
feat(core): Allow to pass onSuccess to handleCallbackErrors
I've seen a few places where we are wrapping things that could be sync or async, and we want to do something with the return value. By adding an onSuccess handler to `handelCallbackErrors` we can handle this more generically in the future.
1 parent 6e8e561 commit 7569a3f

File tree

5 files changed

+146
-43
lines changed

5 files changed

+146
-43
lines changed

packages/core/src/utils/anthropic-ai/index.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
GEN_AI_SYSTEM_ATTRIBUTE,
2525
} from '../ai/gen-ai-attributes';
2626
import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils';
27+
import { handleCallbackErrors } from '../handleCallbackErrors';
2728
import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants';
2829
import { instrumentStream } from './streaming';
2930
import type {
@@ -238,7 +239,7 @@ function instrumentMethod<T extends unknown[], R>(
238239
op: getSpanOperation(methodPath),
239240
attributes: requestAttributes as Record<string, SpanAttributeValue>,
240241
},
241-
async (span: Span) => {
242+
async span => {
242243
try {
243244
if (finalOptions.recordInputs && params) {
244245
addPrivateRequestAttributes(span, params);
@@ -274,27 +275,27 @@ function instrumentMethod<T extends unknown[], R>(
274275
op: getSpanOperation(methodPath),
275276
attributes: requestAttributes as Record<string, SpanAttributeValue>,
276277
},
277-
async (span: Span) => {
278-
try {
279-
if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') {
280-
addPrivateRequestAttributes(span, args[0] as Record<string, unknown>);
281-
}
278+
span => {
279+
if (finalOptions.recordInputs && params) {
280+
addPrivateRequestAttributes(span, params);
281+
}
282282

283-
const result = await originalMethod.apply(context, args);
284-
addResponseAttributes(span, result, finalOptions.recordOutputs);
285-
return result;
286-
} catch (error) {
287-
captureException(error, {
288-
mechanism: {
289-
handled: false,
290-
type: 'auto.ai.anthropic',
291-
data: {
292-
function: methodPath,
283+
return handleCallbackErrors(
284+
() => originalMethod.apply(context, args),
285+
error => {
286+
captureException(error, {
287+
mechanism: {
288+
handled: false,
289+
type: 'auto.ai.anthropic',
290+
data: {
291+
function: methodPath,
292+
},
293293
},
294-
},
295-
});
296-
throw error;
297-
}
294+
});
295+
},
296+
() => {},
297+
result => addResponseAttributes(span, result as AnthropicAiResponse, finalOptions.recordOutputs),
298+
);
298299
},
299300
);
300301
};

packages/core/src/utils/handleCallbackErrors.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import { isThenable } from '../utils/is';
22

3+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4+
export function handleCallbackErrors<Fn extends () => Promise<any>, PromiseValue = Awaited<ReturnType<Fn>>>(
5+
fn: Fn,
6+
onError: (error: unknown) => void,
7+
onFinally?: () => void,
8+
onSuccess?: (result: PromiseValue) => void,
9+
): ReturnType<Fn>;
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
export function handleCallbackErrors<Fn extends () => any>(
12+
fn: Fn,
13+
onError: (error: unknown) => void,
14+
onFinally?: () => void,
15+
onSuccess?: (result: ReturnType<Fn>) => void,
16+
): ReturnType<Fn>;
317
/**
418
* Wrap a callback function with error handling.
519
* If an error is thrown, it will be passed to the `onError` callback and re-thrown.
@@ -14,7 +28,13 @@ import { isThenable } from '../utils/is';
1428
export function handleCallbackErrors<
1529
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1630
Fn extends () => any,
17-
>(fn: Fn, onError: (error: unknown) => void, onFinally: () => void = () => {}): ReturnType<Fn> {
31+
ValueType = ReturnType<Fn>,
32+
>(
33+
fn: Fn,
34+
onError: (error: unknown) => void,
35+
onFinally: () => void = () => {},
36+
onSuccess: (result: ValueType | Awaited<ValueType>) => void = () => {},
37+
): ValueType {
1838
let maybePromiseResult: ReturnType<Fn>;
1939
try {
2040
maybePromiseResult = fn();
@@ -24,7 +44,7 @@ export function handleCallbackErrors<
2444
throw e;
2545
}
2646

27-
return maybeHandlePromiseRejection(maybePromiseResult, onError, onFinally);
47+
return maybeHandlePromiseRejection(maybePromiseResult, onError, onFinally, onSuccess);
2848
}
2949

3050
/**
@@ -37,12 +57,14 @@ function maybeHandlePromiseRejection<MaybePromise>(
3757
value: MaybePromise,
3858
onError: (error: unknown) => void,
3959
onFinally: () => void,
60+
onSuccess: (result: MaybePromise | Awaited<MaybePromise>) => void,
4061
): MaybePromise {
4162
if (isThenable(value)) {
4263
// @ts-expect-error - the isThenable check returns the "wrong" type here
4364
return value.then(
4465
res => {
4566
onFinally();
67+
onSuccess(res);
4668
return res;
4769
},
4870
e => {
@@ -54,5 +76,6 @@ function maybeHandlePromiseRejection<MaybePromise>(
5476
}
5577

5678
onFinally();
79+
onSuccess(value);
5780
return value;
5881
}

packages/core/test/lib/utils/handleCallbackErrors.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,94 @@ describe('handleCallbackErrors', () => {
148148
expect(onFinally).toHaveBeenCalledTimes(1);
149149
});
150150
});
151+
152+
describe('onSuccess', () => {
153+
it('triggers after successful sync callback', () => {
154+
const onError = vi.fn();
155+
const onFinally = vi.fn();
156+
const onSuccess = vi.fn();
157+
158+
const fn = vi.fn(() => 'aa');
159+
160+
const res = handleCallbackErrors(fn, onError, onFinally, onSuccess);
161+
162+
expect(res).toBe('aa');
163+
expect(fn).toHaveBeenCalledTimes(1);
164+
expect(onError).not.toHaveBeenCalled();
165+
expect(onFinally).toHaveBeenCalledTimes(1);
166+
expect(onSuccess).toHaveBeenCalledTimes(1);
167+
expect(onSuccess).toHaveBeenCalledWith('aa');
168+
});
169+
170+
it('does not trigger onSuccess after error in sync callback', () => {
171+
const error = new Error('test error');
172+
173+
const onError = vi.fn();
174+
const onFinally = vi.fn();
175+
const onSuccess = vi.fn();
176+
177+
const fn = vi.fn(() => {
178+
throw error;
179+
});
180+
181+
expect(() => handleCallbackErrors(fn, onError, onFinally, onSuccess)).toThrow(error);
182+
183+
expect(fn).toHaveBeenCalledTimes(1);
184+
expect(onError).toHaveBeenCalledTimes(1);
185+
expect(onError).toHaveBeenCalledWith(error);
186+
expect(onFinally).toHaveBeenCalledTimes(1);
187+
expect(onSuccess).not.toHaveBeenCalled();
188+
});
189+
190+
it('triggers after successful async callback', async () => {
191+
const onError = vi.fn();
192+
const onFinally = vi.fn();
193+
const onSuccess = vi.fn();
194+
195+
const fn = vi.fn(async () => 'aa');
196+
197+
const res = handleCallbackErrors(fn, onError, onFinally, onSuccess);
198+
199+
expect(res).toBeInstanceOf(Promise);
200+
201+
expect(fn).toHaveBeenCalledTimes(1);
202+
expect(onError).not.toHaveBeenCalled();
203+
expect(onFinally).not.toHaveBeenCalled();
204+
205+
const value = await res;
206+
expect(value).toBe('aa');
207+
208+
expect(onFinally).toHaveBeenCalledTimes(1);
209+
expect(onSuccess).toHaveBeenCalled();
210+
expect(onSuccess).toHaveBeenCalledWith('aa');
211+
});
212+
213+
it('does not trigger onSuccess after error in async callback', async () => {
214+
const onError = vi.fn();
215+
const onFinally = vi.fn();
216+
const onSuccess = vi.fn();
217+
218+
const error = new Error('test error');
219+
220+
const fn = vi.fn(async () => {
221+
await new Promise(resolve => setTimeout(resolve, 10));
222+
throw error;
223+
});
224+
225+
const res = handleCallbackErrors(fn, onError, onFinally, onSuccess);
226+
227+
expect(res).toBeInstanceOf(Promise);
228+
229+
expect(fn).toHaveBeenCalledTimes(1);
230+
expect(onError).not.toHaveBeenCalled();
231+
expect(onFinally).not.toHaveBeenCalled();
232+
233+
await expect(res).rejects.toThrow(error);
234+
235+
expect(onError).toHaveBeenCalledTimes(1);
236+
expect(onError).toHaveBeenCalledWith(error);
237+
expect(onFinally).toHaveBeenCalledTimes(1);
238+
expect(onSuccess).not.toHaveBeenCalled();
239+
});
240+
});
151241
});

packages/node/src/integrations/tracing/vercelai/instrumentation.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
getActiveSpan,
1010
getCurrentScope,
1111
handleCallbackErrors,
12-
isThenable,
1312
SDK_VERSION,
1413
withScope,
1514
} from '@sentry/core';
@@ -212,7 +211,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
212211
this._callbacks.forEach(callback => callback());
213212
this._callbacks = [];
214213

215-
function generatePatch(originalMethod: (...args: MethodArgs) => unknown) {
214+
const generatePatch = (originalMethod: (...args: MethodArgs) => unknown) => {
216215
return (...args: MethodArgs) => {
217216
const existingExperimentalTelemetry = args[0].experimental_telemetry || {};
218217
const isEnabled = existingExperimentalTelemetry.isEnabled;
@@ -237,20 +236,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
237236
};
238237

239238
return handleCallbackErrors(
240-
() => {
241-
// @ts-expect-error we know that the method exists
242-
const result = originalMethod.apply(this, args);
243-
244-
if (isThenable(result)) {
245-
// check for tool errors when the promise resolves, keep the original promise identity
246-
result.then(checkResultForToolErrors, () => {});
247-
return result;
248-
}
249-
250-
// check for tool errors when the result is synchronous
251-
checkResultForToolErrors(result);
252-
return result;
253-
},
239+
() => originalMethod.apply(this, args),
254240
error => {
255241
// This error bubbles up to unhandledrejection handler (if not handled before),
256242
// where we do not know the active span anymore
@@ -260,9 +246,13 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
260246
addNonEnumerableProperty(error, '_sentry_active_span', getActiveSpan());
261247
}
262248
},
249+
() => {},
250+
result => {
251+
checkResultForToolErrors(result);
252+
},
263253
);
264254
};
265-
}
255+
};
266256

267257
// Is this an ESM module?
268258
// https://tc39.es/ecma262/#sec-module-namespace-objects

packages/remix/src/server/errors.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,10 @@ export function errorHandleDocumentRequestFunction(
101101
const { request, responseStatusCode, responseHeaders, context, loadContext } = requestContext;
102102

103103
return handleCallbackErrors(
104-
() => {
105-
return origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context, loadContext);
106-
},
104+
() => origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context, loadContext),
107105
err => {
108-
throw err;
106+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
107+
captureRemixServerException(err, 'HandleDocumentRequestFunction', request);
109108
},
110109
);
111110
}

0 commit comments

Comments
 (0)