Repository navigation
fix(google-genai): rebuild streamed interactions.create output from step events - #951
sangkyoonnam wants to merge 11 commits into
Conversation
…ns.create Assisted-by: Claude Opus 5.5
Co-authored-by: Liudmila Molkova <neskazu@gmail.com>
…tests Assisted-by: Claude Opus 5.5
…tep events Assisted-by: Claude Opus 5.5
Pull request dashboard statusWaiting on the author · refreshed 2026-10-23 20:38 UTC Investigate required status check failures. Status above doesn't look right?
|
There was a problem hiding this comment.
🟢 Approval recommended
The focused stream-handling changes have relevant regression coverage, with no unresolved blocking findings.
No open issues 🎉
What changed in this PR
This PR builds on #947 to restore output messages and finish reasons for streamed Google GenAI interactions.create calls within the existing instrumentation.
Changes:
- Reconstructs streamed output from step events using shared handling across all four interaction stream wrappers.
- Records status-derived finish reasons for model and agent streams independently of content capture.
- Adds sync/async cassette regressions and mocked coverage for incomplete streams, tool-call finish reasons, and agent spans.
Paths below are relative to instrumentation/opentelemetry-instrumentation-google-genai/.
| File | Description |
|---|---|
tests/interactions/util.py |
Corrects the mocked completion event name. |
tests/interactions/test_e2e.py |
Verifies streamed output, metadata, and capture-independent finish reasons. |
tests/interactions/cassettes/test_sync_interactions_create_stream.yaml |
Adds a recorded synchronous stream. |
tests/interactions/cassettes/test_async_interactions_create_stream.yaml |
Adds a recorded asynchronous stream. |
tests/interactions/base.py |
Adds status, agent, and incomplete-stream regression cases. |
src/opentelemetry/instrumentation/google_genai/interactions.py |
Shares stream handling and reconstructs output with finish reasons. |
.changelog/951.fixed |
Documents restored output and finish reasons. |
.changelog/947.fixed |
Documents the underlying completion-event fix. |
Review effort: Balanced
| if telemetry_handler.should_capture_content(): | ||
| invocation.output_messages = _interactions_response_to_messages( | ||
| response | ||
| # A streamed completion event carries no steps, so a stream's output | ||
| # comes from its step events and its finish reason from the status. | ||
| finish_reason = ( | ||
| _interaction_finish_reason(_get_field(response, "status")) | ||
| if streamed_parts is not None | ||
| else "stop" | ||
| ) | ||
| messages = _interactions_response_to_messages( | ||
| response, finish_reason=finish_reason | ||
| ) | ||
| if streamed_parts and not _has_content(messages): | ||
| messages = _streamed_output_messages( | ||
| streamed_parts, finish_reason=finish_reason | ||
| ) | ||
| invocation.output_messages = messages |
There was a problem hiding this comment.
Non-streamed create still reports "stop" for every status (e.g. requires_action, or in_progress with background=True), and its agent spans get no gen_ai.response.finish_reasons, so stream and non-stream now disagree. What do you think about deriving it from the status for both paths here? Then the explicit finish_reasons block in _CreateStreamMixin._record_interaction can go.
| if telemetry_handler.should_capture_content(): | |
| invocation.output_messages = _interactions_response_to_messages( | |
| response | |
| # A streamed completion event carries no steps, so a stream's output | |
| # comes from its step events and its finish reason from the status. | |
| finish_reason = ( | |
| _interaction_finish_reason(_get_field(response, "status")) | |
| if streamed_parts is not None | |
| else "stop" | |
| ) | |
| messages = _interactions_response_to_messages( | |
| response, finish_reason=finish_reason | |
| ) | |
| if streamed_parts and not _has_content(messages): | |
| messages = _streamed_output_messages( | |
| streamed_parts, finish_reason=finish_reason | |
| ) | |
| invocation.output_messages = messages | |
| finish_reason = _interaction_finish_reason(_get_field(response, "status")) | |
| if finish_reason: | |
| invocation.finish_reasons = [finish_reason] | |
| if telemetry_handler.should_capture_content(): | |
| messages = _interactions_response_to_messages( | |
| response, finish_reason=finish_reason | |
| ) | |
| if streamed_parts and not _has_content(messages): | |
| messages = _streamed_output_messages( | |
| streamed_parts, finish_reason=finish_reason | |
| ) | |
| invocation.output_messages = messages |
There was a problem hiding this comment.
Done in 5a3ec52. Streamed and non-streamed create now derive finish reasons from the status on both model and agent spans, regardless of content capture, and the explicit block in _CreateStreamMixin._record_interaction is gone. in_progress or a missing status gives no finish reason. Before this, a non-streamed model span with content capture on reported stop even for in_progress.
| "os.environ", | ||
| {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY"}, | ||
| ) | ||
| def test_streaming_output_message_finish_reason_matches_status( |
There was a problem hiding this comment.
The rebuild-from-steps path is only covered by a plain-text cassette. The other new streaming tests use hand-built MagicMock events, which don't match the SDK's real event types, so a wrong field name wouldn't fail them. Also, streamed create still has no tests for a mid-stream error, a caller-side error, or close/early-close, and this PR changes how those finish.
What do you think about following the OpenAI/Anthropic pattern?
- Record a streamed tool-call cassette (sync + async) that ends in
requires_action, and check the rebuilttool_callpart andfinish_reasons == ("tool_calls",)with content capture on and off. - Inject failures on top of a recorded cassette by wrapping the SDK stream in a delegate that raises after N events, like
ErrorInjectingStreamDelegateand_AsyncErrorInjectingStreamDelegate. Add araiseinside thewithblock for the caller-side case.
Mocks are still fine for a stream that ends without a completion event.
There was a problem hiding this comment.
Added in 786d15b: sync and async tool-call cassettes ending in requires_action (capture on and off), errors injected after 0 and 7 events of the recorded text streams, a raise inside with, and early close.
The tool-call and capture-on early-close tests fail with main's interactions.py. The error tests pass on both, since a failed stream still drops partial output. Should keeping it on failure, as Anthropic does, be a separate PR?
…teractions-create-stream-output # Conflicts: # instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py # instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py
… status Co-authored-by: lmolkova <2347409+lmolkova@users.noreply.github.com> Assisted-by: Claude Opus 5.5
…rors and early close Assisted-by: Claude Opus 5.5
…mulation fails Assisted-by: Claude Opus 5.5
Description
Streamed
interactions.createspans rebuild captured output messages from step events becauseinteraction.completedcarries nosteps; all four interaction stream wrappers share that handling and discard captured stream output if accumulation or rebuilding raises an exception. Streamed and non-streamedcreatederivegen_ai.response.finish_reasonsfrom the interaction status on model and agent spans, regardless of content capture;in_progressor a missing status gives no finish reason.Agent streams are covered by mocked tests only.
Type of change
How has this been tested?
requires_action(sync + async, capture on and off), errors injected mid-stream, a caller-sideraise, and an earlyclose().interactions.pyreplaced by main's, 28 tests intests/interactionsfail on latest and on v2; the error tests still pass.py310-test-instrumentation-google-genai-v2: 364 passed.py314-test-instrumentation-google-genai-latest: 693 passed.py310-test-instrumentation-google-genai-oldest: 279 passed.Checklist