Repository navigation
Conversation
Pull request dashboard statusWaiting on the author · refreshed 2026-11-01 11:24 UTC Respond to 1 review item (e.g. link a commit, explain why not, ask a follow-up):
Status above doesn't look right?
|
There was a problem hiding this comment.
🟡 Changes recommended
Iterator materialization must be limited to content capture to preserve default SDK behavior.
1 open finding
What changed in this PR
Fixes generator exhaustion in OpenAI chat completion telemetry.
Changes:
- Materializes one-shot
messagesandtoolsiterators. - Adds synchronous and asynchronous regression tests.
- Adds a changelog fragment.
| File | Description |
|---|---|
patch.py |
Materializes chat request iterators before extraction. |
test_chat_completions.py |
Tests synchronous generator requests. |
test_async_chat_completions.py |
Tests asynchronous generator requests. |
.changelog/825.fixed |
Documents the fix. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| capture_content = handler.should_capture_content() | ||
|
|
||
| def traced_method(wrapped, instance, args, kwargs): | ||
| _materialize_iterables(kwargs) |
Assisted-by: Claude Opus 4.6
Assisted-by: Claude Opus 4.6
lmolkova
left a comment
There was a problem hiding this comment.
Materializing with list() still changes request behavior: it consumes the generator before the SDK runs and retains references to yielded objects. A generator that reuses a mutable dictionary can therefore send different content:
def messages():
message = {"role": "user", "content": ""}
for text in ("first", "second"):
message["content"] = text
yield message
# Processing each item as it is yielded preserves both values.
assert [m["content"] for m in messages()] == ["first", "second"]
# The proposed materialization keeps two references to the same dict.
materialized = list(messages())
assert [m["content"] for m in materialized] == ["second", "second"]The fix also only covers top-level messages and tools; nested iterators, such as message content, remain unprotected.
Could you review #873? It introduces a shared IteratorCapture helper that records values as the SDK consumes them, including nested iterators. Let’s wait for it to land, then adopt that helper for OpenAI in this PR.
This comment has been minimized.
This comment has been minimized.
|
Thanks for the guidance. #873 has now merged, so the shared |
…ator # Conflicts: # instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_chat_completions.py
Replace eager list() materialization of one-shot 'messages' and 'tools' iterators with the shared IteratorCapture helper from the util package (open-telemetry#873), which records values lazily as the SDK consumes them. This keeps SDK request semantics unchanged: generators that reuse mutable dicts now record every yielded value instead of only the final mutation, nested iterators in message content are captured, values consumed before an SDK failure are still recorded, and with content capture disabled the caller's iterator reaches the SDK untouched. Fixes failing 'latest' CI jobs by aliasing the HTTP test client import (httpx2 when installed by OpenAI SDK v3, httpx otherwise), matching the Anthropic package pattern. Fixes open-telemetry#825 Assisted-by: Claude
|
Hi @lmolkova, thanks for the guidance! I've updated #864 to use the shared The changes address mutable dictionary reuse, nested iterators, and preserving SDK behavior when All 82 CI checks are passing on commit Could you take another look when you get a chance? Thanks! |
| except BaseException as error: | ||
| chat_invocation.fail(error) | ||
| raise | ||
|
|
||
| if is_streaming(kwargs): | ||
| return wrap_stream_result( | ||
| ChatStreamWrapper, | ||
| result, | ||
| chat_invocation, | ||
| capture_content, | ||
| ) | ||
|
|
||
| _set_response_properties(chat_invocation, result, capture_content) | ||
| chat_invocation.stop() | ||
| return result |
There was a problem hiding this comment.
Moving the post-call code out of the try means that if wrap_stream_result or _set_response_properties raises (e.g. result.parse() on a with_raw_response result), the invocation is never ended and its span stays current in the caller's context. Could we keep it inside the outer try? Same in the async wrapper.
| except BaseException as error: | |
| chat_invocation.fail(error) | |
| raise | |
| if is_streaming(kwargs): | |
| return wrap_stream_result( | |
| ChatStreamWrapper, | |
| result, | |
| chat_invocation, | |
| capture_content, | |
| ) | |
| _set_response_properties(chat_invocation, result, capture_content) | |
| chat_invocation.stop() | |
| return result | |
| if is_streaming(kwargs): | |
| return wrap_stream_result( | |
| ChatStreamWrapper, | |
| result, | |
| chat_invocation, | |
| capture_content, | |
| ) | |
| _set_response_properties(chat_invocation, result, capture_content) | |
| chat_invocation.stop() | |
| return result | |
| except BaseException as error: | |
| chat_invocation.fail(error) | |
| raise |
| client.chat.completions.create( | ||
| model="gpt-4o-mini", | ||
| messages=[ | ||
| {"role": "user", "content": list(content())}, |
There was a problem hiding this comment.
list(content()) materializes the generator before the call, so this test doesn't exercise a nested iterator. Passing a generator of content parts would cover it (body/span asserts need to match the part shape). Same in the async test.
| {"role": "user", "content": list(content())}, | |
| { | |
| "role": "user", | |
| "content": ({"type": "text", "text": t} for t in ("a", "b")), | |
| }, |
| ] | ||
|
|
||
|
|
||
| def test_chat_completion_tools_generator_reaches_sdk( |
There was a problem hiding this comment.
The streaming path changed too, but there's no test with a generator and stream=True (sync or async). Something like:
def test_chat_completion_streaming_messages_generator(
span_exporter, instrument_with_content
):
def respond(request):
body = (
'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,'
'"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant",'
'"content":"Hi!"},"finish_reason":"stop"}]}\n\n'
"data: [DONE]\n\n"
)
return httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=body
)
client = OpenAI(
api_key="test_openai_api_key",
base_url="http://openai.test",
http_client=httpx.Client(transport=httpx.MockTransport(respond)),
)
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=({"role": "user", "content": c} for c in ["hello"]),
stream=True,
)
for _ in stream:
pass
(span,) = span_exporter.get_finished_spans()
recorded = json.loads(span.attributes["gen_ai.input.messages"])
assert recorded[0]["parts"][0]["content"] == "hello"| "total_tokens": 15, | ||
| }, | ||
| } | ||
| _GENERATOR_TEST_COMPLETION = _REQUEST_TEST_COMPLETION |
There was a problem hiding this comment.
nit: several new tests still inline the same completion JSON instead of using this constant, _GENERATOR_TEST_COMPLETION is just an alias, and most seen dicts are never asserted on. Could we use one shared constant and drop the unused seen?
| ] | ||
|
|
||
|
|
||
| def test_chat_completion_generator_no_capture_content_not_wrapped( |
There was a problem hiding this comment.
nit: this overlaps with test_chat_completion_generator_no_content_transparency, and the docstring doesn't match what's asserted. Maybe drop it?
Assisted-by: GLM 5.3 Flash Assisted-by: Claude Opus 5.5
Keep stream wrapping and response processing inside the try block so a failure there fails the invocation instead of leaving its span open and current. Assisted-by: Claude Opus 5.5
|
Added sync and async streaming-generator tests in ced80c9. Both fully consume the stream and verify the request body and the span’s |

Fixes #825.
Uses the shared
IteratorCapturehelper from #873 to capture generator-backed OpenAImessagesandtoolslazily as the SDK consumes them, instead of eagerly materializing them withlist(). Captured values are extracted after SDK request consumption. This preserves request behavior for reused mutable objects and nested iterators, and leaves inputs untouched when content capture is disabled.Adds sync/async regression tests for one-shot iterators, reused mutable objects, nested message content, empty and failing iterators, and capture-disabled transparency.
Known gaps or concerns
None known.