Python: avoid duplicate agent response telemetry (#4685)

* Python: avoid duplicate agent response telemetry

* Python: conditionally suppress duplicate agent telemetry

* Simplify telemetry ownership tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Aggregate token usage from inner chat spans on invoke_agent span

The invoke_agent span now carries the aggregated input/output token
counts from all inner chat completion spans that occur during an agent
run. Previously, when inner ChatTelemetryLayer spans captured usage,
the outer AgentTelemetryLayer skipped setting usage entirely to avoid
duplication. Now a new INNER_ACCUMULATED_USAGE context variable tracks
cumulative usage across all inner completions, and the agent span
always reports the total.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-03-20 11:09:46 +01:00
committed by GitHub
Unverified
parent 8fc19a3437
commit 81e2336d47
2 changed files with 222 additions and 53 deletions
@@ -11,6 +11,7 @@ from opentelemetry.trace import StatusCode
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Agent,
AgentResponse,
BaseChatClient,
ChatResponse,
@@ -473,10 +474,10 @@ def mock_chat_agent():
@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True)
async def test_agent_instrumentation_enabled(
async def test_agent_span_captures_response_telemetry_without_inner_chat_span(
mock_chat_agent: SupportsAgentRun, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Test that when agent diagnostics are enabled, telemetry is applied."""
"""Agent spans should retain response telemetry when no inner chat span owns it."""
agent = mock_chat_agent()
@@ -492,6 +493,7 @@ async def test_agent_instrumentation_enabled(
assert span.attributes[OtelAttr.AGENT_NAME] == "test_agent"
assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description"
assert span.attributes[OtelAttr.REQUEST_MODEL] == "TestModel"
assert span.attributes[OtelAttr.RESPONSE_ID] == "test_response_id"
assert span.attributes[OtelAttr.INPUT_TOKENS] == 15
assert span.attributes[OtelAttr.OUTPUT_TOKENS] == 25
if enable_sensitive_data:
@@ -1700,6 +1702,24 @@ def test_get_response_attributes_capture_usage_false():
assert OtelAttr.OUTPUT_TOKENS not in result
def test_get_response_attributes_capture_response_id_false():
"""Test _get_response_attributes skips response_id when capture_response_id is False."""
from unittest.mock import Mock
from agent_framework.observability import OtelAttr, _get_response_attributes
response = Mock()
response.response_id = "resp_123"
response.finish_reason = None
response.raw_representation = None
response.usage_details = None
attrs = {}
result = _get_response_attributes(attrs, response, capture_response_id=False)
assert OtelAttr.RESPONSE_ID not in result
# region Test _get_exporters_from_env
@@ -2530,6 +2550,82 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter:
assert sorted_spans[2].name.startswith("chat"), f"Third span should be 'chat', got '{sorted_spans[2].name}'"
@pytest.mark.parametrize("stream", [False, True])
async def test_agent_and_chat_spans_do_not_duplicate_response_telemetry(
span_exporter: InMemorySpanExporter, stream: bool
):
"""The inner chat span owns response-id; usage is aggregated on the agent span."""
class NestedTelemetryChatClient(ChatTelemetryLayer, BaseChatClient[Any]):
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[Content.from_text("Nested")], role="assistant")
yield ChatResponseUpdate(contents=[Content.from_text(" response")], role="assistant")
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse(
messages=[Message(role="assistant", text="Nested response")],
response_id="nested_resp_123",
usage_details=UsageDetails(input_token_count=11, output_token_count=22),
finish_reason="stop",
)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
return ChatResponse(
messages=[Message(role="assistant", text="Nested response")],
response_id="nested_resp_123",
usage_details=UsageDetails(input_token_count=11, output_token_count=22),
finish_reason="stop",
)
return _get()
agent = Agent(
client=NestedTelemetryChatClient(),
id="nested_agent_id",
name="nested_agent",
description="Nested telemetry agent",
default_options={"model_id": "NestedModel"},
)
span_exporter.clear()
if stream:
result_stream = agent.run("Test message", stream=True)
async for _ in result_stream:
pass
response = await result_stream.get_final_response()
else:
response = await agent.run("Test message")
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 2
span_by_operation = {span.attributes[OtelAttr.OPERATION.value]: span for span in spans}
agent_span = span_by_operation[OtelAttr.AGENT_INVOKE_OPERATION]
chat_span = span_by_operation[OtelAttr.CHAT_COMPLETION_OPERATION]
assert chat_span.attributes[OtelAttr.RESPONSE_ID] == "nested_resp_123"
assert chat_span.attributes[OtelAttr.INPUT_TOKENS] == 11
assert chat_span.attributes[OtelAttr.OUTPUT_TOKENS] == 22
assert OtelAttr.RESPONSE_ID not in agent_span.attributes
# The agent span carries the aggregated usage from all inner chat completions
assert agent_span.attributes[OtelAttr.INPUT_TOKENS] == 11
assert agent_span.attributes[OtelAttr.OUTPUT_TOKENS] == 22
# region Test non-ASCII character handling in JSON serialization