From a8062d6db417625c244cedcdc1d12092b46a5add Mon Sep 17 00:00:00 2001 From: Nitesh Seni Date: Fri, 9 Oct 2026 12:15:59 +0530 Subject: [PATCH 1/4] fix(anthropic): materialize generator messages, system, and tools before extraction (#825) --- .../.changelog/825.fixed | 1 + .../instrumentation/genai/anthropic/patch.py | 30 +-- .../tests/test_async_messages.py | 104 +++++++++ .../tests/test_sync_messages.py | 204 +++++++++++++++++- 4 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/825.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/825.fixed b/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/825.fixed new file mode 100644 index 000000000..8436aa95b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/825.fixed @@ -0,0 +1 @@ +Prevent one-shot iterators for messages, system, and tools from being consumed before reaching the SDK during content capture. diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py index 6169b7639..5ecdd4016 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py @@ -96,17 +96,19 @@ def _fail_context_manager_response( ) -def _materialize_tools(kwargs: dict[str, Any]) -> None: - """Replace a one-shot ``tools`` iterator with a list. - - ``tools`` is typed as an iterable, so it may be a generator, and both the - SDK and the tool definitions read it. Call this before either of them does: - ``create`` reads the request after this wrapper, but ``stream`` serializes - it while building the manager, before the invocation is created. +def _materialize_iterables(kwargs: dict[str, Any]) -> None: + """Replace one-shot iterators with lists so both telemetry and SDK see the same data. + + ``messages``, ``system``, and ``tools`` are typed as iterables, so they may be + generators, and both the SDK and telemetry extractors read them. Call this + before either of them does: ``create`` reads the request after this wrapper, + but ``stream`` serializes it while building the manager, before the + invocation is created. """ - tools = kwargs.get("tools") - if isinstance(tools, Iterator): - kwargs["tools"] = list(cast("Iterator[Any]", tools)) + for key in ("messages", "system", "tools"): + value = kwargs.get(key) + if isinstance(value, Iterator): + kwargs[key] = list(cast("Iterator[Any]", value)) def _is_raw_response(result: object) -> bool: @@ -144,7 +146,7 @@ def traced_method( | AnthropicStream[RawMessageStreamEvent] | MessagesStreamWrapper[None] ): - _materialize_tools(kwargs) + _materialize_iterables(kwargs) invocation = _create_invocation( handler, instance, args, kwargs, capture_content ) @@ -203,7 +205,7 @@ async def traced_method( | AnthropicAsyncStream[RawMessageStreamEvent] | AsyncMessagesStreamWrapper[None] ): - _materialize_tools(kwargs) + _materialize_iterables(kwargs) invocation = _create_invocation( handler, instance, args, kwargs, capture_content ) @@ -284,7 +286,7 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> MessagesStreamManagerWrapper[Any]: - _materialize_tools(kwargs) + _materialize_iterables(kwargs) return MessagesStreamManagerWrapper( wrapped(*args, **kwargs), lambda: _create_invocation( @@ -310,7 +312,7 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> AsyncMessagesStreamManagerWrapper[Any]: - _materialize_tools(kwargs) + _materialize_iterables(kwargs) return AsyncMessagesStreamManagerWrapper( wrapped(*args, **kwargs), lambda: _create_invocation( diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py index b9c275244..73ef1c728 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py @@ -970,6 +970,110 @@ def respond(request): _assert_weather_tool_definitions(spans[0]) +@pytest.mark.asyncio +async def test_async_messages_create_messages_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """Async counterpart: a one-shot ``messages`` iterator must reach the SDK.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "What is the weather in SF?"} + try: + await client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + ) + finally: + await client.close() + + assert seen["body"]["messages"] == [user_msg] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_INPUT_MESSAGES + ) + assert input_messages[0]["role"] == "user" + assert input_messages[0]["parts"] == [ + {"type": "text", "content": "What is the weather in SF?"} + ] + + +@pytest.mark.asyncio +async def test_async_messages_create_system_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """Async counterpart: a one-shot ``system`` iterator must reach the SDK.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + system_block = {"type": "text", "text": "You are a helpful assistant."} + try: + await client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "Hello"}], + system=(block for block in [system_block]), + ) + finally: + await client.close() + + assert seen["body"]["system"] == [system_block] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + system_instructions = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS + ) + assert system_instructions == [ + {"type": "text", "content": "You are a helpful assistant."} + ] + + @pytest.mark.asyncio @pytest.mark.vcr() @pytest.mark.cassette("test_async_messages_create_captures_tool_use_content") diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py index 9ec93f3e9..65220e584 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py @@ -14,9 +14,9 @@ import pytest try: - import httpx + import httpx2 as _http_lib except ImportError: - import httpx2 as httpx + import httpx as _http_lib from anthropic import Anthropic, APIConnectionError, NotFoundError from anthropic._response import APIResponse, ResponseContextManager @@ -542,7 +542,7 @@ def handle_request(request): received_content = body["messages"][0]["content"][0]["source"][ "content" ] - return httpx.Response( + return _http_lib.Response( 200, json={ "id": "msg_generator_content", @@ -560,8 +560,8 @@ def document_content(): yield {"type": "text", "text": "First"} yield {"type": "text", "text": "Second"} - transport = httpx.MockTransport(handle_request) - with httpx.Client(transport=transport) as http_client: + transport = _http_lib.MockTransport(handle_request) + with _http_lib.Client(transport=transport) as http_client: client = Anthropic(http_client=http_client) client.messages.create( model="claude-sonnet-4-20250514", @@ -1358,7 +1358,7 @@ def test_sync_messages_create_tools_generator_reaches_the_sdk( def respond(request): seen["body"] = json.loads(request.content) - return httpx.Response( + return _http_lib.Response( 200, json={ "id": "msg_generator", @@ -1375,7 +1375,9 @@ def respond(request): client = Anthropic( api_key="test_anthropic_api_key", base_url="http://anthropic.test", - http_client=httpx.Client(transport=httpx.MockTransport(respond)), + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), ) client.messages.create( model="claude-sonnet-4-20250514", @@ -1390,6 +1392,102 @@ def respond(request): _assert_weather_tool_definitions(spans[0]) +def test_sync_messages_create_messages_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """A one-shot ``messages`` iterator must still reach the SDK.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "What is the weather in SF?"} + client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + ) + + assert seen["body"]["messages"] == [user_msg] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_INPUT_MESSAGES + ) + assert input_messages[0]["role"] == "user" + assert input_messages[0]["parts"] == [ + {"type": "text", "content": "What is the weather in SF?"} + ] + + +def test_sync_messages_create_system_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """A one-shot ``system`` iterator must still reach the SDK.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), + ) + system_block = {"type": "text", "text": "You are a helpful assistant."} + client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "Hello"}], + system=(block for block in [system_block]), + ) + + assert seen["body"]["system"] == [system_block] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + system_instructions = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS + ) + assert system_instructions == [ + {"type": "text", "content": "You are a helpful assistant."} + ] + + def test_sync_messages_stream_tools_generator_is_recorded( span_exporter, instrument_with_content ): @@ -1402,7 +1500,7 @@ def test_sync_messages_stream_tools_generator_is_recorded( def respond(request): seen["body"] = json.loads(request.content) - return httpx.Response( + return _http_lib.Response( 200, headers={"content-type": "text/event-stream"}, content=_STREAM_SSE_BODY, @@ -1411,7 +1509,9 @@ def respond(request): client = Anthropic( api_key="test_anthropic_api_key", base_url="http://anthropic.test", - http_client=httpx.Client(transport=httpx.MockTransport(respond)), + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), ) with client.messages.stream( model="claude-sonnet-4-20250514", @@ -1427,6 +1527,88 @@ def respond(request): _assert_weather_tool_definitions(spans[0]) +def test_sync_messages_stream_messages_generator_is_recorded( + span_exporter, instrument_with_content +): + """``stream`` serializes messages before the invocation exists.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "What is the weather in SF?"} + with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + ) as stream: + stream.until_done() + + assert seen["body"]["messages"] == [user_msg] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_INPUT_MESSAGES + ) + assert input_messages[0]["role"] == "user" + assert input_messages[0]["parts"] == [ + {"type": "text", "content": "What is the weather in SF?"} + ] + + +def test_sync_messages_stream_system_generator_is_recorded( + span_exporter, instrument_with_content +): + """``stream`` serializes system before the invocation exists.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), + ) + system_block = {"type": "text", "text": "You are a helpful assistant."} + with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "Hello"}], + system=(block for block in [system_block]), + ) as stream: + stream.until_done() + + assert seen["body"]["system"] == [system_block] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + system_instructions = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS + ) + assert system_instructions == [ + {"type": "text", "content": "You are a helpful assistant."} + ] + + @pytest.mark.vcr() @pytest.mark.cassette("test_sync_messages_create_captures_tool_use_content") @pytest.mark.skipif( @@ -2151,9 +2333,9 @@ def test_sync_messages_raw_response_parse_to_honors_cast_target( messages=[{"role": "user", "content": "Say hello in one word."}], ) as raw_response: raw_response.parse() - as_httpx = raw_response.parse(to=httpx.Response) + as_httpx = raw_response.parse(to=_http_lib.Response) - assert isinstance(as_httpx, httpx.Response) + assert isinstance(as_httpx, _http_lib.Response) @pytest.mark.vcr() From 9d1897fe8c206ec6e3f21f1e6e8e5880e7c06585 Mon Sep 17 00:00:00 2001 From: Nitesh Seni Date: Fri, 9 Oct 2026 13:21:22 +0530 Subject: [PATCH 2/4] fix(anthropic): guard iterable materialization with capture_content Assisted-by: Claude Opus 4.6 --- .../instrumentation/genai/anthropic/patch.py | 16 +- .../tests/test_async_messages.py | 289 ++++++++++++++++++ .../tests/test_sync_messages.py | 97 ++++++ 3 files changed, 396 insertions(+), 6 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py index 5ecdd4016..7e6ce72f2 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py @@ -101,8 +101,8 @@ def _materialize_iterables(kwargs: dict[str, Any]) -> None: ``messages``, ``system``, and ``tools`` are typed as iterables, so they may be generators, and both the SDK and telemetry extractors read them. Call this - before either of them does: ``create`` reads the request after this wrapper, - but ``stream`` serializes it while building the manager, before the + before either of them does when content capture is enabled: ``create`` reads the request + after this wrapper, but ``stream`` serializes it while building the manager, before the invocation is created. """ for key in ("messages", "system", "tools"): @@ -146,7 +146,8 @@ def traced_method( | AnthropicStream[RawMessageStreamEvent] | MessagesStreamWrapper[None] ): - _materialize_iterables(kwargs) + if capture_content: + _materialize_iterables(kwargs) invocation = _create_invocation( handler, instance, args, kwargs, capture_content ) @@ -205,7 +206,8 @@ async def traced_method( | AnthropicAsyncStream[RawMessageStreamEvent] | AsyncMessagesStreamWrapper[None] ): - _materialize_iterables(kwargs) + if capture_content: + _materialize_iterables(kwargs) invocation = _create_invocation( handler, instance, args, kwargs, capture_content ) @@ -286,7 +288,8 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> MessagesStreamManagerWrapper[Any]: - _materialize_iterables(kwargs) + if capture_content: + _materialize_iterables(kwargs) return MessagesStreamManagerWrapper( wrapped(*args, **kwargs), lambda: _create_invocation( @@ -312,7 +315,8 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> AsyncMessagesStreamManagerWrapper[Any]: - _materialize_iterables(kwargs) + if capture_content: + _materialize_iterables(kwargs) return AsyncMessagesStreamManagerWrapper( wrapped(*args, **kwargs), lambda: _create_invocation( diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py index 73ef1c728..680c2727a 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py @@ -121,6 +121,55 @@ def _assert_weather_tool_definitions(span): ] +_STREAM_SSE_BODY = b"".join( + f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode() + for name, payload in ( + ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + ), + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "ok"}, + }, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 2}, + }, + ), + ("message_stop", {"type": "message_stop"}), + ) +) + + class _AsyncErrorInjectingStreamDelegate: def __init__(self, inner, fail_after=1, error_type=ConnectionError): self._inner = inner @@ -1074,6 +1123,246 @@ def respond(request): ] +@pytest.mark.asyncio +async def test_async_messages_stream_tools_generator_is_recorded( + span_exporter, instrument_with_content +): + """``stream`` serializes the request before the invocation exists. + + The generator has to be frozen in the wrapper, not while the invocation is + built, or the SDK drains it first and the span records no tools. + """ + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + try: + async with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[ + {"role": "user", "content": "What is the weather in SF?"} + ], + tools=(tool for tool in [_WEATHER_TOOL]), + ) as stream: + await stream.until_done() + finally: + await client.close() + + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + _assert_weather_tool_definitions(spans[0]) + + +@pytest.mark.asyncio +async def test_async_messages_stream_messages_generator_is_recorded( + span_exporter, instrument_with_content +): + """``stream`` serializes messages before the invocation exists.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "What is the weather in SF?"} + try: + async with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + ) as stream: + await stream.until_done() + finally: + await client.close() + + assert seen["body"]["messages"] == [user_msg] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_INPUT_MESSAGES + ) + assert input_messages[0]["role"] == "user" + assert input_messages[0]["parts"] == [ + {"type": "text", "content": "What is the weather in SF?"} + ] + + +@pytest.mark.asyncio +async def test_async_messages_stream_system_generator_is_recorded( + span_exporter, instrument_with_content +): + """``stream`` serializes system before the invocation exists.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + system_block = {"type": "text", "text": "You are a helpful assistant."} + try: + async with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "Hello"}], + system=(block for block in [system_block]), + ) as stream: + await stream.until_done() + finally: + await client.close() + + assert seen["body"]["system"] == [system_block] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + system_instructions = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS + ) + assert system_instructions == [ + {"type": "text", "content": "You are a helpful assistant."} + ] + + +@pytest.mark.asyncio +async def test_async_messages_generator_no_content_transparency( + span_exporter, instrument_no_content +): + """When content capture is disabled, generator inputs must reach the SDK + unmaterialized by instrumentation and recorded spans must not contain content attributes.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "Hello without capture"} + system_block = {"type": "text", "text": "System without capture"} + try: + await client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + system=(block for block in [system_block]), + tools=(tool for tool in [_WEATHER_TOOL]), + ) + finally: + await client.close() + + assert seen["body"]["messages"] == [user_msg] + assert seen["body"]["system"] == [system_block] + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS not in span.attributes + assert GenAIAttributes.GEN_AI_TOOL_DEFINITIONS not in span.attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes + + +@pytest.mark.asyncio +async def test_async_messages_stream_generator_no_content_transparency( + span_exporter, instrument_no_content +): + """When content capture is disabled, streaming generator inputs must reach the SDK + unmaterialized by instrumentation and recorded spans must not contain content attributes.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "Stream without capture"} + system_block = {"type": "text", "text": "System stream without capture"} + try: + async with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + system=(block for block in [system_block]), + tools=(tool for tool in [_WEATHER_TOOL]), + ) as stream: + await stream.until_done() + finally: + await client.close() + + assert seen["body"]["messages"] == [user_msg] + assert seen["body"]["system"] == [system_block] + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS not in span.attributes + assert GenAIAttributes.GEN_AI_TOOL_DEFINITIONS not in span.attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes + + @pytest.mark.asyncio @pytest.mark.vcr() @pytest.mark.cassette("test_async_messages_create_captures_tool_use_content") diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py index 65220e584..8fa547823 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py @@ -1609,6 +1609,103 @@ def respond(request): ] +def test_sync_messages_generator_no_content_transparency( + span_exporter, instrument_no_content +): + """When content capture is disabled, generator inputs must reach the SDK + unmaterialized by instrumentation and recorded spans must not contain content attributes.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "Hello without capture"} + system_block = {"type": "text", "text": "System without capture"} + client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + system=(block for block in [system_block]), + tools=(tool for tool in [_WEATHER_TOOL]), + ) + + assert seen["body"]["messages"] == [user_msg] + assert seen["body"]["system"] == [system_block] + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS not in span.attributes + assert GenAIAttributes.GEN_AI_TOOL_DEFINITIONS not in span.attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes + + +def test_sync_messages_stream_generator_no_content_transparency( + span_exporter, instrument_no_content +): + """When content capture is disabled, streaming generator inputs must reach the SDK + unmaterialized by instrumentation and recorded spans must not contain content attributes.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.Client( + transport=_http_lib.MockTransport(respond) + ), + ) + user_msg = {"role": "user", "content": "Stream without capture"} + system_block = {"type": "text", "text": "System stream without capture"} + with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + system=(block for block in [system_block]), + tools=(tool for tool in [_WEATHER_TOOL]), + ) as stream: + stream.until_done() + + assert seen["body"]["messages"] == [user_msg] + assert seen["body"]["system"] == [system_block] + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS not in span.attributes + assert GenAIAttributes.GEN_AI_TOOL_DEFINITIONS not in span.attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes + + @pytest.mark.vcr() @pytest.mark.cassette("test_sync_messages_create_captures_tool_use_content") @pytest.mark.skipif( From d8d249ca3e3c53f5bde97934fd567bf8b5f8d83f Mon Sep 17 00:00:00 2001 From: Nitesh Seni Date: Fri, 9 Oct 2026 13:44:06 +0530 Subject: [PATCH 3/4] chore(anthropic): rename changelog fragment to match PR 865 Assisted-by: Claude Opus 4.6 --- .../.changelog/{825.fixed => 865.fixed} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/{825.fixed => 865.fixed} (100%) diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/825.fixed b/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/865.fixed similarity index 100% rename from instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/825.fixed rename to instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/865.fixed From 873e5ffc123dd72a3100b372d2c6b476689e6770 Mon Sep 17 00:00:00 2001 From: Nitesh Seni Date: Fri, 16 Oct 2026 16:13:48 +0530 Subject: [PATCH 4/4] feat(anthropic): restore lost sync generator tests and confirm async stream coverage --- .../tests/test_sync_messages.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py index 67f463305..9991928f9 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py @@ -1400,6 +1400,98 @@ def respond(request): _assert_weather_tool_definitions(spans[0]) +def test_sync_messages_create_messages_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """A one-shot ``messages`` iterator must still reach the SDK.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + user_msg = {"role": "user", "content": "What is the weather in SF?"} + client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=(msg for msg in [user_msg]), + ) + + assert seen["body"]["messages"] == [user_msg] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_INPUT_MESSAGES + ) + assert input_messages[0]["role"] == "user" + assert input_messages[0]["parts"] == [ + {"type": "text", "content": "What is the weather in SF?"} + ] + + +def test_sync_messages_create_system_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """A one-shot ``system`` iterator must still reach the SDK.""" + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + system_block = {"type": "text", "text": "You are a helpful assistant."} + client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "Hello"}], + system=(block for block in [system_block]), + ) + + assert seen["body"]["system"] == [system_block] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + system_instructions = _load_span_messages( + spans[0], GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS + ) + assert system_instructions == [ + {"type": "text", "content": "You are a helpful assistant."} + ] + + def test_sync_messages_stream_tools_generator_reaches_the_sdk( span_exporter, instrument_with_content ):