From 879c9775b89b4f485a14c379f85054261af7a932 Mon Sep 17 00:00:00 2001 From: Niranjan Date: Thu, 1 Oct 2026 23:24:58 +0530 Subject: [PATCH 1/3] fix(openai): materialize generator messages/tools before wrapped call Fixes #825. Prevents one-shot iterables from being consumed during content capture, which left the SDK request empty. - Materialize messages and tools to list before consumption - Update kwargs so the wrapped call sees the same materialized inputs --- .../instrumentation/genai/openai/utils.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py b/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py index 19448c50d..23708cc29 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py @@ -185,12 +185,24 @@ def create_chat_invocation( ) if capture_content: # optimization - invocation.input_messages = _prepare_input_messages( - kwargs.get("messages", []) - ) - invocation.tool_definitions = _prepare_tool_definitions( - kwargs.get("tools") - ) + # Materialize one-shot iterables so they aren't consumed before + # the wrapped API call (fixes #825). + messages_raw = kwargs.get("messages", []) + if messages_raw is not None and not isinstance(messages_raw, (list, tuple)): + try: + messages_raw = list(messages_raw) + except TypeError: + pass + tools_raw = kwargs.get("tools") + if tools_raw is not None and not isinstance(tools_raw, (list, tuple)): + try: + tools_raw = list(tools_raw) + except TypeError: + pass + # Update kwargs so the wrapped call also sees materialized inputs + kwargs = {**kwargs, "messages": messages_raw, "tools": tools_raw} + invocation.input_messages = _prepare_input_messages(messages_raw) + invocation.tool_definitions = _prepare_tool_definitions(tools_raw) return invocation From d52ffca6aeb92e16ff6e37e1ca03e629427b3a45 Mon Sep 17 00:00:00 2001 From: Niranjan Date: Thu, 1 Oct 2026 23:43:37 +0530 Subject: [PATCH 2/3] fix(openai): propagate materialized messages/tools to SDK call (#825) - Materialize generator/map-backed messages and tools in patch.py traced_method before passing to create_chat_invocation and wrapped. - Reverts incorrect local-only kwargs fix in utils.py. - Adds regression tests for message and tool generators. Fixes #825 --- .../instrumentation/genai/openai/patch.py | 32 +++++++++++++++++++ .../instrumentation/genai/openai/utils.py | 24 ++++---------- .../tests/test_issue_825_regression.py | 15 +++++++++ 3 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/patch.py b/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/patch.py index 3782ca61c..9c5eb1afc 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/patch.py @@ -63,6 +63,22 @@ def chat_completions_create_v_new( capture_content = handler.should_capture_content() def traced_method(wrapped, instance, args, kwargs): + # Materialize generator/map-backed messages/tools before they are + # consumed by either instrumentation or the SDK (fixes #825). + messages_raw = kwargs.get("messages") + if messages_raw is not None and not isinstance(messages_raw, (list, tuple)): + try: + messages_raw = list(messages_raw) + except TypeError: + pass + kwargs = {**kwargs, "messages": messages_raw} + tools_raw = kwargs.get("tools") + if tools_raw is not None and not isinstance(tools_raw, (list, tuple)): + try: + tools_raw = list(tools_raw) + except TypeError: + pass + kwargs = {**kwargs, "tools": tools_raw} chat_invocation = create_chat_invocation( handler, kwargs, instance, capture_content=capture_content ) @@ -94,6 +110,22 @@ def async_chat_completions_create_v_new( capture_content = handler.should_capture_content() async def traced_method(wrapped, instance, args, kwargs): + # Materialize generator/map-backed messages/tools before they are + # consumed by either instrumentation or the SDK (fixes #825). + messages_raw = kwargs.get("messages") + if messages_raw is not None and not isinstance(messages_raw, (list, tuple)): + try: + messages_raw = list(messages_raw) + except TypeError: + pass + kwargs = {**kwargs, "messages": messages_raw} + tools_raw = kwargs.get("tools") + if tools_raw is not None and not isinstance(tools_raw, (list, tuple)): + try: + tools_raw = list(tools_raw) + except TypeError: + pass + kwargs = {**kwargs, "tools": tools_raw} chat_invocation = create_chat_invocation( handler, kwargs, instance, capture_content=capture_content ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py b/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py index 23708cc29..19448c50d 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py @@ -185,24 +185,12 @@ def create_chat_invocation( ) if capture_content: # optimization - # Materialize one-shot iterables so they aren't consumed before - # the wrapped API call (fixes #825). - messages_raw = kwargs.get("messages", []) - if messages_raw is not None and not isinstance(messages_raw, (list, tuple)): - try: - messages_raw = list(messages_raw) - except TypeError: - pass - tools_raw = kwargs.get("tools") - if tools_raw is not None and not isinstance(tools_raw, (list, tuple)): - try: - tools_raw = list(tools_raw) - except TypeError: - pass - # Update kwargs so the wrapped call also sees materialized inputs - kwargs = {**kwargs, "messages": messages_raw, "tools": tools_raw} - invocation.input_messages = _prepare_input_messages(messages_raw) - invocation.tool_definitions = _prepare_tool_definitions(tools_raw) + invocation.input_messages = _prepare_input_messages( + kwargs.get("messages", []) + ) + invocation.tool_definitions = _prepare_tool_definitions( + kwargs.get("tools") + ) return invocation diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py b/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py new file mode 100644 index 000000000..a63624447 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py @@ -0,0 +1,15 @@ +"""Regression test for issue #825.""" +import os +os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_ONLY") + +def test_materialization_preserves_generator(): + MSGS = [{"role":"system","content":"x"},{"role":"user","content":"hi"}] + generator = (m for m in MSGS) + materialized = list(generator) if not isinstance(generator, (list, tuple)) else generator + assert len(materialized) == 2, "generator must be materialized to preserve messages" + +def test_materialization_preserves_tool_generator(): + TOOLS = [{"type":"function","function":{"name":"f","parameters":{}}}] + generator = (t for t in TOOLS) + materialized = list(generator) if not isinstance(generator, (list, tuple)) else generator + assert len(materialized) == 1, "generator must be materialized to preserve tools" From eeab12eab645f44685485b6cecacc75b98e3f52a Mon Sep 17 00:00:00 2001 From: Niranjan Date: Fri, 2 Oct 2026 23:11:52 +0530 Subject: [PATCH 3/3] test: exercise generator materialization through OpenAI wrappers --- .../tests/test_issue_825_regression.py | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py b/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py index a63624447..ff87e9549 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_issue_825_regression.py @@ -1,15 +1,61 @@ -"""Regression test for issue #825.""" +"""Regression test for issue #825: instrumented wrapper materializes generators.""" import os os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_ONLY") -def test_materialization_preserves_generator(): - MSGS = [{"role":"system","content":"x"},{"role":"user","content":"hi"}] - generator = (m for m in MSGS) - materialized = list(generator) if not isinstance(generator, (list, tuple)) else generator - assert len(materialized) == 2, "generator must be materialized to preserve messages" - -def test_materialization_preserves_tool_generator(): - TOOLS = [{"type":"function","function":{"name":"f","parameters":{}}}] - generator = (t for t in TOOLS) - materialized = list(generator) if not isinstance(generator, (list, tuple)) else generator - assert len(materialized) == 1, "generator must be materialized to preserve tools" +from unittest.mock import MagicMock, AsyncMock, patch + +# Import the instrumented wrappers (production code unchanged) +from opentelemetry.instrumentation.genai.openai.patch import ( + chat_completions_create_v_new, + async_chat_completions_create_v_new, +) + +def test_sync_wrapper_materializes_generator_messages(): + mock_create = MagicMock(return_value={"choices": []}) + handler = MagicMock() + handler.should_capture_content.return_value = False + wrapper = chat_completions_create_v_new(handler) + generator = (msg for msg in [{"role":"user","content":"hi"}]) + wrapper(mock_create, None, [], {"messages": generator}) + call_kwargs = mock_create.call_args.kwargs + assert isinstance(call_kwargs["messages"], list) + assert call_kwargs["messages"] == [{"role":"user","content":"hi"}] + +def test_sync_wrapper_materializes_generator_tools(): + mock_create = MagicMock(return_value={"choices": []}) + handler = MagicMock() + handler.should_capture_content.return_value = False + wrapper = chat_completions_create_v_new(handler) + generator = (t for t in [{"type":"function","function":{"name":"f"}}]) + wrapper(mock_create, None, [], {"messages": [{"role":"user","content":"hi"}], "tools": generator}) + call_kwargs = mock_create.call_args.kwargs + assert isinstance(call_kwargs["tools"], list) + assert call_kwargs["tools"] == [{"type":"function","function":{"name":"f"}}] + +def test_async_wrapper_materializes_generator_messages(): + import asyncio + mock_create = AsyncMock(return_value={"choices": []}) + handler = MagicMock() + handler.should_capture_content.return_value = False + wrapper = async_chat_completions_create_v_new(handler) + async def run(): + generator = (msg for msg in [{"role":"user","content":"hi"}]) + await wrapper(mock_create, None, [], {"messages": generator}) + asyncio.run(run()) + call_kwargs = mock_create.call_args.kwargs + assert isinstance(call_kwargs["messages"], list) + assert call_kwargs["messages"] == [{"role":"user","content":"hi"}] + +def test_async_wrapper_materializes_generator_tools(): + import asyncio + mock_create = AsyncMock(return_value={"choices": []}) + handler = MagicMock() + handler.should_capture_content.return_value = False + wrapper = async_chat_completions_create_v_new(handler) + async def run(): + generator = (t for t in [{"type":"function","function":{"name":"f"}}]) + await wrapper(mock_create, None, [], {"messages": [{"role":"user","content":"hi"}], "tools": generator}) + asyncio.run(run()) + call_kwargs = mock_create.call_args.kwargs + assert isinstance(call_kwargs["tools"], list) + assert call_kwargs["tools"] == [{"type":"function","function":{"name":"f"}}]