Skip to content

Commit 7f2e19c

Browse files
authored
Python: Ensure spans created inside sync preparations in streaming call are correctly nested (microsoft#6552)
* Make sure spans created inside sync ops in streaming path are correctly nested * Add tests * Fix comments * Fix typing
1 parent 7b6f582 commit 7f2e19c

4 files changed

Lines changed: 237 additions & 16 deletions

File tree

python/packages/core/agent_framework/observability.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,18 +1519,26 @@ def _record_duration() -> None:
15191519
duration_state["duration"] = perf_counter() - start_time
15201520

15211521
try:
1522-
result_stream = cast(
1523-
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
1524-
super_get_response(
1525-
messages=messages,
1526-
stream=True,
1527-
options=opts,
1528-
compaction_strategy=compaction_strategy,
1529-
tokenizer=tokenizer,
1530-
function_invocation_kwargs=function_invocation_kwargs,
1531-
client_kwargs=merged_client_kwargs,
1532-
),
1533-
)
1522+
# Activate the chat span across the synchronous setup phase so spans
1523+
# created by the underlying client while constructing the stream are
1524+
# parented under it. The per-pull ``_activate_span`` registered below
1525+
# covers iteration; this covers anything the subclass does between
1526+
# being called and returning the ResponseStream. Attach/detach are
1527+
# paired within this sync block, so there is no cross-context
1528+
# detach risk (the span itself is ended later in cleanup hooks).
1529+
with _activate_span(span):
1530+
result_stream = cast(
1531+
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
1532+
super_get_response(
1533+
messages=messages,
1534+
stream=True,
1535+
options=opts,
1536+
compaction_strategy=compaction_strategy,
1537+
tokenizer=tokenizer,
1538+
function_invocation_kwargs=function_invocation_kwargs,
1539+
client_kwargs=merged_client_kwargs,
1540+
),
1541+
)
15341542
except Exception as exception:
15351543
capture_exception(span=span, exception=exception, timestamp=time_ns())
15361544
_close_span()
@@ -1800,7 +1808,17 @@ def _record_duration() -> None:
18001808
duration_state["duration"] = perf_counter() - start_time
18011809

18021810
try:
1803-
run_result: object = execute()
1811+
# Activate the agent span across the synchronous setup phase so spans
1812+
# created by the underlying agent while constructing the stream are
1813+
# parented under it. The per-pull ``_activate_span`` registered below
1814+
# covers iteration; this covers anything the subclass does between
1815+
# being called and returning the ResponseStream (subclasses that
1816+
# instead return an Awaitable defer their work into the first pull,
1817+
# where the per-pull activation already applies). Attach/detach are
1818+
# paired within this sync block, so there is no cross-context detach
1819+
# risk (the span itself is ended later in cleanup hooks).
1820+
with _activate_span(span):
1821+
run_result: object = execute()
18041822
if isinstance(run_result, ResponseStream):
18051823
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
18061824
elif isinstance(run_result, Awaitable):

python/packages/core/tests/core/test_observability.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,85 @@ async def test_chat_client_streaming_observability_with_instructions(
335335
assert [msg.get("role") for msg in input_messages] == ["user"]
336336

337337

338+
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
339+
async def test_chat_client_streaming_sync_setup_span_is_parented_to_chat_span(
340+
span_exporter: InMemorySpanExporter, enable_sensitive_data
341+
):
342+
"""Regression guard for the streaming sync-setup parenting gap.
343+
344+
When a chat client subclass creates spans inside ``_inner_get_response`` during
345+
the synchronous setup phase (before returning the ``ResponseStream``), those
346+
spans must be nested under the chat-completion span produced by
347+
``ChatTelemetryLayer``. The chat span is created via ``_start_streaming_span``
348+
(which does not attach the span as current) and ``with_pull_context_manager``
349+
only activates the span around each pull, so the synchronous setup window
350+
would otherwise see the chat span existing-but-not-current. ``ChatTelemetryLayer``
351+
therefore wraps the synchronous ``super_get_response(...)`` call in
352+
``_activate_span(span)`` so subclass spans opened during setup parent correctly.
353+
"""
354+
from agent_framework.observability import get_tracer
355+
356+
class SyncSetupChatClient(ChatTelemetryLayer, BaseChatClient[Any]):
357+
def service_url(self):
358+
return "https://test.example.com"
359+
360+
def _inner_get_response(
361+
self, *, messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any
362+
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
363+
assert stream is True, "this fixture only exercises the streaming path"
364+
365+
# Synchronous setup the subclass performs before the stream object is
366+
# constructed. Real clients do payload building, auth resolution, etc.
367+
# here. We model that as a child span the subclass wants to nest under
368+
# the chat-completion span.
369+
with get_tracer().start_as_current_span("subclass_sync_setup") as setup_span:
370+
setup_span.set_attribute("subclass.work", "payload_build")
371+
372+
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
373+
yield ChatResponseUpdate(contents=[Content.from_text("hi")], role="assistant", finish_reason="stop")
374+
375+
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
376+
return ChatResponse.from_updates(updates)
377+
378+
return ResponseStream(_stream(), finalizer=_finalize)
379+
380+
client = SyncSetupChatClient()
381+
span_exporter.clear()
382+
383+
stream = client.get_response(stream=True, messages=[Message("user", ["go"])], options={"model": "Test"})
384+
async for _update in stream:
385+
pass
386+
await stream.get_final_response()
387+
388+
spans = span_exporter.get_finished_spans()
389+
chat_spans = [s for s in spans if s.name == "chat Test"]
390+
setup_spans = [s for s in spans if s.name == "subclass_sync_setup"]
391+
392+
assert len(chat_spans) == 1, f"expected exactly one chat span, got {[s.name for s in spans]}"
393+
assert len(setup_spans) == 1, f"expected exactly one setup span, got {[s.name for s in spans]}"
394+
395+
chat_span = chat_spans[0]
396+
setup_span = setup_spans[0]
397+
398+
# Both spans must be part of the same trace.
399+
assert setup_span.context is not None
400+
assert chat_span.context is not None
401+
assert setup_span.context.trace_id == chat_span.context.trace_id, (
402+
"setup span ended up in a different trace from the chat span; "
403+
"they should share the trace produced by ChatTelemetryLayer"
404+
)
405+
406+
# And the chat span must be the parent of the setup span.
407+
assert setup_span.parent is not None, (
408+
"subclass setup span has no parent; expected it to be a child of the chat span"
409+
)
410+
assert setup_span.parent.span_id == chat_span.context.span_id, (
411+
"subclass setup span is not parented to the chat span "
412+
f"(parent={setup_span.parent.span_id:x}, chat={chat_span.context.span_id:x}); "
413+
"this is the streaming sync-setup parenting gap"
414+
)
415+
416+
338417
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
339418
async def test_chat_client_observability_with_system_message_and_instructions(
340419
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
@@ -582,6 +661,96 @@ async def test_agent_streaming_response_with_diagnostics_enabled(
582661
assert span.attributes.get(OtelAttr.OUTPUT_MESSAGES) is not None # type: ignore[union-attr] # ty: ignore[unresolved-attribute] # Streaming, so no usage yet
583662

584663

664+
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
665+
async def test_agent_streaming_sync_setup_span_is_parented_to_agent_span(
666+
span_exporter: InMemorySpanExporter, enable_sensitive_data
667+
):
668+
"""Regression guard for the streaming sync-setup parenting gap in ``AgentTelemetryLayer``.
669+
670+
Mirrors :func:`test_chat_client_streaming_sync_setup_span_is_parented_to_chat_span`
671+
but at the agent layer. When an agent's ``run(stream=True)`` synchronously
672+
constructs the ``ResponseStream`` (rather than returning a coroutine that
673+
the framework wraps via ``ResponseStream.from_awaitable``), any spans the
674+
subclass opens during that synchronous setup must still be nested under
675+
the agent invoke span produced by ``AgentTelemetryLayer``.
676+
677+
The agent invoke span is created via ``_start_streaming_span`` (which does
678+
not attach the span as current) and ``with_pull_context_manager`` only
679+
activates the span around each pull, so the synchronous setup window would
680+
otherwise see the agent span existing-but-not-current. ``BaseAgent.run``
681+
happens to side-step this by always wrapping its streaming path in
682+
``from_awaitable``, but subclasses that return a stream synchronously do not
683+
get the same protection. ``AgentTelemetryLayer`` therefore wraps the
684+
synchronous ``execute()`` call in ``_activate_span(span)`` so subclass spans
685+
opened during setup parent correctly regardless of the return shape.
686+
"""
687+
from agent_framework import AgentResponse, AgentResponseUpdate
688+
from agent_framework.observability import get_tracer
689+
690+
class _SyncSetupAgent:
691+
AGENT_PROVIDER_NAME = "test_agent_system"
692+
693+
def __init__(self) -> None:
694+
self.id = "sync_setup_agent_id"
695+
self.name = "sync_setup_agent"
696+
self.description = "Agent that performs synchronous setup before streaming."
697+
self.default_options: dict[str, Any] = {"model": "TestModel"}
698+
699+
def run(self, messages=None, *, session=None, stream=False, **kwargs): # type: ignore[no-untyped-def]
700+
assert stream is True, "this fixture only exercises the streaming path"
701+
702+
# Synchronous setup the agent subclass performs before constructing
703+
# the ResponseStream (e.g. resolving credentials, building payload,
704+
# opening transport). Real subclasses may want spans here to nest
705+
# under the agent invoke span.
706+
with get_tracer().start_as_current_span("agent_subclass_sync_setup") as setup_span:
707+
setup_span.set_attribute("agent.subclass.work", "payload_build")
708+
709+
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
710+
yield AgentResponseUpdate(contents=[Content.from_text("hi")], role="assistant")
711+
712+
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
713+
714+
class SyncSetupAgent(AgentTelemetryLayer, _SyncSetupAgent): # type: ignore
715+
pass
716+
717+
agent = SyncSetupAgent()
718+
span_exporter.clear()
719+
720+
stream = agent.run("go", stream=True)
721+
async for _update in stream:
722+
pass
723+
await stream.get_final_response()
724+
725+
spans = span_exporter.get_finished_spans()
726+
agent_spans = [s for s in spans if s.name == "invoke_agent sync_setup_agent"]
727+
setup_spans = [s for s in spans if s.name == "agent_subclass_sync_setup"]
728+
729+
assert len(agent_spans) == 1, f"expected exactly one agent span, got {[s.name for s in spans]}"
730+
assert len(setup_spans) == 1, f"expected exactly one setup span, got {[s.name for s in spans]}"
731+
732+
agent_span = agent_spans[0]
733+
setup_span = setup_spans[0]
734+
735+
# Both spans must be part of the same trace.
736+
assert setup_span.context is not None
737+
assert agent_span.context is not None
738+
assert setup_span.context.trace_id == agent_span.context.trace_id, (
739+
"setup span ended up in a different trace from the agent span; "
740+
"they should share the trace produced by AgentTelemetryLayer"
741+
)
742+
743+
# And the agent span must be the parent of the setup span.
744+
assert setup_span.parent is not None, (
745+
"agent subclass setup span has no parent; expected it to be a child of the agent span"
746+
)
747+
assert setup_span.parent.span_id == agent_span.context.span_id, (
748+
"agent subclass setup span is not parented to the agent span "
749+
f"(parent={setup_span.parent.span_id:x}, agent={agent_span.context.span_id:x}); "
750+
"this is the streaming sync-setup parenting gap at the agent layer"
751+
)
752+
753+
585754
async def test_function_call_with_error_handling(span_exporter: InMemorySpanExporter):
586755
"""Test that function call errors are properly captured in telemetry."""
587756

@@ -4728,6 +4897,40 @@ async def make_stream() -> ResponseStream[int, list[int]]:
47284897
assert events[resolve_index - 1] == "enter" # Pull context active during resolution
47294898

47304899

4900+
async def test_with_pull_context_manager_wraps_stream_resolution_via_get_final_response():
4901+
"""``get_final_response`` resolves the inner stream under the pull contexts (no prior iteration)."""
4902+
import contextlib
4903+
4904+
events: list[str] = []
4905+
4906+
@contextlib.contextmanager
4907+
def cm():
4908+
events.append("enter")
4909+
try:
4910+
yield
4911+
finally:
4912+
events.append("exit")
4913+
4914+
async def inner() -> AsyncIterable[int]:
4915+
yield 1
4916+
4917+
async def make_stream() -> ResponseStream[int, list[int]]:
4918+
# Record that we resolve while a pull context is active.
4919+
events.append("resolving")
4920+
return ResponseStream(inner(), finalizer=lambda updates: list(updates))
4921+
4922+
stream: ResponseStream[int, list[int]] = ResponseStream.from_awaitable(make_stream())
4923+
stream.with_pull_context_manager(cm)
4924+
4925+
# Drive get_final_response() directly, without any prior `async for` or `await stream`.
4926+
final = await stream.get_final_response()
4927+
4928+
assert final == [1]
4929+
assert "resolving" in events
4930+
resolve_index = events.index("resolving")
4931+
assert events[resolve_index - 1] == "enter" # Pull context active during resolution
4932+
4933+
47314934
# region Test streaming telemetry error paths
47324935

47334936

python/packages/tools/agent_framework_tools/shell/_executor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def _popen_kwargs_for_group() -> dict[str, object]:
2727
"""Platform-specific process-group isolation so we can kill children too."""
2828
if sys.platform == "win32":
2929
# CREATE_NEW_PROCESS_GROUP lets CTRL_BREAK_EVENT hit the whole group.
30-
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} # type: ignore[attr-defined]
30+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
3131
return {"start_new_session": True}
3232

3333

python/packages/tools/agent_framework_tools/shell/_session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ async def start(self) -> None:
113113
if sys.platform == "win32":
114114
import subprocess # noqa: S404 # nosec B404 - Win32 constants only
115115

116-
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
116+
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
117117
else:
118118
popen_kwargs["start_new_session"] = True
119119

@@ -412,7 +412,7 @@ async def _interrupt_current_command(self) -> None:
412412
return
413413
try:
414414
if sys.platform == "win32":
415-
self._proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
415+
self._proc.send_signal(signal.CTRL_BREAK_EVENT)
416416
else:
417417
os.killpg(os.getpgid(self._proc.pid), signal.SIGINT)
418418
except (ProcessLookupError, PermissionError, OSError):

0 commit comments

Comments
 (0)