Skip to content

Commit 7303d2c

Browse files
authored
Merge pull request #2415 from anik120/otel-for-streamingq
LCORE-2981: Add OpenTelemetry instrumentation for POST /v1/streaming_query endpoint
2 parents c30d443 + dcad0eb commit 7303d2c

5 files changed

Lines changed: 876 additions & 73 deletions

File tree

src/app/endpoints/streaming_query.py

Lines changed: 134 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
APIStatusError as LLSApiStatusError,
1515
)
1616
from openai._exceptions import APIStatusError as OpenAIAPIStatusError
17+
from opentelemetry import trace
1718

1819
from authentication import get_auth_dependency
1920
from authentication.interface import AuthTuple
@@ -65,6 +66,13 @@
6566
)
6667
from utils.mcp_headers import McpHeaders, mcp_headers_dependency
6768
from utils.mcp_oauth_probe import check_mcp_auth
69+
from utils.otel_tracing import (
70+
SpanAttributes,
71+
SpanEvents,
72+
add_span_event,
73+
anonymize_value,
74+
set_span_attributes,
75+
)
6876
from utils.query import (
6977
extract_provider_and_model_from_model_id,
7078
handle_known_apistatus_errors,
@@ -93,6 +101,7 @@
93101
from utils.vector_search import build_rag_context
94102

95103
logger = get_logger(__name__)
104+
tracer = trace.get_tracer(__name__)
96105
router = APIRouter(tags=["streaming_query"])
97106

98107
# Tracks background topic summary tasks for graceful shutdown.
@@ -158,11 +167,55 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
158167
- 500: Internal Server Error - Configuration not loaded or other server errors
159168
- 503: Service Unavailable - Unable to connect to OGX backend
160169
"""
170+
root_span = tracer.start_span("streaming_query.handle_request")
171+
try:
172+
return await _handle_streaming_query_with_tracing(
173+
request, query_request, auth, mcp_headers, root_span
174+
)
175+
except Exception:
176+
root_span.end()
177+
raise
178+
179+
180+
async def _handle_streaming_query_with_tracing( # pylint: disable=too-many-locals
181+
request: Request,
182+
query_request: QueryRequest,
183+
auth: AuthTuple,
184+
mcp_headers: McpHeaders,
185+
root_span: trace.Span,
186+
) -> StreamingResponse:
187+
"""Handle streaming query request with OTEL tracing instrumentation.
188+
189+
Parameters:
190+
request: The incoming HTTP request.
191+
query_request: Request payload containing query and optional parameters.
192+
auth: Authentication tuple (user_id, username, skip_check, token).
193+
mcp_headers: Headers to be passed to MCP servers.
194+
root_span: OpenTelemetry root span for this request.
195+
196+
Returns:
197+
StreamingResponse with SSE-formatted events.
198+
199+
Raises:
200+
HTTPException: On authentication, authorization, quota, or model errors.
201+
"""
161202
check_configuration_loaded(configuration)
162203

163204
user_id, _user_name, _skip_userid_check, token = auth
164205
started_at = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
165206

207+
# Set initial span attributes
208+
set_span_attributes(
209+
root_span,
210+
{
211+
SpanAttributes.USER_ID: anonymize_value(user_id),
212+
SpanAttributes.INPUT: anonymize_value(query_request.query),
213+
SpanAttributes.REQUEST_ATTACHMENTS_COUNT: (
214+
len(query_request.attachments) if query_request.attachments else 0
215+
),
216+
},
217+
)
218+
166219
# Check MCP Auth
167220
await check_mcp_auth(configuration, mcp_headers, token, request.headers)
168221

@@ -181,6 +234,9 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
181234
if query_request.attachments:
182235
validate_attachments_metadata(query_request.attachments)
183236

237+
# Validation completed
238+
add_span_event(root_span, SpanEvents.VALIDATION_COMPLETED)
239+
184240
# Retrieve conversation if conversation_id is provided
185241
user_conversation = None
186242
if query_request.conversation_id:
@@ -291,6 +347,7 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
291347
responses_params=responses_params,
292348
endpoint_path=endpoint_path,
293349
image_attachments=image_attachments,
350+
root_span=root_span,
294351
),
295352
media_type=response_media_type,
296353
)
@@ -316,6 +373,7 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
316373
responses_params=responses_params,
317374
turn_summary=turn_summary,
318375
background_topic_summary_tasks=_background_topic_summary_tasks,
376+
root_span=root_span,
319377
),
320378
media_type=response_media_type,
321379
)
@@ -344,6 +402,7 @@ async def generate_response_with_compaction(
344402
responses_params: ResponsesApiParams,
345403
endpoint_path: str,
346404
image_attachments: Optional[list[Attachment]] = None,
405+
root_span: Optional[trace.Span] = None,
347406
) -> AsyncIterator[str]:
348407
"""Stream a response for a conversation that requires compaction.
349408
@@ -359,79 +418,85 @@ async def generate_response_with_compaction(
359418
responses_params: The base Responses API parameters.
360419
endpoint_path: API endpoint path used for metric labeling.
361420
image_attachments: Image attachments for multimodal prompt construction.
421+
root_span: OpenTelemetry root span for this request.
362422
363423
Yields:
364424
SSE-formatted strings.
365425
"""
366-
media_type = context.query_request.media_type or MEDIA_TYPE_JSON
367-
yield stream_start_event(
368-
conversation_id=context.conversation_id,
369-
request_id=context.request_id,
370-
)
371-
372-
compacted_original_input: Optional[ResponseInput] = None
373426
try:
374-
async for item in apply_compaction(
375-
context.client,
376-
responses_params,
377-
configuration.inference,
378-
configuration.compaction,
379-
emit_events=True,
380-
cache=configured_conversation_cache(),
381-
user_id=context.user_id,
382-
skip_user_id_check=context.skip_userid_check,
383-
):
384-
if isinstance(item, CompactionStartedEvent):
385-
yield stream_compaction_event(context.conversation_id)
386-
elif isinstance(item, CompactionResult):
387-
responses_params = item.params
388-
compacted_original_input = item.original_input
389-
390-
generator, turn_summary = await retrieve_agent_response_generator(
391-
responses_params=responses_params,
392-
context=context,
393-
endpoint_path=endpoint_path,
394-
image_attachments=image_attachments,
395-
)
396-
except HTTPException as e:
397-
yield http_exception_stream_event(e)
398-
return
399-
except RuntimeError as e: # library mode wraps 413 into runtime error
400-
error_response = (
401-
PromptTooLongResponse(model=responses_params.model)
402-
if is_context_length_error(str(e))
403-
else InternalServerErrorResponse.generic()
404-
)
405-
yield stream_http_error_event(error_response, media_type)
406-
return
407-
except APIConnectionError as e:
408-
yield stream_http_error_event(
409-
ServiceUnavailableResponse(backend_name="OGX", cause=str(e)),
410-
media_type,
411-
)
412-
return
413-
except (LLSApiStatusError, OpenAIAPIStatusError) as e:
414-
yield stream_http_error_event(
415-
handle_known_apistatus_errors(e, responses_params.model), media_type
416-
)
417-
return
418-
419-
# Combine inline RAG results (BYOK + Solr) with tool-based results
420-
if context.moderation_result.decision == "passed":
421-
turn_summary.referenced_documents = deduplicate_referenced_documents(
422-
context.inline_rag_context.referenced_documents
423-
+ turn_summary.referenced_documents
427+
media_type = context.query_request.media_type or MEDIA_TYPE_JSON
428+
yield stream_start_event(
429+
conversation_id=context.conversation_id,
430+
request_id=context.request_id,
424431
)
425432

426-
# The start event was already emitted above; delegate the rest (re-yield,
427-
# finalization, compacted-turn storage) to the shared generator.
428-
async for event in generate_agent_response(
429-
generator,
430-
context,
431-
responses_params,
432-
turn_summary,
433-
background_topic_summary_tasks=_background_topic_summary_tasks,
434-
emit_start=False,
435-
original_input=compacted_original_input,
436-
):
437-
yield event
433+
compacted_original_input: Optional[ResponseInput] = None
434+
try:
435+
async for item in apply_compaction(
436+
context.client,
437+
responses_params,
438+
configuration.inference,
439+
configuration.compaction,
440+
emit_events=True,
441+
cache=configured_conversation_cache(),
442+
user_id=context.user_id,
443+
skip_user_id_check=context.skip_userid_check,
444+
):
445+
if isinstance(item, CompactionStartedEvent):
446+
yield stream_compaction_event(context.conversation_id)
447+
elif isinstance(item, CompactionResult):
448+
responses_params = item.params
449+
compacted_original_input = item.original_input
450+
451+
generator, turn_summary = await retrieve_agent_response_generator(
452+
responses_params=responses_params,
453+
context=context,
454+
endpoint_path=endpoint_path,
455+
image_attachments=image_attachments,
456+
)
457+
except HTTPException as e:
458+
yield http_exception_stream_event(e)
459+
return
460+
except RuntimeError as e: # library mode wraps 413 into runtime error
461+
error_response = (
462+
PromptTooLongResponse(model=responses_params.model)
463+
if is_context_length_error(str(e))
464+
else InternalServerErrorResponse.generic()
465+
)
466+
yield stream_http_error_event(error_response, media_type)
467+
return
468+
except APIConnectionError as e:
469+
yield stream_http_error_event(
470+
ServiceUnavailableResponse(backend_name="OGX", cause=str(e)),
471+
media_type,
472+
)
473+
return
474+
except (LLSApiStatusError, OpenAIAPIStatusError) as e:
475+
yield stream_http_error_event(
476+
handle_known_apistatus_errors(e, responses_params.model), media_type
477+
)
478+
return
479+
480+
# Combine inline RAG results (BYOK + Solr) with tool-based results
481+
if context.moderation_result.decision == "passed":
482+
turn_summary.referenced_documents = deduplicate_referenced_documents(
483+
context.inline_rag_context.referenced_documents
484+
+ turn_summary.referenced_documents
485+
)
486+
487+
# The start event was already emitted above; delegate the rest (re-yield,
488+
# finalization, compacted-turn storage) to the shared generator.
489+
async for event in generate_agent_response(
490+
generator,
491+
context,
492+
responses_params,
493+
turn_summary,
494+
background_topic_summary_tasks=_background_topic_summary_tasks,
495+
emit_start=False,
496+
original_input=compacted_original_input,
497+
root_span=root_span,
498+
):
499+
yield event
500+
finally:
501+
if root_span is not None:
502+
root_span.end()

src/utils/agents/streaming.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from fastapi import HTTPException
1414
from ogx_client import APIConnectionError, APIStatusError
15+
from opentelemetry import trace
1516
from pydantic_ai import Agent, AgentRunError, AgentRunResultEvent, ToolReturnPart
1617
from pydantic_ai.messages import (
1718
AgentStreamEvent,
@@ -60,6 +61,13 @@
6061
process_native_tool_result,
6162
)
6263
from utils.conversations import append_turn_items_to_conversation
64+
from utils.otel_tracing import (
65+
SpanAttributes,
66+
SpanEvents,
67+
add_span_event,
68+
anonymize_value,
69+
set_span_attributes,
70+
)
6371
from utils.pydantic_ai_helpers import build_agent
6472
from utils.query import (
6573
build_multimodal_input,
@@ -153,14 +161,15 @@ async def retrieve_agent_response_generator(
153161
raise HTTPException(**response.model_dump()) from exc
154162

155163

156-
async def generate_agent_response(
164+
async def generate_agent_response( # pylint: disable=too-many-statements
157165
generator: AsyncIterator[str],
158166
context: ResponseGeneratorContext,
159167
responses_params: ResponsesApiParams,
160168
turn_summary: TurnSummary,
161169
background_topic_summary_tasks: list[asyncio.Task[None]],
162170
emit_start: bool = True,
163171
original_input: Optional[ResponseInput] = None,
172+
root_span: Optional[trace.Span] = None,
164173
) -> AsyncIterator[str]:
165174
"""Wrap an agent SSE generator with cleanup logic.
166175
@@ -179,6 +188,8 @@ async def generate_agent_response(
179188
original_input: In compacted mode, the original user input before the
180189
explicit-input rewrite. Used to persist the completed turn with its
181190
structured input (preserving attachments); ``None`` otherwise.
191+
root_span: OpenTelemetry root span for this request.
192+
182193
Yields:
183194
SSE-formatted strings from the wrapped generator.
184195
"""
@@ -241,6 +252,8 @@ async def generate_agent_response(
241252
deregister_stream(context.request_id)
242253

243254
if not stream_completed:
255+
if root_span is not None:
256+
root_span.end()
244257
return
245258

246259
should_generate_topic_summary = (
@@ -269,6 +282,8 @@ async def generate_agent_response(
269282
),
270283
media_type,
271284
)
285+
if root_span is not None:
286+
root_span.end()
272287
return
273288
logger.info("Consuming tokens")
274289
consume_query_tokens(
@@ -302,6 +317,40 @@ async def generate_agent_response(
302317
skip_userid_check=context.skip_userid_check,
303318
topic_summary=topic_summary,
304319
)
320+
321+
# Set final OTEL span attributes
322+
if root_span is not None:
323+
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+
)
338+
set_span_attributes(
339+
root_span,
340+
{
341+
SpanAttributes.SESSION_ID: context.conversation_id,
342+
SpanAttributes.LLM_USAGE_INPUT_TOKENS: (
343+
turn_summary.token_usage.input_tokens
344+
),
345+
SpanAttributes.LLM_USAGE_OUTPUT_TOKENS: (
346+
turn_summary.token_usage.output_tokens
347+
),
348+
SpanAttributes.OUTPUT: anonymize_value(turn_summary.llm_response),
349+
},
350+
)
351+
add_span_event(root_span, SpanEvents.LLM_RESPONSE_COMPLETED)
352+
root_span.end()
353+
305354
logger.info("Agent streaming complete")
306355

307356

0 commit comments

Comments
 (0)