Repository navigation
fix(server-utils): Record Anthropic chat spans for thread and tool-runner streams on 1.2+ - #25931
Conversation
size-limit report 📦Compared: 61349ce (PR test merge using develop at 2f3c8a9) Against: 2f3c8a9 (develop) · base run #65503 ✅ Baseline matches: this diff contains only this PR's changes.
|
Optional E2E testsSome optional checks were not successful. They do not block the merge. 6 not successful, 49 successful. See the run.
Successful checks
|
b953034 to
4198705
Compare
|
bugbot run |
|
bugbot run |
7fe1266 to
2e6e182
Compare
|
bugbot run |
…nner streams on 1.2+
On @anthropic-ai/sdk 1.2.0+, streamed turns from
beta.messages.toolRunner({ stream: true }) and beta.messages.thread().stream()
had no gen_ai.chat spans. The thread parses the response of each create() call
through a new APIPromise our hook never sees, so the span never ended. Hook
BetaMessageStream._start(), match each request to its stream by abort signal,
and end the span from the stream's events.
instrumentMessageStream now also ends the span on 'end', so aborted or truncated
streams no longer leave it open.
Fixes #25816
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A later create() sent with a finished thread stream's signal was wired to that stream and its span never ended. Drop the entry once an attempt succeeds, since only rejected attempts are retried. Also cover the 'end' listener with a unit test and update the wrapStreamResult JSDoc. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each mode and turn is now its own test.each case, with no conditionals, so a failure points at one path. The cases share one scenario run per env, since every run is a Node process. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A create() sent with the signal of a thread stream whose request failed was still matched to that stream, so its span never ended. A finished stream sends no more requests, so skip it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
isaacs
left a comment
There was a problem hiding this comment.
Should align with the changes in 26023 (either here or in a follow-up), and we should make a deliberate choice about what to do with recovered errors (even if that's just adding a test to pin the current behavior).
|
|
||
| const { recordOutputs } = resolveAIRecordingOptions(options); | ||
| // Not `asResponse()`, which on 1.x also ends the SDK's own span when the user turned its tracing on. | ||
| void request._responseProps().then( |
There was a problem hiding this comment.
This is a little weird. An attempt that the thread recovers from can end as an error span, which is confusing.
_createWithRecovery() re-sends a turn when ThreadState.recoversFrom(error) is true (for example a lost thread, which triggers a replay). Each rejected attempt gets its own gen_ai.chat span, ended with end(error). So a turn that the SDK recovered from without the user's knowledge shows an errored chat span next to the successful one. That can be accurate (a real 4xx happened), but it is a product decision.
I created a test to debug it, and it shows 2 spans being received, plus the error event. If that's the way we want this to be represented, that's defensible, but probably worth having a test to pin it as an intentional choice: https://gist.github.com/isaacs/5539045945a6881fdaf99cb0a9281743
There was a problem hiding this comment.
that's a great catch, I pinned it with a test and kept it on purpose. the rejected attempt was a real 4xx, same as an http.client span a retry recovers from. hiding it means copying the SDK's private recovery rules (only some codes, only on a continue), and a copy that drifts would hide real errors. didn't get an error event in my run though, only the span. which one did you see? 🤔
5fcb668 to
7df1252
Compare
An aborted thread or tool-runner stream ended its chat span ok with nothing to show it was cut short. Mark it cancelled, as Google GenAI does since #26023, both mid-stream and before the response arrives. Also pin the error span of an attempt the thread recovered from, which is deliberate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7c140a0. Configure here.

On
@anthropic-ai/sdk1.2.0+, streamed turns frombeta.messages.toolRunner({ stream: true })andbeta.messages.thread().stream()had nogen_ai.chatspans. Every tool runner runs over a thread since 1.2.0, including on Bedrock and Vertex.The thread calls
beta.messages.create(), which opens our span, but parses the response through a newAPIPromiseour hook never sees, so the span never ended. This hooksBetaMessageStream._start(), matches each request to its stream by abort signal, and ends the span from the stream's events with usage and output.instrumentMessageStreamnow also ends the span onend, so aborted or truncated streams,messages.stream()included, no longer leave it open. An abort before the response is not marked as an error.Fixes #25816
🤖 Generated with Claude Code