Skip to content

fix(openai): capture request iterators lazily (#825) - #864

Open
Pr1meGG wants to merge 7 commits into
open-telemetry:mainfrom
Pr1meGG:fix/genai-openai-iterator
Open

Pr1meGG wants to merge 7 commits into
open-telemetry:mainfrom
Pr1meGG:fix/genai-openai-iterator

Conversation

@Pr1meGG

@Pr1meGG Pr1meGG commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #825.

Uses the shared IteratorCapture helper from #873 to capture generator-backed OpenAI messages and tools lazily as the SDK consumes them, instead of eagerly materializing them with list(). 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.

@Pr1meGG
Pr1meGG requested a review from a team as a code owner October 9, 2026 07:02
Copilot AI balanced review requested due to automatic review settings October 9, 2026 07:02
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Pull request dashboard status

Waiting 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):

  • Inline threads: 1
Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Should this be with reviewers? Comment /dashboard route:reviewers to route it to them.
  • Anything wrong — including the routing? Report it with what you expected; it helps us improve the dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 messages and tools iterators.
  • 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)

@lmolkova lmolkova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opentelemetry-pr-dashboard

This comment has been minimized.

Pr1meGG commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the guidance. #873 has now merged, so the shared IteratorCapture helper is available. I’m updating this PR to use it instead of eager list() materialization, with regression coverage for reused mutable objects and nested iterators. I’ll resolve the branch conflicts before handing this back for review.

…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
@Pr1meGG Pr1meGG changed the title fix(openai): materialize generator messages and tools before extraction (#825) fix(openai): capture request iterators lazily (#825) Oct 29, 2026
@Pr1meGG

Pr1meGG commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Hi @lmolkova, thanks for the guidance!

I've updated #864 to use the shared IteratorCapture helper from #873 instead of eager list() materialization.

The changes address mutable dictionary reuse, nested iterators, and preserving SDK behavior when capture_content=False. I've also added sync/async regression tests covering these cases.

All 82 CI checks are passing on commit 1a8ab79d.

Could you take another look when you get a chance? Thanks!

Comment on lines +95 to +109
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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())},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
{"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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@Pr1meGG

Pr1meGG commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Added sync and async streaming-generator tests in ced80c9. Both fully consume the stream and verify the request body and the span’s gen_ai.input.messages.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

[genai-openai] With content capture enabled, generator/iterator messages and tools are consumed and the request is sent empty

3 participants