Skip to content

Commit dcad0eb

Browse files
committed
emit tool call OTEL attributes on streaming query root span
1 parent 46e385b commit dcad0eb

2 files changed

Lines changed: 89 additions & 1 deletion

File tree

src/utils/agents/streaming.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,20 @@ async def generate_agent_response( # pylint: disable=too-many-statements
321321
# Set final OTEL span attributes
322322
if root_span is not None:
323323
add_span_event(root_span, SpanEvents.TURN_PERSISTED)
324+
if turn_summary.tool_calls:
325+
tool_names = [tc.name for tc in turn_summary.tool_calls]
326+
set_span_attributes(
327+
root_span,
328+
{
329+
SpanAttributes.TOOL_CALLS_COUNT: len(tool_names),
330+
SpanAttributes.TOOL_CALLS_NAMES: tool_names,
331+
},
332+
)
333+
add_span_event(
334+
root_span,
335+
SpanEvents.TOOL_EXECUTION_COMPLETED,
336+
{"tool.calls": ", ".join(tool_names)},
337+
)
324338
set_span_attributes(
325339
root_span,
326340
{

tests/unit/utils/agents/test_streaming.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from models.common.query import Attachment as QueryAttachment
5656
from models.common.responses.contexts import ResponseGeneratorContext
5757
from models.common.responses.responses_api_params import ResponsesApiParams
58-
from models.common.turn_summary import RAGContext, TurnSummary
58+
from models.common.turn_summary import RAGContext, ToolCallSummary, TurnSummary
5959
from utils.agents.query import AgentFinishReason
6060
from utils.agents.streaming import (
6161
DEFAULT_REFUSAL_RESPONSE,
@@ -884,6 +884,80 @@ async def inner() -> AsyncIterator[str]:
884884
assert SpanEvents.TURN_PERSISTED in event_names
885885
assert SpanEvents.LLM_RESPONSE_COMPLETED in event_names
886886

887+
@pytest.mark.asyncio
888+
async def test_sets_tool_call_span_attributes(
889+
self,
890+
mocker: MockerFixture,
891+
make_generator_context: Callable[..., ResponseGeneratorContext],
892+
responses_params: ResponsesApiParams,
893+
otel: tuple[Any, InMemorySpanExporter],
894+
) -> None:
895+
"""Test that tool call OTEL attributes are emitted on the root span."""
896+
tracer, exporter = otel
897+
context = make_generator_context()
898+
turn_summary = TurnSummary()
899+
turn_summary.token_usage = TokenCounter(input_tokens=10, output_tokens=5)
900+
turn_summary.llm_response = "Result"
901+
turn_summary.tool_calls = [
902+
ToolCallSummary(id="tc-1", name="web_search", type="web_search_call"),
903+
ToolCallSummary(id="tc-2", name="file_search", type="file_search_call"),
904+
]
905+
background_tasks: list[asyncio.Task[None]] = []
906+
root_span = tracer.start_span("streaming_query.handle_request")
907+
908+
async def inner() -> AsyncIterator[str]:
909+
yield serialize_event(
910+
TokenStreamPayload.create(chunk_id=0, token="Hi"),
911+
MEDIA_TYPE_JSON,
912+
)
913+
914+
mocker.patch("utils.agents.streaming.consume_query_tokens")
915+
mocker.patch(
916+
"utils.agents.streaming.get_available_quotas",
917+
return_value={"daily": 100},
918+
)
919+
mocker.patch(
920+
"utils.agents.streaming.maybe_get_topic_summary",
921+
new=mocker.AsyncMock(return_value=None),
922+
)
923+
mocker.patch("utils.agents.streaming.store_query_results")
924+
mock_config = mocker.Mock()
925+
mock_config.quota_limiters = []
926+
mocker.patch("utils.agents.streaming.configuration", mock_config)
927+
mocker.patch(
928+
"utils.agents.streaming.anonymize_value",
929+
side_effect=lambda v: f"[anon:{v}]",
930+
)
931+
932+
[
933+
event
934+
async for event in generate_agent_response(
935+
inner(),
936+
context,
937+
responses_params,
938+
turn_summary,
939+
background_tasks,
940+
root_span=root_span,
941+
)
942+
]
943+
944+
spans = exporter.get_finished_spans()
945+
assert len(spans) == 1
946+
span = spans[0]
947+
assert span.attributes is not None
948+
assert span.attributes[SpanAttributes.TOOL_CALLS_COUNT] == 2
949+
assert span.attributes[SpanAttributes.TOOL_CALLS_NAMES] == (
950+
"web_search",
951+
"file_search",
952+
)
953+
event_names = [e.name for e in span.events]
954+
assert SpanEvents.TOOL_EXECUTION_COMPLETED in event_names
955+
tool_event = next(
956+
e for e in span.events if e.name == SpanEvents.TOOL_EXECUTION_COMPLETED
957+
)
958+
assert tool_event.attributes is not None
959+
assert tool_event.attributes["tool.calls"] == "web_search, file_search"
960+
887961
@pytest.mark.asyncio
888962
async def test_span_ended_on_stream_error(
889963
self,

0 commit comments

Comments
 (0)