From c7facfcde6a4e8557f142562001cceb3848d1fad Mon Sep 17 00:00:00 2001 From: Abhilaksh Sharma Date: Tue, 4 Aug 2026 19:48:19 +0530 Subject: [PATCH] fix(server-utils): Don't capture AI client errors as unhandled at the instrumentation level The exported instrumentOpenAiClient, instrumentAnthropicAiClient and instrumentGoogleGenAIClient wrappers captured provider errors with mechanism.handled = false and then rethrew, so the SDK classified the error as an unhandled crash before the application's retry or fallback logic ran. A call that succeeded on retry still produced an unhandled event, and each retry produced another one. Applies the convention established for the channel-based OpenAI integration in #21877 to the manual client instrumentation, which is the only available path on the edge and serverless runtimes. Error span status and the original error identity are unchanged. Captures are kept where a provider reports an error as data on an otherwise successful call, since the caller never sees those as a thrown error. The AI integration suites no longer mask these events with .ignore('event'), so they fail if the capture returns. Co-Authored-By: Claude Opus 5 --- .../suites/tracing/anthropic/test.ts | 13 +- .../suites/tracing/google-genai/test.ts | 11 +- .../tracing/openai/openai-tool-calls/test.ts | 2 - .../suites/tracing/openai/test.ts | 16 --- .../suites/tracing/openai/v6/test.ts | 5 - .../server-utils/src/ai/anthropic-ai/index.ts | 44 ++---- packages/server-utils/src/ai/core/utils.ts | 17 +-- .../server-utils/src/ai/google-genai/index.ts | 14 +- packages/server-utils/src/ai/openai/index.ts | 32 +---- .../tracing/ai-client-error-capture.test.ts | 132 ++++++++++++++++++ 10 files changed, 158 insertions(+), 128 deletions(-) create mode 100644 packages/server-utils/test/ai/tracing/ai-client-error-capture.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index ebd66be60989..0f2d0de041b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -57,7 +57,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => { test('preserves .withResponse() and .asResponse() for non-streaming and streaming', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -304,7 +303,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument.mjs', (createRunner, test) => { test('streams produce spans with token usage and metadata (PII false)', async () => { await createRunner() - .ignore('event') .expect({ transaction: EXPECTED_STREAM_SPANS_PII_FALSE }) .expect({ span: container => { @@ -358,7 +356,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('streams record response text when PII true', async () => { await createRunner() - .ignore('event') .expect({ transaction: EXPECTED_STREAM_SPANS_PII_TRUE }) .expect({ span: container => { @@ -409,7 +406,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream-nested-create.mjs', 'instrument.mjs', (createRunner, test) => { test('traces a create() invoked from a stream event handler (dedup does not over-suppress)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -440,7 +436,6 @@ describe('Anthropic integration', () => { const EXPECTED_TOOL_CALLS_JSON = '[{"type":"tool_use","id":"tool_weather_1","name":"weather","input":{"city":"Paris"}}]'; await createRunner() - .ignore('event') .expect({ transaction: {}, }) @@ -470,7 +465,6 @@ describe('Anthropic integration', () => { const EXPECTED_TOOL_CALLS_JSON = '[{"type":"tool_use","id":"tool_weather_2","name":"weather","input":{"city":"Paris"}}]'; await createRunner() - .ignore('event') .expect({ transaction: {}, }) @@ -511,6 +505,8 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('handles streaming errors correctly', async () => { await createRunner() + // Anthropic surfaces stream errors as events on a resolved stream rather than by rejecting, + // so the instrumentation still reports them; the caller never sees them as a thrown error. .ignore('event') .expect({ transaction: EXPECTED_STREAM_ERROR_SPANS }) .expect({ @@ -564,7 +560,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('handles tool errors and model retrieval errors correctly', async () => { await createRunner() - .ignore('event') .expect({ transaction: EXPECTED_ERROR_SPANS }) .expect({ span: container => { @@ -599,7 +594,6 @@ describe('Anthropic integration', () => { (createRunner, test) => { test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -666,7 +660,6 @@ describe('Anthropic integration', () => { }, ]); await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -702,7 +695,6 @@ describe('Anthropic integration', () => { test('extracts system instructions from messages', async () => { const expectedInstructions = JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]); await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -743,7 +735,6 @@ describe('Anthropic integration', () => { ]); const expectedLongString = JSON.stringify([longStringInput]); await createRunner() - .ignore('event') .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) .expect({ span: container => { diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 9f61c8a127e4..4efd9be19d7d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -33,7 +33,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates google genai related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -87,7 +86,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates google genai related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -138,7 +136,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates google genai related spans with custom options', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -175,7 +172,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates google genai related spans with tool calls', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -236,6 +232,7 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument.mjs', (createRunner, test) => { test('creates google genai streaming spans with genAI recording disabled', async () => { await createRunner() + // Blocked content is reported from within the stream, not thrown to the caller. .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ @@ -292,6 +289,7 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates google genai streaming spans with genAI recording enabled', async () => { await createRunner() + // Blocked content is reported from within the stream, not thrown to the caller. .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ @@ -351,7 +349,6 @@ describe('Google GenAI integration', () => { (createRunner, test) => { test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -395,7 +392,6 @@ describe('Google GenAI integration', () => { (createRunner, test) => { test('extracts system instructions from messages', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -419,7 +415,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates google genai embeddings spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -458,7 +453,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates google genai embeddings spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -511,7 +505,6 @@ describe('Google GenAI integration', () => { (createRunner, test) => { test('does not truncate input messages when enableTruncation is false', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index 13b18c74909f..eeeff8da7b1c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -78,7 +78,6 @@ describe('OpenAI Tool Calls integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai tool calls related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -326,7 +325,6 @@ describe('OpenAI Tool Calls integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai tool calls related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 32ed89258908..4f44d44a999a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -34,7 +34,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -334,7 +333,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -682,7 +680,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates openai related spans with custom options', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -733,7 +730,6 @@ describe('OpenAI integration', () => { (createRunner, test) => { test('does not truncate input messages when enableTruncation is false', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -782,7 +778,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -915,7 +910,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1148,7 +1142,6 @@ describe('OpenAI integration', () => { (createRunner, test) => { test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1244,7 +1237,6 @@ describe('OpenAI integration', () => { (createRunner, test) => { test('truncates string inputs when they exceed byte limit', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1285,7 +1277,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-conversation.mjs', 'instrument.mjs', (createRunner, test) => { test('captures conversation ID from Conversations API and previous_response_id', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'conversation-test', @@ -1405,7 +1396,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-manual-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => { test('attaches manual conversation ID set via setConversationId() to all chat spans', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'chat-with-manual-conversation-id', @@ -1438,7 +1428,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-separate-scope-1.mjs', 'instrument.mjs', (createRunner, test) => { test('isolates conversation IDs across separate scopes - conversation 1', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'GET /chat/conversation-1', @@ -1470,7 +1459,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-separate-scope-2.mjs', 'instrument.mjs', (createRunner, test) => { test('isolates conversation IDs across separate scopes - conversation 2', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'GET /chat/conversation-2', @@ -1506,7 +1494,6 @@ describe('OpenAI integration', () => { (createRunner, test) => { test('extracts system instructions from messages', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1533,7 +1520,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => { test('preserves .withResponse() method and works correctly', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1564,7 +1550,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-vision.mjs', 'instrument-with-truncation.mjs', (createRunner, test) => { test('redacts inline base64 image data in vision requests', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1590,7 +1575,6 @@ describe('OpenAI integration', () => { test('preserves regular URLs in image_url (does not redact https links)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts index d57ec9387c70..b9cdbd8f00f8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -36,7 +36,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording disabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -346,7 +345,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording enabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -704,7 +702,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with custom options (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -767,7 +764,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording disabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -910,7 +906,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording enabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index d16d02cf8d4e..98efea097bf5 100644 --- a/packages/server-utils/src/ai/anthropic-ai/index.ts +++ b/packages/server-utils/src/ai/anthropic-ai/index.ts @@ -1,11 +1,5 @@ /* eslint-disable typescript-eslint/no-deprecated */ -import { - captureException, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_STATUS_ERROR, - startSpan, - startSpanManual, -} from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, startSpanManual } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_OPERATION_NAME, @@ -183,11 +177,7 @@ export function addResponseAttributes(span: Span, response: AnthropicAiResponse, /** * Handle common error catching and reporting for streaming requests */ -function handleStreamingError(error: unknown, span: Span, methodPath: string): never { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.anthropic', data: { function: methodPath } }, - }); - +function handleStreamingError(error: unknown, span: Span): never { if (span.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); span.end(); @@ -237,12 +227,12 @@ function handleStreamingRequest( options.recordOutputs ?? false, ) as unknown as R; } catch (error) { - return handleStreamingError(error, span, methodPath); + return handleStreamingError(error, span); } })(); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); } else { return startSpanManual(spanConfig, span => { try { @@ -257,7 +247,7 @@ function handleStreamingRequest( return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false); } catch (error) { suppressDelegatedCreate = false; - return handleStreamingError(error, span, methodPath); + return handleStreamingError(error, span); } }); } @@ -329,28 +319,14 @@ function instrumentMethod( addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); } - return originalResult.then( - result => { - addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs); - return result; - }, - error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic', - data: { - function: methodPath, - }, - }, - }); - throw error; - }, - ); + return originalResult.then(result => { + addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs); + return result; + }); }, ); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); }, }); } diff --git a/packages/server-utils/src/ai/core/utils.ts b/packages/server-utils/src/ai/core/utils.ts index 6ed04d1ab0f3..b58f8e0f62a2 100644 --- a/packages/server-utils/src/ai/core/utils.ts +++ b/packages/server-utils/src/ai/core/utils.ts @@ -2,7 +2,7 @@ /** * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) */ -import { captureException, getClient, isThenable } from '@sentry/core'; +import { getClient, isThenable } from '@sentry/core'; import type { Span } from '@sentry/core'; import { GEN_AI_RESPONSE_FINISH_REASONS, @@ -252,17 +252,11 @@ export function extractSystemInstructions(messages: unknown[] | unknown): { async function createWithResponseWrapper( originalWithResponse: Promise, instrumentedPromise: Promise, - mechanismType: string, ): Promise { - // Attach catch handler to originalWithResponse immediately to prevent unhandled rejection - // If instrumentedPromise rejects first, we still need this handled + // Attach a handler immediately so a rejection here is never an unhandled rejection: if + // `instrumentedPromise` rejects first, the await below is skipped and nothing else observes this one. + // The rejection is rethrown so a caller that does await it still sees the original error. const safeOriginalWithResponse = originalWithResponse.catch(error => { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); throw error; }); @@ -290,7 +284,6 @@ async function createWithResponseWrapper( export function wrapPromiseWithMethods( originalPromiseLike: Promise, instrumentedPromise: Promise, - mechanismType: string, ): Promise { // If the original result is not thenable, return the instrumented promise if (!isThenable(originalPromiseLike)) { @@ -314,7 +307,7 @@ export function wrapPromiseWithMethods( if (prop === 'withResponse' && typeof value === 'function') { return function wrappedWithResponse(this: unknown): unknown { const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target); - return createWithResponseWrapper(originalWithResponse, instrumentedPromise, mechanismType); + return createWithResponseWrapper(originalWithResponse, instrumentedPromise); }; } diff --git a/packages/server-utils/src/ai/google-genai/index.ts b/packages/server-utils/src/ai/google-genai/index.ts index 307bb25b708f..03e1cec4c696 100644 --- a/packages/server-utils/src/ai/google-genai/index.ts +++ b/packages/server-utils/src/ai/google-genai/index.ts @@ -1,7 +1,6 @@ /* eslint-disable typescript-eslint/no-deprecated */ /* eslint-disable max-lines */ import { - captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, @@ -307,13 +306,6 @@ function instrumentMethod( return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.google_genai', - data: { function: methodPath }, - }, - }); span.end(); throw error; } @@ -334,11 +326,7 @@ function instrumentMethod( return handleCallbackErrors( () => target.apply(context, args), - error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.google_genai', data: { function: methodPath } }, - }); - }, + () => {}, () => {}, result => { // Only add response attributes for content-producing methods, not for embeddings diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index d61e9c580861..2d76bee20a89 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -1,7 +1,6 @@ /* eslint-disable typescript-eslint/no-deprecated */ import { DEBUG_BUILD } from '../../debug-build'; import { - captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, @@ -182,20 +181,13 @@ function instrumentMethod( ) as unknown as R; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.openai.stream', - data: { function: methodPath }, - }, - }); span.end(); throw error; } })(); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); } // Non-streaming @@ -209,25 +201,13 @@ function instrumentMethod( addRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); } - return originalResult.then( - result => { - addResponseAttributes(span, result, options.recordOutputs); - return result; - }, - error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.openai', - data: { function: methodPath }, - }, - }); - throw error; - }, - ); + return originalResult.then(result => { + addResponseAttributes(span, result, options.recordOutputs); + return result; + }); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); }; } diff --git a/packages/server-utils/test/ai/tracing/ai-client-error-capture.test.ts b/packages/server-utils/test/ai/tracing/ai-client-error-capture.test.ts new file mode 100644 index 000000000000..2222ac1902ee --- /dev/null +++ b/packages/server-utils/test/ai/tracing/ai-client-error-capture.test.ts @@ -0,0 +1,132 @@ +import type * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@sentry/core', async importOriginal => { + const actual = (await importOriginal()) as typeof SentryCore; + return { ...actual, captureException: vi.fn() }; +}); + +import { captureException } from '@sentry/core'; +import { instrumentAnthropicAiClient } from '../../../src/ai/anthropic-ai'; +import { instrumentGoogleGenAIClient } from '../../../src/ai/google-genai'; +import { instrumentOpenAiClient } from '../../../src/ai/openai'; + +const upstreamError = new Error('Rate limit exceeded'); + +/** Rejects on the first call and resolves afterwards, like a transient provider failure. */ +function transientFailure(response: unknown): () => Promise { + let call = 0; + return () => (call++ === 0 ? Promise.reject(upstreamError) : Promise.resolve(response)); +} + +const openAiResponse = { + id: 'chatcmpl-1', + model: 'gpt-4o-mini', + object: 'chat.completion', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], +}; + +const anthropicResponse = { id: 'msg_1', model: 'claude-sonnet-4-5', type: 'message', content: [], usage: {} }; + +const googleResponse = { modelVersion: 'gemini-2.0-flash', candidates: [], usageMetadata: {} }; + +describe('AI client instrumentation error handling', () => { + beforeEach(() => { + vi.mocked(captureException).mockClear(); + }); + + describe.each([ + { + provider: 'openai', + instrument: () => { + const create = transientFailure(openAiResponse); + const client = instrumentOpenAiClient({ chat: { completions: { create } } }); + return () => client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] }); + }, + }, + { + provider: 'openai (streaming)', + instrument: () => { + const create = transientFailure(openAiResponse); + const client = instrumentOpenAiClient({ chat: { completions: { create } } }); + return () => client.chat.completions.create({ model: 'gpt-4o-mini', messages: [], stream: true }); + }, + }, + { + provider: 'anthropic', + instrument: () => { + const create = transientFailure(anthropicResponse); + const client = instrumentAnthropicAiClient({ messages: { create } }); + return () => client.messages.create({ model: 'claude-sonnet-4-5', messages: [] }); + }, + }, + { + provider: 'anthropic (streaming)', + instrument: () => { + const create = transientFailure(anthropicResponse); + const client = instrumentAnthropicAiClient({ messages: { create } }); + return () => client.messages.create({ model: 'claude-sonnet-4-5', messages: [], stream: true }); + }, + }, + { + provider: 'google-genai', + instrument: () => { + const generateContent = transientFailure(googleResponse); + const client = instrumentGoogleGenAIClient({ models: { generateContent } }); + return () => client.models.generateContent({ model: 'gemini-2.0-flash', contents: 'hi' }); + }, + }, + { + provider: 'google-genai (streaming)', + instrument: () => { + const generateContentStream = transientFailure(googleResponse); + const client = instrumentGoogleGenAIClient({ models: { generateContentStream } }); + return () => client.models.generateContentStream({ model: 'gemini-2.0-flash', contents: 'hi' }); + }, + }, + ])('$provider', ({ instrument }) => { + it('rethrows the original error without capturing it', async () => { + const call = instrument(); + + await expect(call()).rejects.toBe(upstreamError); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('reports nothing when a retry recovers the call', async () => { + const call = instrument(); + + await expect(call()).rejects.toBe(upstreamError); + await expect(call()).resolves.toBeDefined(); + + expect(captureException).not.toHaveBeenCalled(); + }); + + it('reports nothing per attempt when every retry fails', async () => { + // `transientFailure` only rejects its first call, so re-instrument per attempt to model + // a caller that retries the same failing request three times. + for (let attempt = 0; attempt < 3; attempt++) { + await expect(instrument()()).rejects.toBe(upstreamError); + } + + expect(captureException).not.toHaveBeenCalled(); + }); + }); + + it('does not capture when a rejection is observed through .withResponse()', async () => { + const create = (): Promise => { + const promise = Promise.reject(upstreamError) as Promise & { withResponse: () => Promise }; + promise.withResponse = () => Promise.reject(upstreamError); + return promise; + }; + const client = instrumentOpenAiClient({ chat: { completions: { create } } }); + + const result = client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] }) as Promise & { + withResponse: () => Promise; + }; + + await expect(result).rejects.toBe(upstreamError); + await expect(result.withResponse()).rejects.toBe(upstreamError); + + expect(captureException).not.toHaveBeenCalled(); + }); +});