Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions python/packages/core/agent_framework/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -1778,12 +1778,16 @@ def _trace_agent_invocation(
**merged_client_kwargs,
)

inner_response_telemetry_captured_fields: set[str] = set()
inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(
inner_response_telemetry_captured_fields
)
inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({})
if stream:
# Set the inner-telemetry context vars eagerly here: the streaming branch returns a
Comment thread
westey-m marked this conversation as resolved.
Outdated
# ResponseStream synchronously and is consumed in this same task/context, so the
# cleanup-hook resets (in _finalize_stream / the exception handler) run in the context
# that created the tokens.
inner_response_telemetry_captured_fields: set[str] = set()
inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(
inner_response_telemetry_captured_fields
)
inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({})
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)

if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
Expand Down Expand Up @@ -1888,6 +1892,18 @@ async def _finalize_stream() -> None:
return wrapped_stream

async def _run() -> AgentResponse[Any]:
# Set the inner-telemetry context vars inside the coroutine so the set and the
# reset in `finally` always happen in the same execution context. `run()` is a sync
# method that returns this coroutine, which may be awaited in a different context than
# the one that called `run()` (e.g. `asyncio.create_task(agent.run(...))`, as used by
# BackgroundAgentsProvider). A contextvars.Token can only be reset in the context that
# created it, so setting eagerly in `run()`/`_trace_agent_invocation` and resetting
# here would raise "Token was created in a different Context".
inner_response_telemetry_captured_fields: set[str] = set()
inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(
inner_response_telemetry_captured_fields
)
inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({})
try:
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span:
try:
Expand Down
69 changes: 69 additions & 0 deletions python/packages/core/tests/core/test_observability.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.

import asyncio
import logging
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from typing import Any, cast
Expand Down Expand Up @@ -5249,6 +5250,74 @@ class FailingExecuteAgent(AgentTelemetryLayer, _FailingExecuteAgent): # type: i
assert agent_spans[0].status.status_code == StatusCode.ERROR


@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_run_contextvars_safe_when_awaited_in_different_context(
span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""``run()`` is a sync method that returns an awaitable; the telemetry contextvar set and reset
must happen in the same execution context so the returned coroutine can be awaited in a different
context.

Regression for background agents (``BackgroundAgentsProvider``), which do
``asyncio.create_task(agent.run(...))``: ``run()`` executes synchronously in the parent context
while the returned coroutine is awaited in a fresh copied context. If the contextvar token were
created eagerly in the parent context but reset inside the coroutine, this raised
``ValueError: <Token ...> was created in a different Context``.
"""

class _SimpleAgent:
AGENT_PROVIDER_NAME = "test_provider"

def __init__(self):
self._id = "simple"
self._name = "Simple"
self._description = "Agent that returns a response without raising"
self._default_options: dict[str, Any] = {}

@property
def id(self):
return self._id

@property
def name(self):
return self._name

@property
def description(self):
return self._description

@property
def default_options(self):
return self._default_options

def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
async def _inner() -> AgentResponse:
return AgentResponse(messages=[Message(role="assistant", contents=["hi"])])

return _inner()

class SimpleAgent(AgentTelemetryLayer, _SimpleAgent): # type: ignore[misc]
pass

agent = SimpleAgent()
span_exporter.clear()

# Mimic BackgroundAgentsProvider: invoke run() synchronously in this context, then await the
# returned coroutine inside a separate task (a different/copied context).
awaitable = agent.run(messages="Hello", stream=False)

async def _runner(aw):
return await aw

result = await asyncio.create_task(_runner(awaitable))
assert isinstance(result, AgentResponse)

spans = span_exporter.get_finished_spans()
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION] # type: ignore[union-attr] # ty: ignore[unresolved-attribute]
assert len(agent_spans) == 1
assert agent_spans[0].status.status_code != StatusCode.ERROR


# region Test heavy operations skipped when span is not recording
#
# When ``ENABLE_INSTRUMENTATION`` is on (the default) but no OpenTelemetry
Expand Down
Loading
Loading