Skip to content

fix(google-genai): rebuild streamed interactions.create output from step events - #951

Open
sangkyoonnam wants to merge 11 commits into
open-telemetry:mainfrom
sangkyoonnam:fix/google-genai-interactions-create-stream-output
Open

sangkyoonnam wants to merge 11 commits into
open-telemetry:mainfrom
sangkyoonnam:fix/google-genai-interactions-create-stream-output

Conversation

@sangkyoonnam

@sangkyoonnam sangkyoonnam commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Description

Streamed interactions.create spans rebuild captured output messages from step events because interaction.completed carries no steps; all four interaction stream wrappers share that handling and discard captured stream output if accumulation or rebuilding raises an exception. Streamed and non-streamed create derive gen_ai.response.finish_reasons from the interaction status on model and agent spans, regardless of content capture; in_progress or a missing status gives no finish reason.

Agent streams are covered by mocked tests only.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How has this been tested?

  • Cassette tests: streamed text checked against the step deltas, a streamed tool call ending in requires_action (sync + async, capture on and off), errors injected mid-stream, a caller-side raise, and an early close().
  • Unit tests cover non-streamed finish reasons by status, a stream that ends without a completion event, payloads that fail to accumulate or rebuild, and agent spans. With only interactions.py replaced by main's, 28 tests in tests/interactions fail 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

  • Followed the style guidelines of this project
  • Changelog updated if the change requires an entry
  • Unit tests added

@sangkyoonnam
sangkyoonnam requested a review from a team as a code owner October 22, 2026 13:48
Copilot AI balanced review requested due to automatic review settings October 22, 2026 13:48
@opentelemetry-pr-dashboard

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

Copy link
Copy Markdown

Pull request dashboard status

Waiting on the author · refreshed 2026-10-23 20:38 UTC

Investigate required status check failures.

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.

🟢 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

Comment on lines 226 to +241
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

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.

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.

Suggested change
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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(

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 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 rebuilt tool_call part and finish_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 ErrorInjectingStreamDelegate and _AsyncErrorInjectingStreamDelegate. Add a raise inside the with block for the caller-side case.

Mocks are still fine for a stream that ends without a completion event.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

sangkyoonnam and others added 5 commits October 23, 2026 11:11
…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

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.

3 participants