Skip to content

fix(server-utils): End Anthropic stream spans drained through the raw Response - #24485

Draft
RulaKhaled wants to merge 5 commits into
developfrom
rolaabuhasna/js-3642-anthropic-raw-response-span
Draft

RulaKhaled wants to merge 5 commits into
developfrom
rolaabuhasna/js-3642-anthropic-raw-response-span

Conversation

@RulaKhaled

@RulaKhaled RulaKhaled commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

The gen_ai.chat span for messages.create({ stream: true }) was only ended by the SDK Stream's async iterator. A caller who takes .asResponse() or .withResponse() and reads response.body themselves never touches that iterator, so nothing ended the span and it was dropped, leaving a bare http.client span for the LLM call. Found in a Flue app, whose @earendil-works/pi-ai layer always calls .asResponse().

Root cause: not what the issue says. The result handed to deferSpanEnd is a Stream, not a raw Response, because the orchestrion promise wrapper side-chains .then() on the APIPromise and that forces parse(). So the check passes, the span's fate is handed to a Stream nobody will ever iterate, and it leaks.

Nothing on that Stream points back at the Response it was built from, so the fix hooks Stream.fromSSEResponse, the one place the two meet. It runs inside the traced messages.create call, so getActiveSpan() there is the span to link. Its Response body then gets wrapped.

Instrumenting the body rather than the Stream is what makes this simple: every drain path bottoms out in response.body, so one accumulator covers the raw-Response reader, the async iterator, tee() and toReadableStream(). tee() was losing its span the same way, since it calls this.iterator() directly. The async-iterator instrumentation is gone from the channel path, since keeping both would have needed a handshake to stop them double-counting; the manual instrumentAnthropicAiClient path still uses it.

Two details that are easy to get wrong and are commented in place: the wrapper takes its reader on the first read rather than at install time, and its stream has a high-water mark of 0. Either one left at the default disturbs the body, which makes text(), arrayBuffer() and clone() throw on a response the caller hasn't read yet.

Verified across asResponse, withResponse, the iterator, tee, toReadableStream and an early break: all six now produce identical attributes, including token usage and finish reasons. A streaming call whose body we can't wrap ends at asyncEnd with request attributes instead of hanging.

Fixes #24258

… Response

Hook `Stream.fromSSEResponse` and wrap the `Response` body it is built from, so
the `gen_ai.chat` span ends however the caller drains the stream.

Every consumption path bottoms out in `response.body`, but only the SDK
`Stream`'s async iterator was instrumented. A caller who takes `.asResponse()`
or `.withResponse()` and reads the body themselves never touches that iterator,
so nothing ever ended the span and it was dropped. `tee()` was lost the same
way, since it calls `this.iterator()` directly.

Fixes #24258

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 18, 2026

Copy link
Copy Markdown

JS-3642

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 29.09 kB - -
@sentry/browser - with treeshaking flags 27.35 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.26 kB - -
@sentry/browser (incl. Tracing) 50.6 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 50.62 kB - -
@sentry/browser (incl. Tracing, Profiling) 53.61 kB - -
@sentry/browser (incl. Tracing, Replay) 90.15 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.25 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 94.85 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 107.83 kB - -
@sentry/browser (incl. Feedback) 46.62 kB - -
@sentry/browser (incl. sendFeedback) 34.15 kB - -
@sentry/browser (incl. FeedbackAsync) 39.26 kB - -
@sentry/browser (incl. Metrics) 30.1 kB - -
@sentry/browser (incl. Logs) 30.35 kB - -
@sentry/browser (incl. Metrics & Logs) 31.02 kB - -
@sentry/react 30.84 kB - -
@sentry/react (incl. Tracing) 52.94 kB - -
@sentry/vue 36.34 kB - -
@sentry/vue (incl. Tracing) 52.91 kB - -
@sentry/svelte 29.11 kB - -
CDN Bundle 30.8 kB - -
CDN Bundle (incl. Tracing) 51.15 kB - -
CDN Bundle (incl. Logs, Metrics) 33.06 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 53.14 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.75 kB - -
CDN Bundle (incl. Tracing, Replay) 88.69 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.63 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 94.73 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.78 kB - -
CDN Bundle - uncompressed 91.16 kB - -
CDN Bundle (incl. Tracing) - uncompressed 152.66 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.73 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.61 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 227.14 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 272.23 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 278.17 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 285.93 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 291.86 kB - -
@sentry/nextjs (client) 55.27 kB - -
@sentry/sveltekit (client) 51.05 kB - -
@sentry/core/server 39.63 kB - -
@sentry/core/browser 13.66 kB - -
@sentry/node 132.98 kB +0.48% +629 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 82.07 kB +0.05% +41 B 🔺
@sentry/node - without tracing 89.86 kB +0.09% +73 B 🔺
@sentry/node - without channel injection 111.86 kB +0.59% +652 B 🔺
@sentry/aws-serverless 98.1 kB +0.07% +63 B 🔺
@sentry/cloudflare (withSentry) - minified 204.69 kB - -
@sentry/cloudflare (withSentry) 509.25 kB - -

View base workflow run

RulaKhaled and others added 4 commits September 18, 2026 09:34
…pping it

The wrapper acquired a reader as soon as it was installed, and its stream
defaulted to a high-water mark of 1, so it read a chunk ahead before anyone
asked for one. Both disturb the body, which made `text()`, `arrayBuffer()` and
`clone()` throw on a `.asResponse()` result the caller had not read yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The body wrapper sees every chunk whichever way the caller drains the stream, so
keeping the async-iterator instrumentation alongside it bought nothing and cost a
claim handshake to stop the two from double-counting. Drop it from the channel
path: one accumulator, one owner, and `wrapStreamResult` becomes a membership
test. The manual `instrumentAnthropicAiClient` path still uses the iterator.

A streaming call whose body we couldn't wrap now ends at `asyncEnd` with request
attributes rather than hanging, so the degraded case stays graceful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Handing the span to the SSE body wrapper left several paths uncovered.

A body that is not a web ReadableStream, which is what an injected
node-fetch or undici shim hands back, could not be wrapped at all, so the
span ended the moment create() resolved with request attributes only.
Patching the SDK Stream's iterator now serves as the fallback for those.

clone() tees the response's internal body and swaps in one branch, which
left the stream the wrapper had captured locked, so the next read threw.
The wrapper now resolves the source through the prototype getter on every
read instead of holding on to the stream it was handed.

text(), json() and arrayBuffer() read the internal body and never touch
the property we shadow, so the span never ended. Those are wrapped too,
and text() and arrayBuffer() feed their result through the accumulator so
the response attributes survive.

Three smaller ones: recordOutputs is resolved per call rather than once at
subscribe time, since subscribeToSseStream runs once per process and a
later client can carry different options. The frame parser's try now
covers a single frame, so one unparsable line no longer costs us the rest
of its chunk, where message_delta and message_stop ride. An unsampled span
skips the wrap entirely, and settle() flushes a trailing frame left
without a newline.

The pass-through is a byte stream now, so getReader({ mode: 'byob' })
keeps working on a response that supported it before.

instrumentRawSseBody moved to its own file to stay under the line cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Anthropic gen_ai span never ends when a streaming call is consumed via .asResponse()

1 participant