From 44ca6900faf430b1b13adeff75e60f13d79c6ced Mon Sep 17 00:00:00 2001 From: Solaris-star <820622658@qq.com> Date: Mon, 20 Jul 2026 19:50:06 +0800 Subject: [PATCH] fix(anthropic): record finish_reasons for empty-content streams message_delta only wrote stop_reason onto per-content-block events. When the stream had no content blocks, events stayed empty and set_streaming_response_attributes returned early, dropping gen_ai.response.finish_reasons. Store message-level stop_reason and use it as a fallback. Fixes #4362 --- .../instrumentation/anthropic/span_utils.py | 38 ++++++++++++++++++- .../instrumentation/anthropic/streaming.py | 10 ++++- .../tests/test_semconv_span_attrs.py | 28 ++++++++++++++ 3 files changed, 72 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..5443128907 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py @@ -392,10 +392,27 @@ 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_or_events): + """Record finish_reasons / output messages for a streaming response. + + Accepts either the full ``complete_response`` dict (preferred; carries a + message-level ``stop_reason`` when content blocks are empty — #4362) or the + legacy bare events list used by existing unit tests. + """ from opentelemetry.instrumentation.anthropic import set_span_attribute - if not span.is_recording() or not complete_response_events: + if not span.is_recording(): + return + + if isinstance(complete_response_or_events, dict): + complete_response_events = complete_response_or_events.get("events") or [] + message_stop_reason = complete_response_or_events.get("stop_reason") + else: + complete_response_events = complete_response_or_events or [] + message_stop_reason = None + + # Empty stream with no stop_reason: nothing to record. + if not complete_response_events and not message_stop_reason: return # Collect all parts and determine finish_reason @@ -436,6 +453,10 @@ def set_streaming_response_attributes(span, complete_response_events): "content": event.get("text"), }) + # Fallback for empty-content streams: use message-level stop_reason. + if not finish_reasons and message_stop_reason: + finish_reasons.append(_map_finish_reason(message_stop_reason)) + if finish_reasons: span.set_attribute(GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons) @@ -452,3 +473,16 @@ def set_streaming_response_attributes(span, complete_response_events): GenAIAttributes.GEN_AI_OUTPUT_MESSAGES, json.dumps(output_messages, cls=JSONEncoder), ) + elif finish_reasons and should_send_prompts(): + # Empty content but we still have a stop_reason — record an empty + # assistant message so output.messages is present (#4362). + msg = { + "role": "assistant", + "parts": [], + "finish_reason": finish_reasons[-1], + } + set_span_attribute( + span, + GenAIAttributes.GEN_AI_OUTPUT_MESSAGES, + json.dumps([msg], cls=JSONEncoder), + ) diff --git a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py index bd2d6dc15d..d1506e13d1 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py @@ -58,8 +58,14 @@ 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": + # Always keep the message-level stop_reason. When the stream has no + # content blocks, events stays empty and the per-event loop below is a + # no-op — without this, finish_reasons are lost (#4362). + stop_reason = getattr(item.delta, "stop_reason", None) + if stop_reason is not None: + complete_response["stop_reason"] = stop_reason for event in complete_response.get("events", []): - event["finish_reason"] = item.delta.stop_reason + event["finish_reason"] = stop_reason if item.usage: if "usage" in complete_response: item_output_tokens = dict(item.usage).get("output_tokens", 0) @@ -175,7 +181,7 @@ 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) 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..92703b9225 100644 --- a/packages/opentelemetry-instrumentation-anthropic/tests/test_semconv_span_attrs.py +++ b/packages/opentelemetry-instrumentation-anthropic/tests/test_semconv_span_attrs.py @@ -363,6 +363,34 @@ def test_finish_reasons_set_when_content_tracing_disabled(): assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes +def test_streaming_empty_content_records_finish_reason(): + """Empty content stream still records finish_reasons from message-level stop_reason (#4362).""" + span = make_span() + complete_response = {"events": [], "stop_reason": "end_turn", "model": "claude-x", "usage": {}, "id": ""} + set_streaming_response_attributes(span, complete_response) + + assert span.attributes[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ["stop"] + output = json.loads(span.attributes[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES]) + assert output[0]["role"] == "assistant" + assert output[0]["parts"] == [] + assert output[0]["finish_reason"] == "stop" + + +def test_streaming_process_response_item_stores_message_stop_reason(): + """message_delta must store stop_reason even when no content blocks exist (#4362).""" + from types import SimpleNamespace + from opentelemetry.instrumentation.anthropic.streaming import _process_response_item + + complete_response = {"events": [], "model": "", "usage": {}, "id": ""} + item = SimpleNamespace( + type="message_delta", + delta=SimpleNamespace(stop_reason="end_turn"), + usage=None, + ) + _process_response_item(item, complete_response) + assert complete_response["stop_reason"] == "end_turn" + + def test_streaming_finish_reasons_set_when_content_tracing_disabled(): """Streaming finish_reasons must be recorded even when TRACELOOP_TRACE_CONTENT=false.""" os.environ[TRACELOOP_TRACE_CONTENT] = "false"