Skip to content

Commit 3ebd31f

Browse files
committed
Align streaming_query otel spans with query
1 parent fa4401a commit 3ebd31f

4 files changed

Lines changed: 238 additions & 192 deletions

File tree

‎src/app/endpoints/streaming_query.py‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,12 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
166166
"""
167167
root_span = tracer.start_span("streaming_query.handle_request")
168168
try:
169-
return await _handle_streaming_query_with_tracing(
170-
request, query_request, auth, mcp_headers, root_span
171-
)
169+
with trace.use_span( # pylint: disable=not-context-manager
170+
root_span, end_on_exit=False
171+
):
172+
return await _handle_streaming_query_with_tracing(
173+
request, query_request, auth, mcp_headers, root_span
174+
)
172175
except Exception:
173176
root_span.end()
174177
raise
@@ -398,8 +401,8 @@ async def generate_response_with_compaction(
398401
context: ResponseGeneratorContext,
399402
responses_params: ResponsesApiParams,
400403
endpoint_path: str,
404+
root_span: trace.Span,
401405
image_attachments: Optional[list[Attachment]] = None,
402-
root_span: Optional[trace.Span] = None,
403406
) -> AsyncIterator[str]:
404407
"""Stream a response for a conversation that requires compaction.
405408
@@ -414,8 +417,8 @@ async def generate_response_with_compaction(
414417
context: The response generator context.
415418
responses_params: The base Responses API parameters.
416419
endpoint_path: API endpoint path used for metric labeling.
417-
image_attachments: Image attachments for multimodal prompt construction.
418420
root_span: OpenTelemetry root span for this request.
421+
image_attachments: Image attachments for multimodal prompt construction.
419422
420423
Yields:
421424
SSE-formatted strings.
@@ -497,12 +500,11 @@ async def generate_response_with_compaction(
497500
responses_params,
498501
turn_summary,
499502
background_topic_summary_tasks=_background_topic_summary_tasks,
503+
root_span=root_span,
500504
emit_start=False,
501505
original_input=compacted_original_input,
502-
root_span=root_span,
503506
context_status=context_status,
504507
):
505508
yield event
506509
finally:
507-
if root_span is not None:
508-
root_span.end()
510+
root_span.end()

‎src/utils/agents/streaming.py‎

Lines changed: 120 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
from utils.query import (
7777
build_multimodal_input,
7878
consume_query_tokens,
79+
extract_provider_and_model_from_model_id,
7980
store_query_results,
8081
)
8182
from utils.quota_utils import get_available_quotas
@@ -94,6 +95,7 @@
9495
type AgentDispatchEvent = AgentStreamEvent | AgentRunResultEvent
9596

9697
logger = get_logger(__name__)
98+
tracer = trace.get_tracer(__name__)
9799

98100
DEFAULT_REFUSAL_RESPONSE: Final[str] = (
99101
"I cannot process this request due to policy restrictions."
@@ -212,9 +214,9 @@ async def generate_agent_response( # pylint: disable=too-many-statements
212214
responses_params: ResponsesApiParams,
213215
turn_summary: TurnSummary,
214216
background_topic_summary_tasks: list[asyncio.Task[None]],
217+
root_span: trace.Span,
215218
emit_start: bool = True,
216219
original_input: Optional[ResponseInput] = None,
217-
root_span: Optional[trace.Span] = None,
218220
context_status: ContextStatus = "full",
219221
) -> AsyncIterator[str]:
220222
"""Wrap an agent SSE generator with cleanup logic.
@@ -229,12 +231,12 @@ async def generate_agent_response( # pylint: disable=too-many-statements
229231
turn_summary: TurnSummary populated during streaming.
230232
background_topic_summary_tasks: Mutable list tracking fire-and-forget
231233
topic summary tasks for graceful shutdown.
234+
root_span: OpenTelemetry root span for this request.
232235
emit_start: Whether to emit the SSE start event. False when the caller
233236
(the compaction-aware wrapper) has already emitted it.
234237
original_input: In compacted mode, the original user input before the
235238
explicit-input rewrite. Used to persist the completed turn with its
236239
structured input (preserving attachments); ``None`` otherwise.
237-
root_span: OpenTelemetry root span for this request.
238240
context_status: Whether the conversation context was sent in full
239241
("full") or older turns were replaced by a summary ("summarized").
240242
Reported to the client in the SSE end event.
@@ -260,8 +262,11 @@ async def generate_agent_response( # pylint: disable=too-many-statements
260262
media_type,
261263
)
262264
try:
263-
async for event in generator:
264-
yield event
265+
with trace.use_span( # pylint: disable=not-context-manager
266+
root_span, end_on_exit=False
267+
):
268+
async for event in generator:
269+
yield event
265270

266271
stream_completed = True
267272

@@ -301,8 +306,7 @@ async def generate_agent_response( # pylint: disable=too-many-statements
301306
deregister_stream(context.request_id)
302307

303308
if not stream_completed:
304-
if root_span is not None:
305-
root_span.end()
309+
root_span.end()
306310
return
307311

308312
await _persist_compacted_turn(
@@ -314,12 +318,15 @@ async def generate_agent_response( # pylint: disable=too-many-statements
314318
and bool(context.query_request.generate_topic_summary)
315319
)
316320
try:
317-
topic_summary = await maybe_get_topic_summary(
318-
generate_topic_summary=should_generate_topic_summary,
319-
input_text=context.query_request.query,
320-
client=context.client,
321-
model_id=responses_params.model,
322-
)
321+
with trace.use_span( # pylint: disable=not-context-manager
322+
root_span, end_on_exit=False
323+
):
324+
topic_summary = await maybe_get_topic_summary(
325+
generate_topic_summary=should_generate_topic_summary,
326+
input_text=context.query_request.query,
327+
client=context.client,
328+
model_id=responses_params.model,
329+
)
323330
except HTTPException as exc:
324331
logger.warning(
325332
"Topic summary failed for request %s: %s",
@@ -335,8 +342,7 @@ async def generate_agent_response( # pylint: disable=too-many-statements
335342
),
336343
media_type,
337344
)
338-
if root_span is not None:
339-
root_span.end()
345+
root_span.end()
340346
return
341347
logger.info("Consuming tokens")
342348
consume_query_tokens(
@@ -373,37 +379,22 @@ async def generate_agent_response( # pylint: disable=too-many-statements
373379
)
374380

375381
# Set final OTEL span attributes
376-
if root_span is not None:
377-
add_span_event(root_span, SpanEvents.TURN_PERSISTED)
378-
if turn_summary.tool_calls:
379-
tool_names = [tc.name for tc in turn_summary.tool_calls]
380-
set_span_attributes(
381-
root_span,
382-
{
383-
SpanAttributes.TOOL_CALLS_COUNT: len(tool_names),
384-
SpanAttributes.TOOL_CALLS_NAMES: tool_names,
385-
},
386-
)
387-
add_span_event(
388-
root_span,
389-
SpanEvents.TOOL_EXECUTION_COMPLETED,
390-
{"tool.calls": ", ".join(tool_names)},
391-
)
392-
set_span_attributes(
393-
root_span,
394-
{
395-
SpanAttributes.SESSION_ID: context.conversation_id,
396-
SpanAttributes.LLM_USAGE_INPUT_TOKENS: (
397-
turn_summary.token_usage.input_tokens
398-
),
399-
SpanAttributes.LLM_USAGE_OUTPUT_TOKENS: (
400-
turn_summary.token_usage.output_tokens
401-
),
402-
SpanAttributes.OUTPUT: turn_summary.llm_response,
403-
},
404-
)
405-
add_span_event(root_span, SpanEvents.LLM_RESPONSE_COMPLETED)
406-
root_span.end()
382+
add_span_event(root_span, SpanEvents.TURN_PERSISTED)
383+
set_span_attributes(
384+
root_span,
385+
{
386+
SpanAttributes.SESSION_ID: context.conversation_id,
387+
SpanAttributes.LLM_USAGE_INPUT_TOKENS: (
388+
turn_summary.token_usage.input_tokens
389+
),
390+
SpanAttributes.LLM_USAGE_OUTPUT_TOKENS: (
391+
turn_summary.token_usage.output_tokens
392+
),
393+
SpanAttributes.OUTPUT: turn_summary.llm_response,
394+
},
395+
)
396+
add_span_event(root_span, SpanEvents.LLM_RESPONSE_COMPLETED)
397+
root_span.end()
407398

408399
logger.info("Agent streaming complete")
409400

@@ -429,57 +420,97 @@ async def agent_response_generator(
429420
Yields:
430421
Serialized SSE event strings.
431422
"""
432-
media_type = context.query_request.media_type or MEDIA_TYPE_JSON
433-
dispatch_state = AgentTurnAccumulator(
434-
vector_store_ids=context.vector_store_ids,
435-
rag_id_mapping=context.rag_id_mapping,
436-
turn_summary=turn_summary,
437-
)
438-
reject_image_attachments_in_compacted_mode(responses_params, image_attachments)
439-
if image_attachments:
440-
prompt = build_multimodal_input(
441-
agent_prompt_text(responses_params),
442-
image_attachments,
423+
with tracer.start_as_current_span("llm.inference") as span:
424+
provider_id, model_id = extract_provider_and_model_from_model_id(
425+
responses_params.model
443426
)
444-
else:
445-
prompt = agent_prompt_text(responses_params)
427+
set_span_attributes(
428+
span,
429+
{
430+
SpanAttributes.LLM_MODEL_ID: model_id,
431+
SpanAttributes.LLM_PROVIDER_ID: provider_id,
432+
},
433+
)
434+
add_span_event(span, SpanEvents.LLM_INFERENCE_STARTED)
446435

447-
logger.debug("Starting agent streaming response processing")
448-
async with agent.run_stream_events(prompt) as stream:
449-
async for event in stream:
450-
if payload := dispatch_stream_event(event, dispatch_state):
451-
yield serialize_event(payload, media_type)
436+
media_type = context.query_request.media_type or MEDIA_TYPE_JSON
437+
dispatch_state = AgentTurnAccumulator(
438+
vector_store_ids=context.vector_store_ids,
439+
rag_id_mapping=context.rag_id_mapping,
440+
turn_summary=turn_summary,
441+
)
442+
reject_image_attachments_in_compacted_mode(responses_params, image_attachments)
443+
if image_attachments:
444+
prompt = build_multimodal_input(
445+
agent_prompt_text(responses_params),
446+
image_attachments,
447+
)
448+
else:
449+
prompt = agent_prompt_text(responses_params)
450+
451+
logger.debug("Starting agent streaming response processing")
452+
async with agent.run_stream_events(prompt) as stream:
453+
async for event in stream:
454+
if payload := dispatch_stream_event(event, dispatch_state):
455+
yield serialize_event(payload, media_type)
456+
457+
# Capture the structured output items OGX returned so compacted mode can
458+
# persist the turn exactly as OGX would have (LCORE-3883).
459+
turn_summary.output_items = captured_output_items(agent)
460+
461+
if dispatch_state.run_result is None:
462+
logger.error("No final result received from agent run")
463+
return
464+
465+
run_result = dispatch_state.run_result
466+
turn_summary.token_usage = extract_agent_token_usage(
467+
run_result.usage,
468+
responses_params.model,
469+
endpoint_path,
470+
)
452471

453-
# Capture the structured output items OGX returned so compacted mode can
454-
# persist the turn exactly as OGX would have (LCORE-3883).
455-
turn_summary.output_items = captured_output_items(agent)
472+
set_span_attributes(
473+
span,
474+
{
475+
SpanAttributes.LLM_USAGE_INPUT_TOKENS: run_result.usage.input_tokens,
476+
SpanAttributes.LLM_USAGE_OUTPUT_TOKENS: run_result.usage.output_tokens,
477+
},
478+
)
456479

457-
if dispatch_state.run_result is None:
458-
logger.error("No final result received from agent run")
459-
return
480+
if turn_summary.tool_calls:
481+
tool_names = [tc.name for tc in turn_summary.tool_calls]
482+
set_span_attributes(
483+
span,
484+
{
485+
SpanAttributes.TOOL_CALLS_COUNT: len(tool_names),
486+
SpanAttributes.TOOL_CALLS_NAMES: tool_names,
487+
},
488+
)
489+
add_span_event(
490+
span,
491+
SpanEvents.TOOL_EXECUTION_COMPLETED,
492+
{"tool.calls": ", ".join(tool_names)},
493+
)
460494

461-
run_result = dispatch_state.run_result
462-
turn_summary.token_usage = extract_agent_token_usage(
463-
run_result.usage,
464-
responses_params.model,
465-
endpoint_path,
466-
)
495+
add_span_event(span, SpanEvents.LLM_INFERENCE_COMPLETED)
467496

468-
finish_reason = get_agent_finish_reason(run_result.response)
469-
if finish_reason != AgentFinishReason.SUCCESS:
470-
error_response = get_finish_reason_error(finish_reason, responses_params.model)
471-
yield serialize_event(
472-
ErrorStreamPayload.from_error_response(error_response),
473-
media_type,
474-
)
497+
finish_reason = get_agent_finish_reason(run_result.response)
498+
if finish_reason != AgentFinishReason.SUCCESS:
499+
error_response = get_finish_reason_error(
500+
finish_reason, responses_params.model
501+
)
502+
yield serialize_event(
503+
ErrorStreamPayload.from_error_response(error_response),
504+
media_type,
505+
)
475506

476-
turn_summary.referenced_documents = deduplicate_referenced_documents(
477-
context.inline_rag_context.referenced_documents
478-
+ turn_summary.referenced_documents
479-
)
480-
turn_summary.rag_chunks = (
481-
context.inline_rag_context.rag_chunks + turn_summary.rag_chunks
482-
)
507+
turn_summary.referenced_documents = deduplicate_referenced_documents(
508+
context.inline_rag_context.referenced_documents
509+
+ turn_summary.referenced_documents
510+
)
511+
turn_summary.rag_chunks = (
512+
context.inline_rag_context.rag_chunks + turn_summary.rag_chunks
513+
)
483514

484515

485516
def serialize_event(

‎tests/unit/app/endpoints/test_streaming_query.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Unit tests for the /streaming_query (v2) endpoint using Responses API."""
22

3+
# pylint: disable=too-many-lines
4+
35
from collections.abc import AsyncIterator
46
from typing import Any
57

@@ -11,6 +13,7 @@
1113
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
1214
InMemorySpanExporter,
1315
)
16+
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
1417
from pytest_mock import MockerFixture
1518

1619
from app.endpoints.streaming_query import (
@@ -993,6 +996,14 @@ async def fake_generate_agent_response(
993996
context=context,
994997
responses_params=responses_params,
995998
endpoint_path="/v1/streaming_query",
999+
root_span=NonRecordingSpan(
1000+
SpanContext(
1001+
trace_id=0x1,
1002+
span_id=0x2,
1003+
is_remote=False,
1004+
trace_flags=TraceFlags(0x01),
1005+
)
1006+
),
9961007
)
9971008
]
9981009

0 commit comments

Comments
 (0)