From 67be1216aa507bd8049c4b7f54360eaa88335590 Mon Sep 17 00:00:00 2001 From: Doron Kopit Date: Thu, 5 Nov 2026 15:09:03 +0200 Subject: [PATCH] fix(anthropic): record finish reasons for streamed turns with empty content Fixes #4362 --- .../instrumentation/anthropic/span_utils.py | 10 +++-- .../instrumentation/anthropic/streaming.py | 5 ++- .../tests/test_semconv_span_attrs.py | 38 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py index 61ecb32bc8..bc6bf2b52b 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py @@ -392,17 +392,17 @@ def set_response_attributes(span, response): @dont_throw -def set_streaming_response_attributes(span, complete_response_events): +def set_streaming_response_attributes(span, complete_response_events, stop_reason=None): from opentelemetry.instrumentation.anthropic import set_span_attribute - if not span.is_recording() or not complete_response_events: + if not span.is_recording() or not (complete_response_events or stop_reason): return # Collect all parts and determine finish_reason parts = [] finish_reasons = [] - for event in complete_response_events: + for event in complete_response_events or []: finish_reason = event.get("finish_reason") if finish_reason: mapped = _map_finish_reason(finish_reason) @@ -436,6 +436,10 @@ def set_streaming_response_attributes(span, complete_response_events): "content": event.get("text"), }) + # A turn with no content blocks only carries the message-level stop_reason + if not finish_reasons and stop_reason: + finish_reasons.append(_map_finish_reason(stop_reason)) + if finish_reasons: span.set_attribute(GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons) diff --git a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py index bd2d6dc15d..6c74dfc1e5 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py @@ -58,6 +58,7 @@ def _process_response_item(item, complete_response): if event.get("type") == "tool_use": event["input"] = event.get("input", "") + item.delta.partial_json elif item.type == "message_delta": + complete_response["stop_reason"] = item.delta.stop_reason for event in complete_response.get("events", []): event["finish_reason"] = item.delta.stop_reason if item.usage: @@ -175,7 +176,9 @@ def _handle_streaming_response(span, event_logger, complete_response): else: if not span.is_recording(): return - set_streaming_response_attributes(span, complete_response.get("events")) + set_streaming_response_attributes( + span, complete_response.get("events"), complete_response.get("stop_reason") + ) class AnthropicStream(ObjectProxy): diff --git a/packages/opentelemetry-instrumentation-anthropic/tests/test_semconv_span_attrs.py b/packages/opentelemetry-instrumentation-anthropic/tests/test_semconv_span_attrs.py index f99385702f..60f9ab86d7 100644 --- a/packages/opentelemetry-instrumentation-anthropic/tests/test_semconv_span_attrs.py +++ b/packages/opentelemetry-instrumentation-anthropic/tests/test_semconv_span_attrs.py @@ -1062,6 +1062,44 @@ def test_streaming_finish_reason_none_does_not_set_span_attr(): assert GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS not in span.attributes +@pytest.mark.parametrize( + "with_content, expected_output", + [ + (False, None), + (True, [{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "stop"}]), + ], +) +def test_streaming_finish_reason_recorded_from_message_delta(with_content, expected_output): + """The message_delta stop_reason must be recorded even when the streamed + turn has no content blocks (content == []).""" + from opentelemetry.instrumentation.anthropic.streaming import ( + _handle_streaming_response, + _process_response_item, + ) + + items = [] + if with_content: + items += [ + SimpleNamespace(type="content_block_start", index=0, content_block=SimpleNamespace(type="text")), + SimpleNamespace( + type="content_block_delta", index=0, delta=SimpleNamespace(type="text_delta", text="hi") + ), + ] + items.append( + SimpleNamespace(type="message_delta", delta=SimpleNamespace(stop_reason="end_turn"), usage=None) + ) + complete_response = {"events": [], "model": "", "usage": {}, "id": ""} + for item in items: + _process_response_item(item, complete_response) + + span = make_span() + _handle_streaming_response(span, None, complete_response) + + assert span.attributes[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ["stop"] + output = span.attributes.get(GenAIAttributes.GEN_AI_OUTPUT_MESSAGES) + assert (json.loads(output) if output else None) == expected_output + + # --------------------------------------------------------------------------- # P2 #1 — Streaming: unknown block types (e.g. redacted_thinking) must be skipped # ---------------------------------------------------------------------------