Repository navigation
fix(util-genai,openai): scope stream invocation context #817
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
lmolkova
merged 16 commits into
open-telemetry:main
from
sangkyoonnam:fix/677-util-stream-execution-context
Oct 11, 2026
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
167279e
Prototype LangChain execution-scoped context propagation (util and Op…
lmolkova e7e7a3c
fix(openai): suspend Responses API stream invocations between reads
sangkyoonnam 29a4afd
docs: describe the stream execution context hook and add changelog fr…
sangkyoonnam b493216
docs: name the changelog fragments after the pull request
sangkyoonnam 9d277c9
fix(util): run the stream manager exit inside the stream execution co…
sangkyoonnam 993a9d9
fix(openai): finalize a close through the Responses API HTTP response…
sangkyoonnam e3868f5
docs(util): note the execution context on close finalizers and manage…
sangkyoonnam a2769b9
Merge branch 'main' into fix/677-util-stream-execution-context
sangkyoonnam f9e35d9
Merge branch 'main' into fix/677-util-stream-execution-context
sangkyoonnam bbcdef5
feat(util): make suspended invocations current by default in the stre…
sangkyoonnam 9581198
chore: let codespell accept the asend generator method
sangkyoonnam 82c4177
docs(util): pass the invocation to the stream base wrapper instead of…
sangkyoonnam 06840f3
Merge remote-tracking branch 'upstream/main' into fix/677-util-stream…
sangkyoonnam a916053
test(util): keep suppressed inference invocations out of stream activ…
sangkyoonnam 835c6db
fix(util): finalize abandoned streams inside the stream execution con…
sangkyoonnam 76a7383
fix(openai): suspend raw-response stream invocations before parse()
sangkyoonnam File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1 change: 1 addition & 0 deletions
1
instrumentation/opentelemetry-instrumentation-genai-openai/.changelog/817.fixed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Suspend the chat completion and Responses API invocations while a streamed response is unconsumed and re-activate them only while a chunk is read or the stream is cleaned up: the inference span is no longer current in the caller's context between reads, so spans a caller creates between chunks are siblings of the inference span, not children. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
91 changes: 91 additions & 0 deletions
91
instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_chat_stream_context.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # Copyright The OpenTelemetry Authors | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| from collections.abc import AsyncIterator, Iterator | ||
|
|
||
| import pytest | ||
| from openai.types.chat import ChatCompletionChunk | ||
|
|
||
| from opentelemetry import context, trace | ||
| from opentelemetry.instrumentation.genai.openai.chat_wrappers import ( | ||
| AsyncChatStreamWrapper, | ||
| ChatStreamWrapper, | ||
| ) | ||
| from opentelemetry.util.genai.handler import TelemetryHandler | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize("asynchronous", [False, True]) | ||
| @pytest.mark.parametrize("ending", ["drain", "close", "error"]) | ||
| async def test_chat_stream_activates_only_during_reads( | ||
| tracer_provider, span_exporter, caplog, asynchronous: bool, ending: str | ||
| ) -> None: | ||
| before = context.get_current() | ||
| invocation = TelemetryHandler( | ||
| tracer_provider=tracer_provider, | ||
| ).inference("openai", request_model="test-model") | ||
| error = ConnectionError("stream failed") | ||
| chunk = ChatCompletionChunk( | ||
| id="test", | ||
| created=1, | ||
| model="test-model", | ||
| object="chat.completion.chunk", | ||
| choices=[], | ||
| ) | ||
|
|
||
| def produce() -> Iterator[ChatCompletionChunk]: | ||
| assert ( | ||
| trace.get_current_span().get_span_context() | ||
| == trace.get_current_span(invocation.context).get_span_context() | ||
| ) | ||
| yield chunk | ||
| assert ( | ||
| trace.get_current_span().get_span_context() | ||
| == trace.get_current_span(invocation.context).get_span_context() | ||
| ) | ||
| if ending == "error": | ||
| raise error | ||
|
|
||
| async def aproduce() -> AsyncIterator[ChatCompletionChunk]: | ||
| for item in produce(): | ||
| await asyncio.sleep(0) | ||
| yield item | ||
|
|
||
| if asynchronous: | ||
| stream = AsyncChatStreamWrapper(aproduce(), invocation, False) | ||
| else: | ||
| stream = ChatStreamWrapper(produce(), invocation, False) | ||
| assert context.get_current() is before | ||
| if asynchronous: | ||
| assert await asyncio.create_task(anext(stream)) is chunk | ||
| else: | ||
| assert next(stream) is chunk | ||
| assert context.get_current() is before | ||
| assert span_exporter.get_finished_spans() == () | ||
|
|
||
| if ending == "close": | ||
| if asynchronous: | ||
| await stream.aclose() | ||
| else: | ||
| stream.close() | ||
| elif ending == "error": | ||
| with pytest.raises(ConnectionError) as raised: | ||
| if asynchronous: | ||
| await anext(stream) | ||
| else: | ||
| next(stream) | ||
| assert raised.value is error | ||
| elif asynchronous: | ||
| assert [item async for item in stream] == [] | ||
| else: | ||
| assert list(stream) == [] | ||
| assert context.get_current() is before | ||
| assert len(span_exporter.get_finished_spans()) == 1 | ||
| assert not [ | ||
| r | ||
| for r in caplog.records | ||
| if r.name == "opentelemetry.context" and r.levelno >= 40 | ||
| ] |
120 changes: 120 additions & 0 deletions
120
...tion/opentelemetry-instrumentation-genai-openai/tests/test_raw_response_stream_suspend.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| # Copyright The OpenTelemetry Authors | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import asyncio | ||
|
|
||
| from openai import AsyncStream, Stream | ||
|
|
||
| from opentelemetry import context, trace | ||
| from opentelemetry.instrumentation.genai.openai._raw_response import ( | ||
| wrap_stream_result, | ||
| ) | ||
| from opentelemetry.instrumentation.genai.openai.chat_wrappers import ( | ||
| AsyncChatStreamWrapper, | ||
| ChatStreamWrapper, | ||
| ) | ||
| from opentelemetry.util.genai.handler import TelemetryHandler | ||
|
|
||
| from .test_raw_response_proxy import _AsyncRawResponse, _RawResponse | ||
|
|
||
|
|
||
| class _EmptyStream(Stream): | ||
| def __init__(self): | ||
| pass | ||
|
|
||
| def __iter__(self): | ||
| return iter(()) | ||
|
|
||
| def close(self): | ||
| pass | ||
|
|
||
|
|
||
| class _EmptyAsyncStream(AsyncStream): | ||
| def __init__(self): | ||
| pass | ||
|
|
||
| def __aiter__(self): | ||
| return self | ||
|
|
||
| async def __anext__(self): | ||
| raise StopAsyncIteration | ||
|
|
||
| async def close(self): | ||
| pass | ||
|
|
||
|
|
||
| def _start(tracer_provider): | ||
| return TelemetryHandler(tracer_provider=tracer_provider).inference( | ||
| "openai", request_model="m" | ||
| ) | ||
|
|
||
|
|
||
| def test_raw_response_span_not_current_after_create(tracer_provider): | ||
| before = context.get_current() | ||
| raw = wrap_stream_result( | ||
| ChatStreamWrapper, | ||
| _RawResponse(_EmptyStream()), | ||
| _start(tracer_provider), | ||
| False, | ||
| ) | ||
| assert context.get_current() is before | ||
| raw.http_response.close() | ||
|
|
||
|
|
||
| def test_unparsed_raw_response_stops_inside_its_span(tracer_provider): | ||
| invocation = _start(tracer_provider) | ||
| stop = invocation.stop | ||
| current_at_stop = [] | ||
|
|
||
| def recording_stop(): | ||
| current_at_stop.append(trace.get_current_span()) | ||
| stop() | ||
|
|
||
| invocation.stop = recording_stop | ||
| raw = wrap_stream_result( | ||
| ChatStreamWrapper, _RawResponse(_EmptyStream()), invocation, False | ||
| ) | ||
| before = context.get_current() | ||
| with tracer_provider.get_tracer("t").start_as_current_span("caller"): | ||
| raw.http_response.close() | ||
|
|
||
| assert current_at_stop == [invocation.span] | ||
| assert context.get_current() is before | ||
|
|
||
|
|
||
| def test_raw_response_parse_under_caller_span_then_drain( | ||
| tracer_provider, span_exporter | ||
| ): | ||
| before = context.get_current() | ||
| raw = wrap_stream_result( | ||
| ChatStreamWrapper, | ||
| _RawResponse(_EmptyStream()), | ||
| _start(tracer_provider), | ||
| False, | ||
| ) | ||
| with tracer_provider.get_tracer("t").start_as_current_span("caller"): | ||
| stream = raw.parse() | ||
| assert list(stream) == [] | ||
| assert len(span_exporter.get_finished_spans()) == 2 | ||
| assert context.get_current() is before | ||
|
|
||
|
|
||
| def test_async_raw_response_parse_under_caller_span_then_drain( | ||
| tracer_provider, span_exporter | ||
| ): | ||
| async def run(): | ||
| before = context.get_current() | ||
| raw = wrap_stream_result( | ||
| AsyncChatStreamWrapper, | ||
| _AsyncRawResponse(_EmptyAsyncStream()), | ||
| _start(tracer_provider), | ||
| False, | ||
| ) | ||
| assert context.get_current() is before | ||
| with tracer_provider.get_tracer("t").start_as_current_span("caller"): | ||
| stream = await raw.parse() | ||
| assert [chunk async for chunk in stream] == [] | ||
| assert len(span_exporter.get_finished_spans()) == 2 | ||
| assert context.get_current() is before | ||
|
|
||
| asyncio.run(run()) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the
with_raw_responsepath this wrapper is only built onparse(), sosuspend()runs wherever the caller parses. If that's inside a caller span, the inference span is left current after the stream is drained and the span has ended. On main the same flow ends with the context restored, sincestop()detaches. The span is also still current betweencreate()andparse().Would it work to suspend in
wrap_stream_result(or right beforetraced_methodreturns) so both paths are covered? Same applies to_ResponseStreamMixin. Both tests below fail on this branch; the second passes on main:Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 76a7383: wrap_stream_result now suspends before returning the raw-response proxy, so create() leaves the context as it found it and parse() under a caller span no longer leaves the inference span current. The Responses API streams go through the same function, so _ResponseStreamMixin is covered too. Your two tests are in test_raw_response_stream_suspend.py, plus an async variant and one for a response closed without parse().