diff --git a/.github/instructions/instrumentation.instructions.md b/.github/instructions/instrumentation.instructions.md index 513f637ba..6e56f01c0 100644 --- a/.github/instructions/instrumentation.instructions.md +++ b/.github/instructions/instrumentation.instructions.md @@ -54,6 +54,9 @@ prefer opt-in or additive. Breaking changes need explicit justification in the P - Message content, prompts, and tool call arguments must only be set through the util's content capture path — never as unconditional span/log attributes. - Adding attributes to invocations produced by the util is fine. +- Flag captured request iterators that bypass `IteratorCapture`, eager consumption or replay, + wrapping unrelated arguments, capture when disabled, and snapshots taken before SDK consumption. + See [request property capture](../../AGENTS.md#capturing-properties-that-may-contain-iterators). - Streaming responses must be instrumented by subclassing the util's `SyncStreamWrapper` / `AsyncStreamWrapper` (`opentelemetry.util.genai.stream`). Flag hand-rolled stream wrappers, and invocation-backed wrappers that do not pass the invocation to `super().__init__(stream, invocation)` @@ -109,6 +112,7 @@ Flag, with a link to the rule: - For every public API instrumented, cover sync/async variants when both exist. - Cover streaming and non-streaming variants when both exist. +- Flag missing iterator tests for nested values, partial reads, errors, or file-backed content. - Cover happy path and error scenarios. For error scenarios, at minimum include: provider error / endpoint unavailable, stream interrupted by network, stream closed early by the caller, and a caller-side exception raised inside the `with …stream(…) as stream:` block before the stream is drained. diff --git a/AGENTS.md b/AGENTS.md index 75662ed0f..9373ef629 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,6 +233,32 @@ as the reference: `instrument(completion_hook=…)` override) and ship a `custom_hook.py` example mirroring the OpenAI package. +#### Capturing properties that may contain iterators + +- Use `opentelemetry.util.genai.iterator_capture.IteratorCapture` for captured request properties + that may contain iterators, including nested values. Wrap those properties, not all `kwargs`. + Don't drain or replay them for telemetry. +- Use one instance per named property: `IteratorCapture.from_property(kwargs, "messages", enabled=capture_content)`. + It replaces that property in `kwargs`. Use `capture.extract(extractor)` + after SDK consumption, including failures and lazy stream entry. + The extractor receives a snapshot with `()` for unread iterators and `None` + for disabled or missing properties; `capture.snapshot()` exposes the raw snapshot. +- Preserve file objects. Extend the util for missing support; don't write local wrappers. +- Test nested generators, partial reads, errors, and files across supported sync/async and + streaming variants. + +```python +from opentelemetry.util.genai.iterator_capture import IteratorCapture + +capture = IteratorCapture.from_property( + kwargs, "messages", enabled=capture_content +) +try: + response = sdk_call(**kwargs) +finally: + captured_messages = capture.extract(get_input_messages) +``` + #### Streaming responses A streamed response only finishes once the caller has drained the stream, so the invocation must diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/873.fixed b/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/873.fixed new file mode 100644 index 000000000..e696059ce --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/873.fixed @@ -0,0 +1 @@ +Capture one-shot message, content, system, and tool iterators lazily as the Anthropic SDK consumes them. diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/README.rst b/instrumentation/opentelemetry-instrumentation-genai-anthropic/README.rst index 978a5f7fe..d45cc57e6 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/README.rst +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/README.rst @@ -85,4 +85,3 @@ References * `OpenTelemetry GenAI semantic conventions `_ * `Anthropic SDK (Python) `_ * `Anthropic Documentation `_ - diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-anthropic/pyproject.toml index aef6be05c..3045a9b3f 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "opentelemetry-api ~= 1.43", "opentelemetry-instrumentation >= 0.64b0, <1", "opentelemetry-semantic-conventions >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py index 0b65ae464..342483dc5 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py @@ -120,6 +120,8 @@ def get_input_messages( return [] result: list[InputMessage] = [] for message in messages: + if "role" not in message or "content" not in message: + continue role = message["role"] parts = convert_content_to_parts(message["content"]) result.append(InputMessage(role=role, parts=parts)) 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..056b4fa56 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 @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Callable, Iterator +from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, cast from anthropic.types import Message as AnthropicMessage @@ -16,6 +16,7 @@ ) from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import InferenceInvocation +from opentelemetry.util.genai.iterator_capture import IteratorCapture from ._raw_response import wrap_raw_response from .messages_extractors import ( @@ -96,19 +97,6 @@ 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. - """ - tools = kwargs.get("tools") - if isinstance(tools, Iterator): - kwargs["tools"] = list(cast("Iterator[Any]", tools)) - - def _is_raw_response(result: object) -> bool: """Whether ``result`` is a raw-response object to route through the proxy. @@ -144,15 +132,31 @@ def traced_method( | AnthropicStream[RawMessageStreamEvent] | MessagesStreamWrapper[None] ): - _materialize_tools(kwargs) - invocation = _create_invocation( - handler, instance, args, kwargs, capture_content + messages_capture = IteratorCapture.from_property( + kwargs, "messages", capture_content + ) + system_capture = IteratorCapture.from_property( + kwargs, "system", capture_content ) + tools_capture = IteratorCapture.from_property( + kwargs, "tools", capture_content + ) + invocation = _create_invocation(handler, instance, args, kwargs) try: - # ``Any``: through ``with_raw_response`` / ``with_streaming_response`` - # this returns a response object that ``create``'s annotations do - # not describe, so the checks below cannot be narrowed away. - result: Any = wrapped(*args, **kwargs) + try: + # Raw response variants return types outside create's + # annotations, so Any keeps the checks below reachable. + result: Any = wrapped(*args, **kwargs) + finally: + invocation.input_messages = ( + messages_capture.extract(get_input_messages) or [] + ) + invocation.system_instruction = ( + system_capture.extract(get_system_instruction) or [] + ) + invocation.tool_definitions = tools_capture.extract( + get_tool_definitions + ) except BaseException as exc: invocation.fail(exc) raise @@ -203,13 +207,30 @@ async def traced_method( | AnthropicAsyncStream[RawMessageStreamEvent] | AsyncMessagesStreamWrapper[None] ): - _materialize_tools(kwargs) - invocation = _create_invocation( - handler, instance, args, kwargs, capture_content + messages_capture = IteratorCapture.from_property( + kwargs, "messages", capture_content + ) + system_capture = IteratorCapture.from_property( + kwargs, "system", capture_content ) + tools_capture = IteratorCapture.from_property( + kwargs, "tools", capture_content + ) + invocation = _create_invocation(handler, instance, args, kwargs) try: - # See ``messages_create`` on why this is Any. - result: Any = await wrapped(*args, **kwargs) + try: + # See ``messages_create`` on why this is Any. + result: Any = await wrapped(*args, **kwargs) + finally: + invocation.input_messages = ( + messages_capture.extract(get_input_messages) or [] + ) + invocation.system_instruction = ( + system_capture.extract(get_system_instruction) or [] + ) + invocation.tool_definitions = tools_capture.extract( + get_tool_definitions + ) except BaseException as exc: invocation.fail(exc) raise @@ -239,7 +260,6 @@ def _create_invocation( instance: Messages | AsyncMessages, args: tuple[Any, ...], kwargs: dict[str, Any], - capture_content: bool, ) -> InferenceInvocation: params = extract_params(*args, **kwargs) attributes = get_llm_request_attributes(params, instance) @@ -259,15 +279,6 @@ def _create_invocation( server_address=server_address, server_port=server_port, ) - invocation.input_messages = ( - get_input_messages(params.messages) if capture_content else [] - ) - invocation.system_instruction = ( - get_system_instruction(params.system) if capture_content else [] - ) - invocation.tool_definitions = ( - get_tool_definitions(params.tools) if capture_content else None - ) invocation.attributes = attributes return invocation @@ -284,13 +295,22 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> MessagesStreamManagerWrapper[Any]: - _materialize_tools(kwargs) + messages_capture = IteratorCapture.from_property( + kwargs, "messages", capture_content + ) + system_capture = IteratorCapture.from_property( + kwargs, "system", capture_content + ) + tools_capture = IteratorCapture.from_property( + kwargs, "tools", capture_content + ) return MessagesStreamManagerWrapper( wrapped(*args, **kwargs), - lambda: _create_invocation( - handler, instance, args, kwargs, capture_content - ), + lambda: _create_invocation(handler, instance, args, kwargs), capture_content, + messages_capture=messages_capture, + system_capture=system_capture, + tools_capture=tools_capture, ) return cast( @@ -310,13 +330,22 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> AsyncMessagesStreamManagerWrapper[Any]: - _materialize_tools(kwargs) + messages_capture = IteratorCapture.from_property( + kwargs, "messages", capture_content + ) + system_capture = IteratorCapture.from_property( + kwargs, "system", capture_content + ) + tools_capture = IteratorCapture.from_property( + kwargs, "tools", capture_content + ) return AsyncMessagesStreamManagerWrapper( wrapped(*args, **kwargs), - lambda: _create_invocation( - handler, instance, args, kwargs, capture_content - ), + lambda: _create_invocation(handler, instance, args, kwargs), capture_content, + messages_capture=messages_capture, + system_capture=system_capture, + tools_capture=tools_capture, ) return cast( diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/wrappers.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/wrappers.py index 80af9cc9e..c3c5de8be 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/wrappers.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/wrappers.py @@ -29,7 +29,12 @@ finalize_on_close, ) -from .messages_extractors import set_invocation_response_attributes +from .messages_extractors import ( + get_input_messages, + get_system_instruction, + get_tool_definitions, + set_invocation_response_attributes, +) _logger = logging.getLogger(__name__) @@ -58,6 +63,7 @@ from anthropic.types.parsed_message import ParsedMessage from opentelemetry.util.genai.invocation import InferenceInvocation + from opentelemetry.util.genai.iterator_capture import IteratorCapture ResponseFormatT = TypeVar("ResponseFormatT") @@ -307,9 +313,32 @@ def __init__( manager: MessageStreamManager[ResponseFormatT], invocation_factory: Callable[[], InferenceInvocation], capture_content: bool, + *, + messages_capture: IteratorCapture, + system_capture: IteratorCapture, + tools_capture: IteratorCapture, ): super().__init__(manager, invocation_factory) self._self_capture_content = capture_content + self._self_messages_capture = messages_capture + self._self_system_capture = system_capture + self._self_tools_capture = tools_capture + + def _enter_manager( + self, invocation: InferenceInvocation + ) -> MessageStream[ResponseFormatT]: + try: + return super()._enter_manager(invocation) + finally: + invocation.input_messages = ( + self._self_messages_capture.extract(get_input_messages) or [] + ) + invocation.system_instruction = ( + self._self_system_capture.extract(get_system_instruction) or [] + ) + invocation.tool_definitions = self._self_tools_capture.extract( + get_tool_definitions + ) def _wrap_stream( self, @@ -340,9 +369,32 @@ def __init__( manager: AsyncMessageStreamManager[ResponseFormatT], invocation_factory: Callable[[], InferenceInvocation], capture_content: bool, + *, + messages_capture: IteratorCapture, + system_capture: IteratorCapture, + tools_capture: IteratorCapture, ): super().__init__(manager, invocation_factory) self._self_capture_content = capture_content + self._self_messages_capture = messages_capture + self._self_system_capture = system_capture + self._self_tools_capture = tools_capture + + async def _enter_manager( + self, invocation: InferenceInvocation + ) -> AsyncMessageStream[ResponseFormatT]: + try: + return await super()._enter_manager(invocation) + finally: + invocation.input_messages = ( + self._self_messages_capture.extract(get_input_messages) or [] + ) + invocation.system_instruction = ( + self._self_system_capture.extract(get_system_instruction) or [] + ) + invocation.tool_definitions = self._self_tools_capture.extract( + get_tool_definitions + ) def _wrap_stream( self, diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.oldest.txt index 504210574..688d04bfe 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.oldest.txt @@ -20,3 +20,5 @@ # factor, so they are NOT pinned here — pyproject.toml is the single source of truth. The # OpenTelemetry SDK and test utilities come transitively from opentelemetry-test-util-genai, which # every oldest env installs. Pin here only test-only deps that nothing else already provides. + +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_wrappers.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_wrappers.py index 548041c0d..666538e3b 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_wrappers.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_wrappers.py @@ -15,6 +15,7 @@ gen_ai_attributes as GenAIAttributes, ) from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.iterator_capture import IteratorCapture def _make_invocation(): @@ -360,6 +361,15 @@ def test_sync_manager_enter_constructs_stream_wrapper(): manager=_FakeSyncManager(stream=stream), invocation_factory=_make_invocation, capture_content=False, + messages_capture=IteratorCapture.from_property( + {}, "messages", enabled=False + ), + system_capture=IteratorCapture.from_property( + {}, "system", enabled=False + ), + tools_capture=IteratorCapture.from_property( + {}, "tools", enabled=False + ), ) with wrapper as result: @@ -376,6 +386,15 @@ def test_sync_manager_does_not_create_invocation_until_enter(): factory_calls.append(True) or _make_invocation() ), capture_content=False, + messages_capture=IteratorCapture.from_property( + {}, "messages", enabled=False + ), + system_capture=IteratorCapture.from_property( + {}, "system", enabled=False + ), + tools_capture=IteratorCapture.from_property( + {}, "tools", enabled=False + ), ) assert not factory_calls @@ -538,6 +557,15 @@ async def test_async_manager_enter_constructs_async_stream_wrapper(): manager=_FakeAsyncManager(stream=stream), invocation_factory=_make_invocation, capture_content=False, + messages_capture=IteratorCapture.from_property( + {}, "messages", enabled=False + ), + system_capture=IteratorCapture.from_property( + {}, "system", enabled=False + ), + tools_capture=IteratorCapture.from_property( + {}, "tools", enabled=False + ), ) async with wrapper as result: diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_iterator_transparency.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_iterator_transparency.py new file mode 100644 index 000000000..34158052d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_iterator_transparency.py @@ -0,0 +1,283 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import json +from collections.abc import Iterator +from io import BytesIO +from typing import Any +from unittest.mock import MagicMock + +import pytest +from anthropic import Anthropic, AsyncAnthropic + +try: + import httpx2 as httpx +except ImportError: + import httpx + +from opentelemetry.instrumentation.genai.anthropic import patch, wrappers +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) + +from .test_async_wrappers import ( + _FakeAsyncManager, + _FakeAsyncStream, + _FakeSyncManager, + _FakeSyncStream, +) +from .test_sync_messages import ( + _STREAM_SSE_BODY, + _build_message, + _load_span_messages, +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "wrapper_name", + [ + "messages_create", + "async_messages_create", + "messages_stream", + "async_messages_stream", + ], +) +@pytest.mark.parametrize("sdk_failure", [False, True]) +async def test_extraction_failure_preserves_sdk_outcome( + monkeypatch: pytest.MonkeyPatch, wrapper_name: str, sdk_failure: bool +) -> None: + extractor = MagicMock(side_effect=ValueError("extraction failed")) + monkeypatch.setattr(patch, "get_input_messages", extractor) + monkeypatch.setattr(wrappers, "get_input_messages", extractor) + handler = MagicMock() + handler.should_capture_content.return_value = True + error = RuntimeError("SDK failed") + asynchronous = wrapper_name.startswith("async") + streaming = wrapper_name.endswith("stream") + stream = _FakeAsyncStream() if asynchronous else _FakeSyncStream() + manager_type = _FakeAsyncManager if asynchronous else _FakeSyncManager + response = object() + message = {"role": "user", "content": "Hi"} + kwargs = { + "messages": iter([message]), + "system": [{"type": "text", "text": "Be brief"}], + } + + def sdk_call(**kwargs: Any) -> Any: + assert list(kwargs["messages"]) == [message] + if streaming: + return manager_type( + stream, enter_error=error if sdk_failure else None + ) + if sdk_failure: + raise error + return response + + async def async_sdk_call(**kwargs: Any) -> Any: + return sdk_call(**kwargs) + + wrapper = getattr(patch, wrapper_name)(handler) + + async def invoke() -> Any: + if wrapper_name == "async_messages_create": + return await wrapper(async_sdk_call, MagicMock(), (), kwargs) + result = wrapper(sdk_call, MagicMock(), (), kwargs) + if streaming: + if asynchronous: + async with result as wrapped_stream: + assert wrapped_stream.__wrapped__ is stream + else: + with result as wrapped_stream: + assert wrapped_stream.__wrapped__ is stream + return result + + invocation = handler.inference.return_value + if sdk_failure: + with pytest.raises(RuntimeError) as caught: + await invoke() + assert caught.value is error + invocation.fail.assert_called_once_with(error) + else: + result = await invoke() + if not streaming: + assert result is response + invocation.fail.assert_not_called() + + extractor.assert_called_once_with([message]) + assert invocation.input_messages == [] + assert invocation.system_instruction[0].content == "Be brief" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("capture_content", [False, True]) +@pytest.mark.parametrize("iterator_failure", [False, True]) +@pytest.mark.parametrize( + "wrapper_name", + [ + "messages_create", + "async_messages_create", + "messages_stream", + "async_messages_stream", + ], +) +async def test_wrappers_leave_iterators_for_sdk( + wrapper_name: str, capture_content: bool, iterator_failure: bool +) -> None: + handler = MagicMock() + handler.should_capture_content.return_value = capture_content + consumed: list[str] = [] + error = ValueError("SDK request failed") + + def generate(name: str, value: Any) -> Iterator[Any]: + consumed.append(name) + if iterator_failure: + raise error + yield value + + inputs = { + "messages": generate("messages", {"role": "user", "content": "Hi"}), + "system": generate("system", {"type": "text", "text": "Be brief"}), + "tools": generate("tools", {"name": "weather", "input_schema": {}}), + } + other_kwargs = { + "stop_sequences": iter(["END"]), + "extra_body": {"custom": iter(["value"])}, + } + + def sdk_call(*args: Any, **kwargs: Any) -> Any: + assert consumed == [] + for key, value in other_kwargs.items(): + assert kwargs[key] is value + for key, value in inputs.items(): + if not capture_content: + assert kwargs[key] is value + assert isinstance(kwargs[key], type(value)) + assert next(kwargs[key]) + raise error + + async def async_sdk_call(*args: Any, **kwargs: Any) -> Any: + return sdk_call(*args, **kwargs) + + wrapper = getattr(patch, wrapper_name)(handler) + with pytest.raises(ValueError) as caught: + if wrapper_name == "async_messages_create": + await wrapper( + async_sdk_call, MagicMock(), (), {**inputs, **other_kwargs} + ) + else: + wrapper(sdk_call, MagicMock(), (), {**inputs, **other_kwargs}) + assert caught.value is error + assert consumed == ( + ["messages"] if iterator_failure else ["messages", "system", "tools"] + ) + if wrapper_name.endswith("create"): + handler.inference.return_value.fail.assert_called_once_with(error) + if capture_content and not iterator_failure: + invocation = handler.inference.return_value + assert invocation.input_messages[0].parts[0].content == "Hi" + assert invocation.system_instruction[0].content == "Be brief" + assert invocation.tool_definitions[0].name == "weather" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("message_iterator", [False, True]) +@pytest.mark.parametrize("block_type", ["text", "image", "document"]) +async def test_sdk_consumes_and_captures_request_iterators( + span_exporter: Any, + instrument_with_content: Any, + asynchronous: bool, + streaming: bool, + message_iterator: bool, + block_type: str, +) -> None: + seen: list[dict[str, Any]] = [] + blocks: list[dict[str, Any]] = [{"type": "text", "text": "Hello"}] + expected_blocks = blocks + expected_parts = [{"type": "text", "content": "Hello"}] + if block_type != "text": + source = { + "type": "base64", + "media_type": ( + "image/png" if block_type == "image" else "application/pdf" + ), + } + blocks = [ + { + "type": block_type, + "source": {**source, "data": BytesIO(b"content")}, + } + ] + expected_blocks = [ + { + "type": block_type, + "source": {**source, "data": "Y29udGVudA=="}, + } + ] + expected_parts = [{"type": "blob"}] + system = [{"type": "text", "text": "Be brief"}] + tools = [{"name": "weather", "input_schema": {"type": "object"}}] + messages = [{"role": "user", "content": iter(blocks)}] + kwargs = { + "model": "claude-sonnet-4-20250514", + "max_tokens": 32, + "messages": iter(messages) if message_iterator else messages, + "system": iter(system), + "tools": iter(tools), + } + + def respond(request: Any) -> Any: + seen.append(json.loads(request.content)) + if streaming: + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + return httpx.Response(200, json=_build_message().model_dump()) + + if asynchronous: + async with AsyncAnthropic( + api_key="test", + http_client=httpx.AsyncClient( + transport=httpx.MockTransport(respond) + ), + ) as client: + if streaming: + async with client.messages.stream(**kwargs) as stream: + await stream.until_done() + else: + await client.messages.create(**kwargs) + else: + with Anthropic( + api_key="test", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) as client: + if streaming: + with client.messages.stream(**kwargs) as stream: + stream.until_done() + else: + client.messages.create(**kwargs) + + assert len(seen) == 1 + assert seen[0]["messages"] == [ + {"role": "user", "content": expected_blocks} + ] + assert seen[0]["system"] == system + assert seen[0]["tools"] == tools + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + captured_messages = _load_span_messages( + spans[0], GenAI.GEN_AI_INPUT_MESSAGES + ) + assert captured_messages[0]["parts"] == expected_parts + system_instructions = _load_span_messages( + spans[0], GenAI.GEN_AI_SYSTEM_INSTRUCTIONS + ) + assert system_instructions == [{"type": "text", "content": "Be brief"}] + tool_definitions = _load_span_messages( + spans[0], GenAI.GEN_AI_TOOL_DEFINITIONS + ) + assert tool_definitions[0]["name"] == "weather" diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py index 3eb56a8ee..2d1270c03 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py @@ -39,6 +39,29 @@ ) +@pytest.mark.parametrize( + "invalid", + [ + {}, + {"role": "user"}, + {"content": "Hello"}, + ], +) +def test_input_messages_skip_missing_fields(invalid: dict) -> None: + assert get_input_messages([invalid]) == [] + messages = get_input_messages( + [ + {"role": "user", "content": "First"}, + invalid, + {"role": "user", "content": "Last"}, + ] + ) + assert [message.parts for message in messages] == [ + [TextPart(content="First")], + [TextPart(content="Last")], + ] + + def test_extract_params_reads_sampling_params_from_extra_body(): params = extract_params( extra_body={"temperature": 0.7, "top_p": 0.9, "top_k": 40} 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..67f463305 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py @@ -533,6 +533,7 @@ def test_sync_messages_create_captures_multimodal_content( def test_sync_messages_create_preserves_generator_document_content( instrument_with_content, + span_exporter, ): received_content = None @@ -586,6 +587,15 @@ def document_content(): {"type": "text", "text": "First"}, {"type": "text", "text": "Second"}, ] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + messages = json.loads( + spans[0].attributes[GenAIAttributes.GEN_AI_INPUT_MESSAGES] + ) + assert messages[0]["parts"] == [ + {"type": "text", "content": "First"}, + {"type": "text", "content": "Second"}, + ] @pytest.mark.vcr() @@ -1390,14 +1400,9 @@ def respond(request): _assert_weather_tool_definitions(spans[0]) -def test_sync_messages_stream_tools_generator_is_recorded( +def test_sync_messages_stream_tools_generator_reaches_the_sdk( 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): diff --git a/util/opentelemetry-util-genai/.changelog/873.added b/util/opentelemetry-util-genai/.changelog/873.added new file mode 100644 index 000000000..71aa926f4 --- /dev/null +++ b/util/opentelemetry-util-genai/.changelog/873.added @@ -0,0 +1 @@ +Add IteratorCapture to observe nested request iterators lazily and expose snapshots for telemetry extraction. diff --git a/util/opentelemetry-util-genai/README.rst b/util/opentelemetry-util-genai/README.rst index 6e61193f1..7d8be4e0c 100644 --- a/util/opentelemetry-util-genai/README.rst +++ b/util/opentelemetry-util-genai/README.rst @@ -204,6 +204,47 @@ For embedding invocations, the following attributes are set: - ``gen_ai.usage.input_tokens``: Int(24) +Capturing request iterators +--------------------------- + +Use ``IteratorCapture.from_property()`` for a named request property. It replaces +that property's value in ``kwargs`` and records values as the SDK reads them. +Disabled captures and missing properties leave ``kwargs`` untouched and return +``None`` from ``snapshot()``. + +.. code-block:: python + + from opentelemetry.util.genai.iterator_capture import IteratorCapture + + messages_capture = IteratorCapture.from_property( + kwargs, "messages", enabled=capture_content + ) + try: + response = sdk_call(**kwargs) + finally: + messages = messages_capture.extract(get_input_messages) + +For a bare value, pass ``capture.wrapped`` to the SDK: + +.. code-block:: python + + capture = IteratorCapture(messages, enabled=capture_content) + try: + response = sdk_call(messages=capture.wrapped) + finally: + messages = capture.extract(get_input_messages) + +``extract(extractor)`` calls the instrumentation's extractor with a snapshot, +using ``()`` for unread iterators and ``None`` for disabled or missing properties. +The extractor must accept that shape and return the telemetry content. +``extract()`` logs snapshot or conversion errors and returns ``None``. Use +``snapshot()`` directly when no conversion is needed. + +Nested built-in dictionaries, lists, and tuples are supported. File objects +pass through unchanged. Snapshots contain only the consumed portion of each +iterator; async iterators and custom containers are not captured. + + Installation ------------ diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/iterator_capture.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/iterator_capture.py new file mode 100644 index 000000000..ea187a040 --- /dev/null +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/iterator_capture.py @@ -0,0 +1,260 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Record request iterator values as the SDK consumes them.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Iterator, MutableMapping, Sequence +from io import IOBase +from typing import TYPE_CHECKING, TypeVar, cast + +if TYPE_CHECKING: + + class _ObjectProxy: + __wrapped__: object + + def __init__(self, wrapped: object) -> None: ... + +else: + from wrapt import ObjectProxy as _ObjectProxy + +_logger = logging.getLogger(__name__) +_InputT = TypeVar("_InputT") +_ResultT = TypeVar("_ResultT") +UNCONSUMED: object = object() +"""Snapshot placeholder for an iterator the SDK has not read.""" + + +class _Node: + """A captured request value, with depth capped to avoid recursion errors.""" + + _MAX_DEPTH = 64 + + @staticmethod + def capture(value: object, ancestors: set[int], depth: int = 0) -> _Node: + if depth >= _Node._MAX_DEPTH: + return _Node(value, unavailable=True) + if isinstance(value, IOBase): + return _Node(value) + if isinstance(value, Iterator): + return _IteratorNode(cast("Iterator[object]", value), depth) + if type(value) not in (dict, list, tuple): + return _Node(value) + value_id = id(value) + if value_id in ancestors: + return _Node(value, unavailable=True) + ancestors.add(value_id) + try: + if type(value) is dict: + mapping = cast("dict[object, object]", value) + children = { + key: _Node.capture(item, ancestors, depth + 1) + for key, item in mapping.items() + } + wrapped = { + key: child.wrapped for key, child in children.items() + } + return _Node( + mapping + if all( + wrapped[key] is item for key, item in mapping.items() + ) + else wrapped, + children, + ) + sequence = cast("Sequence[object]", value) + items = [ + _Node.capture(item, ancestors, depth + 1) for item in sequence + ] + wrapped_items = [item.wrapped for item in items] + wrapped_sequence: object = value + if any( + new is not old for new, old in zip(wrapped_items, sequence) + ): + wrapped_sequence = ( + tuple(wrapped_items) + if type(value) is tuple + else wrapped_items + ) + return _Node(wrapped_sequence, items) + finally: + ancestors.remove(value_id) + + def __init__( + self, + wrapped: object, + children: dict[object, _Node] | list[_Node] | None = None, + *, + unavailable: bool = False, + ) -> None: + self.wrapped: object = wrapped + self.children: dict[object, _Node] | list[_Node] | None = children + self.unavailable: bool = unavailable + + @property + def complete(self) -> bool: + if self.unavailable: + return False + children = self.children + if isinstance(children, dict): + return all(child.complete for child in children.values()) + if isinstance(children, list): + return all(child.complete for child in children) + return True + + def snapshot(self, unconsumed: object) -> object: + if self.unavailable: + return unconsumed + children = self.children + if isinstance(children, dict): + return { + key: child.snapshot(unconsumed) + for key, child in children.items() + } + if isinstance(children, list): + items = [child.snapshot(unconsumed) for child in children] + return tuple(items) if isinstance(self.wrapped, tuple) else items + return self.wrapped + + +class _IteratorNode(_Node): + def __init__(self, iterator: Iterator[object], depth: int) -> None: + super().__init__(iterator) + self.depth: int = depth + self.items: list[_Node] = [] + self.started: bool = False + self.exhausted: bool = False + self.wrapped = _RecordingIterator(iterator, self) + + @property + def complete(self) -> bool: + return self.exhausted and all(item.complete for item in self.items) + + def snapshot(self, unconsumed: object) -> object: + if not self.started: + return unconsumed + return [item.snapshot(unconsumed) for item in self.items] + + +class _RecordingIterator(_ObjectProxy): + _self_node: _IteratorNode + + def __init__( + self, iterator: Iterator[object], node: _IteratorNode + ) -> None: + super().__init__(iterator) + self._self_node = node + + def __iter__(self) -> _RecordingIterator: + return self + + def __next__(self) -> object: + iterator = cast("Iterator[object]", self.__wrapped__) + return self._self_advance(lambda: next(iterator)) + + def send(self, value: object) -> object: + send = cast( + "Callable[[object], object]", getattr(self.__wrapped__, "send") + ) + return self._self_advance(lambda: send(value)) + + def throw(self, *args: object) -> object: + throw = cast( + "Callable[..., object]", getattr(self.__wrapped__, "throw") + ) + return self._self_advance(lambda: throw(*args)) + + def _self_advance(self, advance: Callable[[], object]) -> object: + node = self._self_node + node.started = True + try: + item = advance() + except StopIteration: + node.exhausted = True + raise + # Snapshot before the next advance: generators can mutate and yield + # the same dictionary repeatedly. + captured = _Node.capture(item, set(), node.depth + 1) + node.items.append(captured) + return captured.wrapped + + +class IteratorCapture: + """Capture input iterators as the SDK consumes them. + + Wrap synchronous iterators in the input, including those + nested in built-in dicts, lists, and tuples. Copy containers that need + wrappers; leave the originals, files, and other objects untouched. + Store consumed values in memory, copying each yielded container. + + Read ``snapshot()`` or ``extract()`` after the SDK call, including on + failure. Iterator exceptions propagate unchanged. Disabled captures pass + the input through unchanged and return ``None`` snapshots. + """ + + def __init__( + self, + value: object, + enabled: bool = True, + ) -> None: + self.property_name: str | None = None + self._node: _Node | None = ( + _Node.capture(value, set()) if enabled else None + ) + self.wrapped: object = self._node.wrapped if self._node else value + + @classmethod + def from_property( + cls, + properties: MutableMapping[str, object], + property_name: str, + enabled: bool = True, + ) -> IteratorCapture: + """Replace one property's value; leave missing properties absent.""" + present = property_name in properties + capture = cls(properties.get(property_name), enabled and present) + capture.property_name = property_name + if enabled and present: + properties[property_name] = capture.wrapped + return capture + + @property + def complete(self) -> bool: + """True if capture succeeded and all captured iterators are exhausted.""" + return self._node is not None and self._node.complete + + def snapshot(self, *, unconsumed: object = UNCONSUMED) -> object: + """Return a snapshot without advancing iterators. + + Each iterator becomes a list of items consumed so far. Unread + iterators, cycles, and values beyond the depth limit become + ``unconsumed``. + Empty iterators become ``[]`` after the SDK reads them. + Disabled or missing properties return ``None``. + """ + return self._node.snapshot(unconsumed) if self._node else None + + def extract( + self, extractor: Callable[[_InputT | None], _ResultT] + ) -> _ResultT | None: + """Pass a snapshot to ``extractor`` and return its result. + + The extractor must accept lists for consumed iterators, ``()`` for + unread or unavailable values, and ``None`` for disabled or missing + properties. Log snapshot or conversion errors and return ``None``. + """ + try: + snapshot = cast("_InputT | None", self.snapshot(unconsumed=())) + return extractor(snapshot) + except Exception: + # Extractors should handle user input defensively, but cannot guard + # against every unexpected value or custom object. Extraction often + # runs in finally blocks and its errors must not hide SDK failures. + _logger.debug( + "Failed to extract %s", + self.property_name or "captured input", + exc_info=True, + ) + return None diff --git a/util/opentelemetry-util-genai/tests/test_iterator_capture.py b/util/opentelemetry-util-genai/tests/test_iterator_capture.py new file mode 100644 index 000000000..02ccd3751 --- /dev/null +++ b/util/opentelemetry-util-genai/tests/test_iterator_capture.py @@ -0,0 +1,413 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +import logging +from collections.abc import Generator, Iterable, Iterator, Sequence +from io import BytesIO, StringIO +from pathlib import Path +from typing import Any + +import pytest + +from opentelemetry.util.genai.iterator_capture import ( + UNCONSUMED, + IteratorCapture, +) + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_bare_iterator_capture(enabled: bool) -> None: + source = iter(["first", "second"]) + capture = IteratorCapture(source, enabled=enabled) + assert capture.property_name is None + if not enabled: + assert capture.wrapped is source + assert capture.snapshot() is (UNCONSUMED if enabled else None) + assert next(capture.wrapped) == "first" + assert capture.snapshot() == (["first"] if enabled else None) + assert list(capture.wrapped) == ["second"] + assert capture.complete is enabled + + +def test_bare_nested_value_preserves_original_container() -> None: + source = {"content": iter(["Hello"])} + capture = IteratorCapture(source) + assert list(capture.wrapped["content"]) == ["Hello"] + assert capture.snapshot() == {"content": ["Hello"]} + assert source["content"] is not capture.wrapped["content"] + + +@pytest.mark.parametrize( + "state", ["disabled", "missing", "none", "unread", "empty"] +) +def test_extract_converts_snapshot_defaults(state: str) -> None: + source = iter(["Hello"] if state != "empty" else []) + kwargs: dict[str, Any] = {"messages": source} + if state == "missing": + kwargs.clear() + elif state == "none": + kwargs["messages"] = None + capture = IteratorCapture.from_property( + kwargs, "messages", enabled=state != "disabled" + ) + if state == "empty": + assert list(kwargs["messages"]) == [] + + def extract(values: Iterable[str] | None) -> str: + return "absent" if values is None else ",".join(values) + + expected = "absent" if state in ("disabled", "missing", "none") else "" + assert capture.extract(extract) == expected + if state != "empty": + assert next(source) == "Hello" + + +def test_extract_converts_partial_nested_input_after_failure() -> None: + error = ValueError("request failed") + unread = iter(["unread"]) + + def generate() -> Iterator[dict[str, Iterator[str]]]: + yield {"content": iter(["first", "second"]), "other": unread} + raise error + + kwargs = {"messages": generate()} + capture = IteratorCapture.from_property(kwargs, "messages") + message = next(kwargs["messages"]) + assert next(message["content"]) == "first" + with pytest.raises(ValueError) as caught: + next(kwargs["messages"]) + assert caught.value is error + + def extract( + messages: Iterable[dict[str, Sequence[str]]] | None, + ) -> list[str]: + return [ + text + for message in messages or () + for content in message.values() + for text in content + ] + + assert capture.extract(extract) == ["first"] + assert not capture.complete + assert next(message["content"]) == "second" + assert next(unread) == "unread" + + +@pytest.mark.parametrize("failure", ["snapshot", "extractor"]) +def test_extract_errors_return_none( + failure: str, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + capture = IteratorCapture.from_property({"messages": [1]}, "messages") + + def extract(value: object) -> str: + raise ValueError("invalid content") + + if failure == "snapshot": + + def fail_snapshot(*, unconsumed: object) -> object: + raise ValueError("invalid snapshot") + + monkeypatch.setattr(capture, "snapshot", fail_snapshot) + + with caplog.at_level(logging.DEBUG): + assert capture.extract(extract) is None + assert "Failed to extract messages" in caplog.text + + +def test_extract_does_not_swallow_cancellation() -> None: + capture = IteratorCapture.from_property({}, "messages") + error = asyncio.CancelledError() + + def extract(value: object) -> str: + raise error + + with pytest.raises(asyncio.CancelledError) as caught: + capture.extract(extract) + assert caught.value is error + + +def test_disabled_capture_leaves_request_untouched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_capture(value: object, ancestors: set[int]) -> None: + pytest.fail("disabled capture must not inspect request values") + + monkeypatch.setattr( + "opentelemetry.util.genai.iterator_capture._Node.capture", fail_capture + ) + messages = {"content": iter(["Hello"])} + kwargs = {"messages": messages} + capture = IteratorCapture.from_property(kwargs, "messages", enabled=False) + assert kwargs["messages"] is messages + assert capture.snapshot() is None + assert not capture.complete + + +def test_missing_property_is_not_added() -> None: + kwargs: dict[str, object] = {} + capture = IteratorCapture.from_property(kwargs, "messages") + assert kwargs == {} + assert capture.snapshot() is None + assert not capture.complete + + +def test_captures_are_independent_and_leave_other_properties_untouched() -> ( + None +): + messages = iter(["Hello", "Goodbye"]) + tools = iter(["weather"]) + unrelated = iter(["stop"]) + kwargs = {"messages": messages, "tools": tools, "stop": unrelated} + messages_capture = IteratorCapture.from_property(kwargs, "messages") + tools_capture = IteratorCapture.from_property(kwargs, "tools") + + assert messages_capture.property_name == "messages" + assert tools_capture.property_name == "tools" + assert kwargs["stop"] is unrelated + assert next(kwargs["messages"]) == "Hello" + assert messages_capture.snapshot() == ["Hello"] + assert tools_capture.snapshot() is UNCONSUMED + assert list(kwargs["tools"]) == ["weather"] + assert tools_capture.snapshot() == ["weather"] + assert tools_capture.complete + assert not messages_capture.complete + + +@pytest.mark.parametrize("on_disk", [False, True]) +@pytest.mark.parametrize("binary", [False, True]) +def test_file_objects_are_preserved( + tmp_path: Path, on_disk: bool, binary: bool +) -> None: + path = tmp_path / "content.txt" + path.write_bytes(b"content") + if on_disk: + data = path.open("rb" if binary else "r") + else: + data = BytesIO(b"content") if binary else StringIO("content") + with data: + kwargs = {"value": {"data": data}} + capture = IteratorCapture.from_property(kwargs, "value") + assert kwargs["value"]["data"] is data + assert data.tell() == 0 + assert capture.snapshot(unconsumed=()) == {"data": data} + assert kwargs["value"]["data"].read() == ( + b"content" if binary else "content" + ) + assert capture.snapshot(unconsumed=()) == {"data": data} + assert capture.complete + + +def test_capture_is_lazy_and_distinguishes_empty_from_unread() -> None: + reads: list[str] = [] + + def generate() -> Iterator[str]: + reads.append("read") + yield from () + + kwargs = {"value": generate()} + capture = IteratorCapture.from_property(kwargs, "value") + assert reads == [] + assert capture.snapshot() is UNCONSUMED + assert capture.snapshot(unconsumed=()) == () + assert not capture.complete + assert list(kwargs["value"]) == [] + assert reads == ["read"] + assert capture.snapshot() == [] + assert capture.complete + + +def test_nested_capture_preserves_containers_and_partial_input() -> None: + inner = iter(["first", "second"]) + source = {"arbitrary": [{"payload": (inner,)}]} + kwargs = {"value": source} + capture = IteratorCapture.from_property(kwargs, "value") + wrapped = kwargs["value"]["arbitrary"][0]["payload"][0] + assert isinstance(wrapped, type(inner)) + assert wrapped.__class__ is inner.__class__ + assert source["arbitrary"][0]["payload"][0] is inner + assert capture.snapshot() == {"arbitrary": [{"payload": (UNCONSUMED,)}]} + assert next(wrapped) == "first" + assert capture.snapshot() == {"arbitrary": [{"payload": (["first"],)}]} + assert not capture.complete + assert list(wrapped) == ["second"] + assert capture.complete + assert capture.snapshot() == { + "arbitrary": [{"payload": (["first", "second"],)}] + } + + +def test_yielded_mutable_objects_are_snapshotted_per_item() -> None: + shared: dict[str, object] = {"value": [0]} + + def generate() -> Iterator[dict[str, object]]: + yield shared + shared["value"] = [1] + yield shared + shared.clear() + + kwargs = {"value": generate()} + capture = IteratorCapture.from_property(kwargs, "value") + iterator = kwargs["value"] + assert next(iterator) is shared + assert next(iterator) is shared + assert list(iterator) == [] + assert capture.snapshot() == [{"value": [0]}, {"value": [1]}] + + +def test_nested_iterators_yielded_by_iterators_stay_lazy() -> None: + reads: list[str] = [] + + def inner() -> Iterator[str]: + reads.append("inner") + yield "value" + + kwargs = {"value": iter([{"data": inner()}])} + capture = IteratorCapture.from_property(kwargs, "value") + item = next(kwargs["value"]) + assert reads == [] + assert capture.snapshot() == [{"data": UNCONSUMED}] + assert list(item["data"]) == ["value"] + assert capture.snapshot() == [{"data": ["value"]}] + assert not capture.complete + assert list(kwargs["value"]) == [] + assert capture.complete + + +@pytest.mark.parametrize( + "error", [ValueError("iterator failed"), asyncio.CancelledError()] +) +def test_iterator_errors_propagate_unchanged(error: BaseException) -> None: + def generate() -> Iterator[str]: + yield "first" + raise error + + kwargs = {"value": generate()} + capture = IteratorCapture.from_property(kwargs, "value") + assert next(kwargs["value"]) == "first" + with pytest.raises(type(error)) as caught: + next(kwargs["value"]) + assert caught.value is error + assert capture.snapshot() == ["first"] + assert not capture.complete + + +def test_generator_protocol_and_return_value() -> None: + closed: list[bool] = [] + + def generate() -> Generator[object, object, str]: + try: + received = yield "first" + try: + yield received + except ValueError: + yield "handled" + return "finished" + finally: + closed.append(True) + + kwargs = {"value": generate()} + capture = IteratorCapture.from_property(kwargs, "value") + assert next(kwargs["value"]) == "first" + assert kwargs["value"].send("sent") == "sent" + assert kwargs["value"].throw(ValueError()) == "handled" + with pytest.raises(StopIteration) as caught: + next(kwargs["value"]) + assert caught.value.value == "finished" + assert capture.snapshot() == ["first", "sent", "handled"] + assert capture.complete + assert closed == [True] + + +def test_close_does_not_drain_input() -> None: + def generate() -> Iterator[int]: + yield 1 + pytest.fail("close must not advance the iterator") + + kwargs = {"value": generate()} + capture = IteratorCapture.from_property(kwargs, "value") + assert next(kwargs["value"]) == 1 + kwargs["value"].close() + assert capture.snapshot() == [1] + assert not capture.complete + + +def test_repeated_iterator_reference_does_not_replay_items() -> None: + iterator = iter([1, 2]) + kwargs = {"value": [iterator, iterator]} + capture = IteratorCapture.from_property(kwargs, "value") + assert list(kwargs["value"][0]) == [1, 2] + assert list(kwargs["value"][1]) == [] + assert capture.snapshot() == [[1, 2], []] + + +def test_plain_containers_are_not_replaced() -> None: + source = {"values": [1, {"value": "text"}]} + kwargs = {"value": source} + capture = IteratorCapture.from_property(kwargs, "value") + assert kwargs["value"] is source + snapshot = capture.snapshot() + source["values"].clear() + assert snapshot == {"values": [1, {"value": "text"}]} + assert capture.snapshot() == snapshot + + +def test_opaque_objects_are_not_inspected() -> None: + class Opaque: + def model_dump(self) -> None: + pytest.fail("capture must not invoke model methods") + + def __iter__(self) -> Iterator[object]: + pytest.fail("capture must not iterate unknown containers") + + value = Opaque() + kwargs = {"value": value} + capture = IteratorCapture.from_property(kwargs, "value") + assert kwargs["value"] is value + assert capture.snapshot() is value + + +@pytest.mark.parametrize("kind", ["dict", "list", "iterator"]) +def test_deep_input_is_preserved_and_snapshot_is_bounded(kind: str) -> None: + value: Any = "leaf" + for _ in range(2000): + if kind == "dict": + value = {"child": value} + elif kind == "iterator": + value = iter([value]) + else: + value = [value] + + kwargs = {"value": value} + capture = IteratorCapture.from_property(kwargs, "value") + current = kwargs["value"] + for _ in range(2000): + if kind == "iterator": + child = next(current) + assert next(current, UNCONSUMED) is UNCONSUMED + current = child + else: + current = current["child"] if kind == "dict" else current[0] + assert current == "leaf" + + snapshot = capture.snapshot() + depth = 0 + while isinstance(snapshot, (dict, list)): + snapshot = snapshot["child"] if kind == "dict" else snapshot[0] + depth += 1 + assert snapshot is UNCONSUMED + assert 0 < depth < 2000 + assert not capture.complete + + +def test_cycles_do_not_break_the_request() -> None: + value: list[Any] = [] + value.append(value) + kwargs = {"value": value} + capture = IteratorCapture.from_property(kwargs, "value") + assert kwargs["value"] is value + assert capture.snapshot() == [UNCONSUMED] + assert not capture.complete