From f3e6532d265723274722bbb0befe24af073d13be Mon Sep 17 00:00:00 2001 From: Abhilaksh Sharma Date: Tue, 4 Aug 2026 19:48:19 +0530 Subject: [PATCH 1/5] 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. The anthropic suite asserted this divergence directly, expecting the model-error event only while orchestrion was disabled. Both paths agree now, so that branch and its expectation are gone. 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. Dropping the capture also left the .catch() in createWithResponseWrapper rethrowing into a promise nothing observes, which is not a handler at all: awaiting the two promises in sequence orphans the second whenever the first rejects. They are awaited together now. Co-Authored-By: Claude Opus 5 --- .../suites/tracing/anthropic/test.ts | 53 +------ .../suites/tracing/google-genai/test.ts | 10 +- .../tracing/openai/openai-tool-calls/test.ts | 2 - .../suites/tracing/openai/test.ts | 12 -- .../suites/tracing/openai/v6/test.ts | 5 - .../server-utils/src/ai/anthropic-ai/index.ts | 44 ++---- packages/server-utils/src/ai/core/utils.ts | 24 +--- .../server-utils/src/ai/google-genai/index.ts | 16 +-- packages/server-utils/src/ai/openai/index.ts | 32 +---- .../test/ai/lib/tracing/ai/utils.test.ts | 10 +- .../tracing/ai-client-error-capture.test.ts | 132 ++++++++++++++++++ 11 files changed, 169 insertions(+), 171 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 573194961082..88e3d049909f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -19,7 +19,6 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; -import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('Anthropic integration', () => { @@ -27,17 +26,6 @@ describe('Anthropic integration', () => { cleanupChildProcesses(); }); - const EXPECTED_MODEL_ERROR = { - exception: { - values: [ - { - type: 'Error', - value: '404 Model not found', - }, - ], - }, - }; - const EXPECTED_STREAM_EVENT_HANDLER_MESSAGE = { message: 'stream event from user-added event listener captured', }; @@ -45,7 +33,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', @@ -81,15 +68,7 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans with genAI recording disabled', async () => { - const runner = createRunner(); - - // The orchestrion path only marks the errored span; unlike the OTel path it does not - // capture the handled `error-model` rejection as an event. - if (!isOrchestrionEnabled()) { - runner.expect({ event: EXPECTED_MODEL_ERROR }); - } - - await runner + await createRunner() .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -122,15 +101,7 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates anthropic related spans with genAI recording enabled', async () => { - const runner = createRunner(); - - // The orchestrion path only marks the errored span; unlike the OTel path it does not - // capture the handled `error-model` rejection as an event. - if (!isOrchestrionEnabled()) { - runner.expect({ event: EXPECTED_MODEL_ERROR }); - } - - await runner + await createRunner() .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -192,15 +163,7 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates anthropic related spans with custom options', async () => { - const runner = createRunner(); - - // The orchestrion path only marks the errored span; unlike the OTel path it does not - // capture the handled `error-model` rejection as an event. - if (!isOrchestrionEnabled()) { - runner.expect({ event: EXPECTED_MODEL_ERROR }); - } - - await runner + await createRunner() .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -246,7 +209,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 => { @@ -300,7 +262,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 => { @@ -351,7 +312,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 => { @@ -382,7 +342,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: {}, }) @@ -412,7 +371,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: {}, }) @@ -453,6 +411,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({ @@ -501,7 +461,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('handles tool errors correctly', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -531,7 +490,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', @@ -555,7 +513,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates anthropic related spans with span streaming enabled', async () => { await createRunner() - .ignore('event') .expect({ span: container => { const completionSpan = container.items.find( 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 de405368d40c..21364a25a149 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 @@ -32,7 +32,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 => { @@ -86,7 +85,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 => { @@ -137,7 +135,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 => { @@ -174,7 +171,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 => { @@ -233,6 +229,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({ @@ -289,6 +286,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({ @@ -348,7 +346,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 => { @@ -372,7 +369,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 => { @@ -411,7 +407,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 => { @@ -458,7 +453,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates google genai related spans with span streaming enabled', async () => { await createRunner() - .ignore('event') .expect({ span: container => { const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash'); 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 b14d5a56901f..0f6be4aea592 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 13c53a7d164f..4b166d7a38d2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -33,7 +33,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 => { @@ -333,7 +332,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 => { @@ -681,7 +679,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 => { @@ -726,7 +723,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', @@ -859,7 +855,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', @@ -1089,7 +1084,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', @@ -1209,7 +1203,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', @@ -1242,7 +1235,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', @@ -1274,7 +1266,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', @@ -1310,7 +1301,6 @@ describe('OpenAI integration', () => { (createRunner, test) => { test('extracts system instructions from messages', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1337,7 +1327,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', @@ -1368,7 +1357,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates openai related spans with span streaming enabled', async () => { await createRunner() - .ignore('event') .expect({ span: container => { const chatCompletionSpan = container.items.find( 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 3362a48159b8..091867486e4e 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 e151c6cd2369..d557c7bf359c 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, @@ -166,11 +160,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(); @@ -220,12 +210,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 { @@ -240,7 +230,7 @@ function handleStreamingRequest( return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false); } catch (error) { suppressDelegatedCreate = false; - return handleStreamingError(error, span, methodPath); + return handleStreamingError(error, span); } }); } @@ -312,28 +302,14 @@ function instrumentMethod( addPrivateRequestAttributes(span, params); } - 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 f4cadbc35ab1..6fd5ec36e34c 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, @@ -238,22 +238,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 - const safeOriginalWithResponse = originalWithResponse.catch(error => { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - }); - - const instrumentedResult = await instrumentedPromise; - const originalWrapper = await safeOriginalWithResponse; + // Awaited together rather than in sequence so both promises get a handler attached synchronously. + // Awaiting them one after the other leaves the second unobserved when the first rejects, which + // surfaces as an unhandled rejection. + const [instrumentedResult, originalWrapper] = await Promise.all([instrumentedPromise, originalWithResponse]); // Combine instrumented result with original metadata if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) { @@ -276,7 +265,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)) { @@ -300,7 +288,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 c7ec34b5b70d..7342d3a043bf 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, @@ -289,13 +288,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; } @@ -314,13 +306,11 @@ function instrumentMethod( addPrivateRequestAttributes(span, params, operationName); } + // `onError` is a no-op because the rejection is rethrown to the caller and `startSpan` already + // marks the span errored; both leading callbacks are positional and only exist to reach `onSuccess`. 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 ded2f28d755d..83d9aceb9541 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, @@ -172,20 +171,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 @@ -199,25 +191,13 @@ function instrumentMethod( addRequestAttributes(span, params, operationName); } - 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/lib/tracing/ai/utils.test.ts b/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts index 05fd5dd59c59..5659fc23d417 100644 --- a/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts @@ -82,7 +82,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.resolve('instrumented-data'); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); const result = await wrapped; expect(result).toBe('instrumented-data'); @@ -94,7 +94,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.resolve('instrumented-data'); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); const withResponseResult = await (wrapped as typeof original).withResponse(); expect(withResponseResult).toEqual({ @@ -111,7 +111,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.resolve('instrumented-data'); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); const response = await (wrapped as typeof original).asResponse(); expect(response).toBe(mockResponse); @@ -120,7 +120,7 @@ describe('wrapPromiseWithMethods', () => { it('returns instrumentedPromise when original is not thenable', async () => { const instrumented = Promise.resolve('instrumented-data'); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const wrapped = wrapPromiseWithMethods(null as any, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(null as any, instrumented); const result = await wrapped; expect(result).toBe('instrumented-data'); @@ -132,7 +132,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.reject(new Error('instrumented-error')); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); await expect(wrapped).rejects.toThrow('instrumented-error'); }); 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..31572676d70b --- /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): (...args: 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 = (..._args: unknown[]): 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(); + }); +}); From 28d6e59237a021a296f8c0b4574c5b871dbb0913 Mon Sep 17 00:00:00 2001 From: Abhilaksh Sharma Date: Thu, 13 Aug 2026 18:36:06 +0530 Subject: [PATCH 2/5] ref(server-utils): Keep `@sentry/core` import multiline in anthropic-ai Removing `captureException` put the import at 111 chars, so oxfmt collapsed it to a single line. Pin the previous layout with `oxfmt-ignore` per review feedback. Co-Authored-By: Claude Opus 5 --- packages/server-utils/src/ai/anthropic-ai/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index d557c7bf359c..5cb4b49f01a0 100644 --- a/packages/server-utils/src/ai/anthropic-ai/index.ts +++ b/packages/server-utils/src/ai/anthropic-ai/index.ts @@ -1,5 +1,11 @@ /* eslint-disable typescript-eslint/no-deprecated */ -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, startSpanManual } from '@sentry/core'; +// oxfmt-ignore +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, From 3fb2551a1551ddbe378110d82aaabe08e00dbbc1 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 25 Aug 2026 15:37:01 +0200 Subject: [PATCH 3/5] remove oxfmt-ignore --- packages/server-utils/src/ai/anthropic-ai/index.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index 04e5270c14cf..30802331fdb2 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 */ -// oxfmt-ignore -import { - 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, From cdaa9e12d438c38414ac031f97e1bc18661edbdd Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 25 Aug 2026 16:29:18 +0200 Subject: [PATCH 4/5] fix(server-utils): Don't capture caller-facing Anthropic stream errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic stream errors — connection drops and `error`-type stream events — are surfaced to the caller (the async iterator rejects, or their `error` listener fires), so recording them as `handled: false` at the instrumentation level makes the same premature "unhandled crash" decision that was fixed for the client wrappers. Mark the span failed and leave the error for the caller. Also assert the genuine error-as-data captures instead of masking them with `.ignore('event')`: Google GenAI blocked content and Anthropic error-shaped responses are returned as data on a resolved value and never thrown, so the instrumentation intentionally captures them. Those cases now have explicit event assertions, and the synthetic captureException-mock unit test is dropped in favour of this integration coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../anthropic/scenario-response-error.mjs | 45 ++++++ .../suites/tracing/anthropic/test.ts | 27 +++- .../suites/tracing/google-genai/test.ts | 34 ++++- .../src/ai/anthropic-ai/streaming.ts | 23 +-- .../tracing/ai-client-error-capture.test.ts | 132 ------------------ 5 files changed, 105 insertions(+), 156 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-response-error.mjs delete 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/scenario-response-error.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-response-error.mjs new file mode 100644 index 000000000000..2a485287795f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-response-error.mjs @@ -0,0 +1,45 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + // Anthropic can hand back an error-shaped body on an otherwise successful (HTTP 200) response. + // The SDK resolves it as data, so the caller never sees a thrown error. + // @see https://docs.anthropic.com/en/api/errors#error-shapes + app.post('/anthropic/v1/messages', (_req, res) => { + res + .status(200) + .set('x-request-id', 'mock-response-error') + .json({ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + // Resolves with the error-shaped body; no try/catch because nothing is thrown. + await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + max_tokens: 100, + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); 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 98c40c7e5052..118ea48a503d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -417,9 +417,6 @@ 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({ span: container => { @@ -541,4 +538,28 @@ describe('Anthropic integration', () => { .completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-response-error.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures error-shaped responses returned as data', async () => { + await createRunner() + // The API returns the error as data on a 200 response, never as a thrown error to the caller, + // so the instrumentation intentionally captures it as an event. + .unordered() + .expect({ + event: { + exception: { + values: [ + { + value: 'Overloaded', + mechanism: { type: 'auto.ai.anthropic.anthropic_error', handled: false }, + }, + ], + }, + }, + }) + .expect({ transaction: { transaction: 'main' } }) + .start() + .completed(); + }); + }); }); 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 21364a25a149..969c4f6e26e7 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 @@ -229,8 +229,21 @@ 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') + // The provider surfaces blocked content within the stream and never returns it to the caller as + // a thrown error, so the instrumentation intentionally captures it as an event. + .unordered() + .expect({ + event: { + exception: { + values: [ + { + value: 'Content blocked: The prompt was blocked due to safety concerns', + mechanism: { type: 'auto.ai.google_genai', handled: false }, + }, + ], + }, + }, + }) .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -286,8 +299,21 @@ 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') + // The provider surfaces blocked content within the stream and never returns it to the caller as + // a thrown error, so the instrumentation intentionally captures it as an event. + .unordered() + .expect({ + event: { + exception: { + values: [ + { + value: 'Content blocked: The prompt was blocked due to safety concerns', + mechanism: { type: 'auto.ai.google_genai', handled: false }, + }, + ], + }, + }, + }) .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { diff --git a/packages/server-utils/src/ai/anthropic-ai/streaming.ts b/packages/server-utils/src/ai/anthropic-ai/streaming.ts index fcbafa1a456f..c28bf2d9cdb7 100644 --- a/packages/server-utils/src/ai/anthropic-ai/streaming.ts +++ b/packages/server-utils/src/ai/anthropic-ai/streaming.ts @@ -1,4 +1,4 @@ -import { captureException, SPAN_STATUS_ERROR } from '@sentry/core'; +import { SPAN_STATUS_ERROR } from '@sentry/core'; import type { Span } from '@sentry/core'; import { endStreamSpan } from '../core/utils'; import type { AnthropicAiStreamingEvent } from './types'; @@ -48,16 +48,10 @@ interface StreamingState { function isErrorEvent(event: AnthropicAiStreamingEvent, span: Span): boolean { if ('type' in event && typeof event.type === 'string') { - // If the event is an error, set the span status and capture the error - // These error events are not rejected by the API by default, but are sent as metadata of the response if (event.type === 'error') { + // The SDK surfaces this error to the caller (the async iterator rejects / their `error` + // listener fires), so we only mark the span failed and do not record it. span.setStatus({ code: SPAN_STATUS_ERROR, message: mapAnthropicErrorToStatusMessage(event.error?.type) }); - captureException(event.error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic.anthropic_error', - }, - }); return true; } } @@ -265,14 +259,9 @@ export function instrumentMessageStream endStreamSpan(span, state, recordOutputs); }); - stream.on('error', (error: unknown) => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic.stream_error', - }, - }); - + stream.on('error', () => { + // The stream error is surfaced to the caller (the async iterator rejects, or their own `error` + // listener fires), so we only mark the span failed and do not record it. if (span.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); span.end(); 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 deleted file mode 100644 index 31572676d70b..000000000000 --- a/packages/server-utils/test/ai/tracing/ai-client-error-capture.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -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): (...args: 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 = (..._args: unknown[]): 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(); - }); -}); From 1741dc03f6323b3ebedbcbc1798283c7684952a4 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 26 Aug 2026 11:05:34 +0200 Subject: [PATCH 5/5] fix(server-utils): Capture Anthropic stream errors to avoid swallowing them Attaching the MessageStream `error` listener stops the error being raised as an unhandled rejection, so not capturing it swallows the error for callers that don't await or iterate the stream. Restore the capture in the stream.on('error') handler. The type: 'error' stream-event path (isErrorEvent) stays uncaptured: the SDK throws that to the caller and never delivers it as a stream event, so it is a caller-handled error rather than error-as-data. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/anthropic/test.ts | 4 ++++ .../server-utils/src/ai/anthropic-ai/streaming.ts | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) 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 118ea48a503d..ee2bb16b37dc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -417,6 +417,10 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('handles streaming errors correctly', async () => { await createRunner() + // Stream errors surface via the MessageStream `error` event; attaching that listener stops it + // being raised as an unhandled rejection, so the instrumentation captures it. This test only + // asserts the spans. + .ignore('event') .expect({ transaction: EXPECTED_STREAM_ERROR_SPANS }) .expect({ span: container => { diff --git a/packages/server-utils/src/ai/anthropic-ai/streaming.ts b/packages/server-utils/src/ai/anthropic-ai/streaming.ts index c28bf2d9cdb7..3daf61c2b57e 100644 --- a/packages/server-utils/src/ai/anthropic-ai/streaming.ts +++ b/packages/server-utils/src/ai/anthropic-ai/streaming.ts @@ -1,4 +1,4 @@ -import { SPAN_STATUS_ERROR } from '@sentry/core'; +import { captureException, SPAN_STATUS_ERROR } from '@sentry/core'; import type { Span } from '@sentry/core'; import { endStreamSpan } from '../core/utils'; import type { AnthropicAiStreamingEvent } from './types'; @@ -259,9 +259,16 @@ export function instrumentMessageStream endStreamSpan(span, state, recordOutputs); }); - stream.on('error', () => { - // The stream error is surfaced to the caller (the async iterator rejects, or their own `error` - // listener fires), so we only mark the span failed and do not record it. + stream.on('error', (error: unknown) => { + // Attaching this listener stops the stream error from being raised as an unhandled rejection, so + // we capture it here to avoid swallowing it (e.g. for callers that don't await/iterate the stream). + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.stream_error', + }, + }); + if (span.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); span.end();