Skip to content

AI client instrumentation reports caller-handled provider errors as unhandled, once per retry #23023

Description

@lux-in-tenebris-lucet

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

  1. Instrument an OpenAI, Anthropic, or Google GenAI client with the exported instrument*Client function.
  2. Make a call that fails once and succeeds on retry.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status
    Waiting for: Product Owner

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions