Is there an existing issue for this?
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/node
SDK Version
10.69.0
Framework Version
Node 24, no framework
Link to Sentry event
n/a, reproduced locally with a recording transport
Reproduction Example/SDK Setup
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: '...', tracesSampleRate: 1 });
const client = Sentry.instrumentOpenAiClient(openai);
// Ordinary application-level retry
async function withRetry(fn, attempts) {
let lastError;
for (let i = 0; i < attempts; i++) {
try { return await fn(); } catch (error) { lastError = error; }
}
throw lastError;
}
// First attempt 429s, second succeeds.
await withRetry(() => client.chat.completions.create(request), 3);
Steps to Reproduce
- Instrument an OpenAI, Anthropic, or Google GenAI client with the exported
instrument*Client function.
- Make a call that fails once and succeeds on retry.
- Observe the events sent to Sentry.
Running the above against 10.69.0 with a recording transport:
A. transient failure, retry succeeds
outcome: recovered
events sent: 1
- "Rate limit exceeded" handled=false type=auto.ai.openai
B. all attempts fail
outcome: exhausted
events sent: 3
- "Rate limit exceeded" handled=false type=auto.ai.openai
- "Rate limit exceeded" handled=false type=auto.ai.openai
- "Rate limit exceeded" handled=false type=auto.ai.openai
All three exported client instrumentations behave the same way:
openai outcome=recovered events=1 handled=false (auto.ai.openai)
anthropic outcome=recovered events=1 handled=false (auto.ai.anthropic)
google-genai outcome=recovered events=1 handled=false (auto.ai.google_genai)
Expected Result
No error event for a call the application recovered from, and at most one event for a call that ultimately fails, carrying the caller's own handled status.
Actual Result
The instrumentation calls captureException(error, { mechanism: { handled: false } }) and then rethrows, so it decides the error is an unhandled crash before any application retry or fallback logic runs. Because the capture happens inside the wrapped method, every retry produces another event.
Concretely, this means:
- A transient failure that the application successfully recovered from still appears in Sentry as an unhandled crash.
- A request retried N times produces N duplicate events.
- When the call really is fatal, the application's own boundary captures it too, so the same failure is recorded twice with conflicting
handled values. dedupeIntegration is not in the Node defaults, so both land.
The affected call sites all rethrow to the caller:
| File |
Mechanism type |
ai/openai/index.ts (streaming + non-streaming) |
auto.ai.openai.stream, auto.ai.openai |
ai/anthropic-ai/index.ts (streaming + non-streaming) |
auto.ai.anthropic |
ai/google-genai/index.ts (streaming + non-streaming) |
auto.ai.google_genai |
ai/core/utils.ts (.withResponse() wrapper) |
caller-supplied |
These are reachable through instrumentOpenAiClient, instrumentAnthropicAiClient and instrumentGoogleGenAIClient, which are exported from @sentry/node, @sentry/bun, @sentry/cloudflare, @sentry/vercel-edge, @sentry/aws-serverless, @sentry/google-cloud-serverless and @sentry/elysia. On the edge and serverless runtimes this is the only available instrumentation path.
The AI integration tests currently mask this: the OpenAI scenario catches two provider errors with the comment // Error is expected and handled, while the test runner drops the resulting events via .ignore('event').
Impact
handled feeds release health, so the effect lands on numbers teams act on. An unhandled error marks the session crashed, so a retried 429 costs the same crash-free rate as a real crash. Deploy during a provider blip and release health flags the new version as a regression. Unhandled issues are also auto-prioritized, so recovered failures outrank real bugs in triage, and volume-threshold alerts fire on retry storms that never reached a user.
A provider outage compounds it. One retried request bills 3 to 5 events instead of 0, and orgs near quota start dropping real errors, so an OpenAI incident costs visibility into unrelated production bugs.
AI apps hit this harder than other integrations because the multipliers are standard practice: retry with backoff, fallback chains across models and vendors, agent loops making 10 to 30 calls per user action, and errors that are ordinary control flow rather than faults (context_length_exceeded handled by truncating and retrying, or a content-policy refusal rendered as a normal message).
The workaround today is to filter the auto-instrumented event and capture explicitly at the application boundary, which is what we're doing. The other option teams reach for is dropping the AI integration to stop the false crash reports, which gives up the gen-AI spans and token accounting with it.
Suggested direction
This looks like the same call that was already made for the channel-based OpenAI integration in #21877, whose merge commit reads:
Let errors bubble to the global handlers instead of capturing them as unhandled at the instrumentation level, matching the DB/cache channel subscribers.
Applying that convention to the exported client instrumentations would keep the error span status while leaving the handled decision to the application boundary that actually knows the outcome.
Worth keeping as-is, for contrast: the places where a provider reports an error as data rather than by rejecting. That covers Anthropic's error stream events and error-shaped responses, and Google GenAI's blocked-content signal. The caller never sees those as a thrown error, so dropping those captures would lose the signal entirely.
Happy to open a PR for this.
Is there an existing issue for this?
mechanism#17212 (mechanism consistency), which is closed and does not cover the AI integrations.How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/nodeSDK Version
10.69.0
Framework Version
Node 24, no framework
Link to Sentry event
n/a, reproduced locally with a recording transport
Reproduction Example/SDK Setup
Steps to Reproduce
instrument*Clientfunction.Running the above against 10.69.0 with a recording transport:
All three exported client instrumentations behave the same way:
Expected Result
No error event for a call the application recovered from, and at most one event for a call that ultimately fails, carrying the caller's own
handledstatus.Actual Result
The instrumentation calls
captureException(error, { mechanism: { handled: false } })and then rethrows, so it decides the error is an unhandled crash before any application retry or fallback logic runs. Because the capture happens inside the wrapped method, every retry produces another event.Concretely, this means:
handledvalues.dedupeIntegrationis not in the Node defaults, so both land.The affected call sites all rethrow to the caller:
ai/openai/index.ts(streaming + non-streaming)auto.ai.openai.stream,auto.ai.openaiai/anthropic-ai/index.ts(streaming + non-streaming)auto.ai.anthropicai/google-genai/index.ts(streaming + non-streaming)auto.ai.google_genaiai/core/utils.ts(.withResponse()wrapper)These are reachable through
instrumentOpenAiClient,instrumentAnthropicAiClientandinstrumentGoogleGenAIClient, which are exported from@sentry/node,@sentry/bun,@sentry/cloudflare,@sentry/vercel-edge,@sentry/aws-serverless,@sentry/google-cloud-serverlessand@sentry/elysia. On the edge and serverless runtimes this is the only available instrumentation path.The AI integration tests currently mask this: the OpenAI scenario catches two provider errors with the comment
// Error is expected and handled, while the test runner drops the resulting events via.ignore('event').Impact
handledfeeds release health, so the effect lands on numbers teams act on. An unhandled error marks the session crashed, so a retried 429 costs the same crash-free rate as a real crash. Deploy during a provider blip and release health flags the new version as a regression. Unhandled issues are also auto-prioritized, so recovered failures outrank real bugs in triage, and volume-threshold alerts fire on retry storms that never reached a user.A provider outage compounds it. One retried request bills 3 to 5 events instead of 0, and orgs near quota start dropping real errors, so an OpenAI incident costs visibility into unrelated production bugs.
AI apps hit this harder than other integrations because the multipliers are standard practice: retry with backoff, fallback chains across models and vendors, agent loops making 10 to 30 calls per user action, and errors that are ordinary control flow rather than faults (
context_length_exceededhandled by truncating and retrying, or a content-policy refusal rendered as a normal message).The workaround today is to filter the auto-instrumented event and capture explicitly at the application boundary, which is what we're doing. The other option teams reach for is dropping the AI integration to stop the false crash reports, which gives up the gen-AI spans and token accounting with it.
Suggested direction
This looks like the same call that was already made for the channel-based OpenAI integration in #21877, whose merge commit reads:
Applying that convention to the exported client instrumentations would keep the error span status while leaving the
handleddecision to the application boundary that actually knows the outcome.Worth keeping as-is, for contrast: the places where a provider reports an error as data rather than by rejecting. That covers Anthropic's
errorstream events and error-shaped responses, and Google GenAI's blocked-content signal. The caller never sees those as a thrown error, so dropping those captures would lose the signal entirely.Happy to open a PR for this.