From 67c5f63b0b51213520de22deb5a8d74904629e2d Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 1 Jul 2026 20:38:17 +0200 Subject: [PATCH] fix(core): Capture Anthropic stream stop_reason from message_delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic streaming instrumentation only read `stop_reason` from `event.message.stop_reason`, but a real stream never populates that — `event.message` appears only on `message_start` (where `stop_reason` is `null`), and the final reason arrives on the `message_delta` event as `delta.stop_reason`. So `finish_reasons` was silently dropped for real streams. Reads it from `message_delta.delta` (the dead `message.stop_reason` path is removed) and re-adds the `finish_reasons` assertions to the streaming tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/anthropic/test.ts | 6 +++++- packages/core/src/tracing/anthropic-ai/streaming.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 5 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 cbb90ce72bca..6ba0dcc08cb3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -8,6 +8,7 @@ import { GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, @@ -301,7 +302,9 @@ describe('Anthropic integration', () => { expect(span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE].value).toBe('msg_stream_1'); } - const detailedStreamSpan = requestStreamSpans[0]; + const detailedStreamSpan = requestStreamSpans.find( + span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]?.value === '["end_turn"]', + ); expect(detailedStreamSpan).toBeDefined(); expect(detailedStreamSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE].value).toBe('anthropic'); expect(detailedStreamSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE].value).toBe('chat'); @@ -426,6 +429,7 @@ describe('Anthropic integration', () => { expect(span.status).toBe('ok'); expect(span.attributes['sentry.op'].value).toBe('gen_ai.chat'); expect(span.attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE].value).toBe(true); + expect(span.attributes[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE].value).toBe('["tool_use"]'); expect(span.attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE].value).toBe(EXPECTED_TOOLS_JSON); expect(span.attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE].value).toBe(EXPECTED_TOOL_CALLS_JSON); } diff --git a/packages/core/src/tracing/anthropic-ai/streaming.ts b/packages/core/src/tracing/anthropic-ai/streaming.ts index d8bc8d3fad2e..68bc9a281001 100644 --- a/packages/core/src/tracing/anthropic-ai/streaming.ts +++ b/packages/core/src/tracing/anthropic-ai/streaming.ts @@ -72,12 +72,15 @@ function isErrorEvent(event: AnthropicAiStreamingEvent, span: Span): boolean { */ function handleMessageMetadata(event: AnthropicAiStreamingEvent, state: StreamingState): void { - // The token counts shown in the usage field of the message_delta event are cumulative. + // Cumulative token counts and the final stop reason both arrive on the message_delta event. // @see https://docs.anthropic.com/en/docs/build-with-claude/streaming#event-types - if (event.type === 'message_delta' && event.usage) { - if ('output_tokens' in event.usage && typeof event.usage.output_tokens === 'number') { + if (event.type === 'message_delta') { + if (event.usage && typeof event.usage.output_tokens === 'number') { state.completionTokens = event.usage.output_tokens; } + if (event.delta?.stop_reason) { + state.finishReasons.push(event.delta.stop_reason); + } } if (event.message) { @@ -85,7 +88,6 @@ function handleMessageMetadata(event: AnthropicAiStreamingEvent, state: Streamin if (message.id) state.responseId = message.id; if (message.model) state.responseModel = message.model; - if (message.stop_reason) state.finishReasons.push(message.stop_reason); if (message.usage) { if (typeof message.usage.input_tokens === 'number') state.promptTokens = message.usage.input_tokens;