Skip to content

Commit 3e485c6

Browse files
committed
LCORE-1792: Add OpenTelemetry instrumentation for POST /v1/query endpoint
Implements comprehensive OpenTelemetry (OTEL) tracing instrumentation for the POST /v1/query endpoint to enable distributed tracing and observability. **Instrumented Components** 1. Query Endpoint Handler (`src/app/endpoints/query.py`) 2. Quota Check (`src/utils/quota_utils.py`) 3. Shield Moderation (`src/utils/shields.py`) 4. RAG Retrieval (`src/utils/vector_search.py`) 5. LLM Inference (`src/utils/agents/query.py`) 6. Tool Execution (`src/utils/agents/query.py`) **Span Hierarchy** ``` query.handle_request (root span) ├── quota.check ├── shield.moderate ├── rag.retrieve └── llm.inference └── tool.execution (attributes only) ```
1 parent 1af3f85 commit 3e485c6

8 files changed

Lines changed: 712 additions & 97 deletions

File tree

src/app/endpoints/query.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Annotated, Any
55

66
from fastapi import APIRouter, Depends, Request
7+
from opentelemetry import trace
78

89
from authentication import get_auth_dependency
910
from authentication.interface import AuthTuple
@@ -38,6 +39,13 @@
3839
)
3940
from utils.mcp_headers import McpHeaders, mcp_headers_dependency
4041
from utils.mcp_oauth_probe import check_mcp_auth
42+
from utils.otel_tracing import (
43+
SpanAttributes,
44+
SpanEvents,
45+
add_span_event,
46+
anonymize_value,
47+
set_span_attributes,
48+
)
4149
from utils.query import (
4250
consume_query_tokens,
4351
prepare_input,
@@ -56,6 +64,7 @@
5664
from utils.vector_search import build_rag_context
5765

5866
logger = get_logger(__name__)
67+
tracer = trace.get_tracer(__name__)
5968
router = APIRouter(tags=["query"])
6069

6170
query_response: dict[int | str, dict[str, Any]] = {
@@ -113,11 +122,51 @@ async def query_endpoint_handler(
113122
- 500: Internal Server Error - Configuration not loaded or other server errors
114123
- 503: Service Unavailable - Unable to connect to OGX backend
115124
"""
125+
with tracer.start_as_current_span("query.handle_request") as root_span:
126+
return await _handle_query_with_tracing(
127+
request, query_request, auth, mcp_headers, root_span
128+
)
129+
130+
131+
async def _handle_query_with_tracing(
132+
request: Request,
133+
query_request: QueryRequest,
134+
auth: AuthTuple,
135+
mcp_headers: McpHeaders,
136+
root_span: trace.Span,
137+
) -> QueryResponse:
138+
"""Handle query request with OTEL tracing instrumentation.
139+
140+
Parameters:
141+
request: The incoming HTTP request.
142+
query_request: Request payload containing query and optional parameters.
143+
auth: Authentication tuple (user_id, username, skip_check, token).
144+
mcp_headers: Headers to be passed to MCP servers.
145+
root_span: OpenTelemetry root span for this request.
146+
147+
Returns:
148+
QueryResponse containing conversation ID, LLM response, and metadata.
149+
150+
Raises:
151+
HTTPException: On authentication, authorization, quota, or model errors.
152+
"""
116153
check_configuration_loaded(configuration)
117154

118155
started_at = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
119156
user_id, _, _skip_userid_check, token = auth
120157

158+
# Set initial span attributes
159+
set_span_attributes(
160+
root_span,
161+
{
162+
SpanAttributes.USER_ID: anonymize_value(user_id),
163+
SpanAttributes.INPUT: anonymize_value(query_request.query),
164+
SpanAttributes.REQUEST_ATTACHMENTS_COUNT: (
165+
len(query_request.attachments) if query_request.attachments else 0
166+
),
167+
},
168+
)
169+
121170
# Check MCP Auth
122171
await check_mcp_auth(configuration, mcp_headers, token, request.headers)
123172

@@ -136,6 +185,9 @@ async def query_endpoint_handler(
136185
if query_request.attachments:
137186
validate_attachments_metadata(query_request.attachments)
138187

188+
# Validation completed
189+
add_span_event(root_span, SpanEvents.VALIDATION_COMPLETED)
190+
139191
# Retrieve conversation if conversation_id is provided
140192
user_conversation = None
141193
if query_request.conversation_id:
@@ -279,6 +331,22 @@ async def query_endpoint_handler(
279331
)
280332

281333
logger.info("Building final response")
334+
335+
# Set final span attributes
336+
set_span_attributes(
337+
root_span,
338+
{
339+
SpanAttributes.SESSION_ID: conversation_id,
340+
SpanAttributes.LLM_USAGE_INPUT_TOKENS: turn_summary.token_usage.input_tokens,
341+
SpanAttributes.LLM_USAGE_OUTPUT_TOKENS: turn_summary.token_usage.output_tokens,
342+
SpanAttributes.OUTPUT: anonymize_value(turn_summary.llm_response),
343+
},
344+
)
345+
346+
# Emit final events
347+
add_span_event(root_span, SpanEvents.LLM_RESPONSE_COMPLETED)
348+
add_span_event(root_span, SpanEvents.TURN_PERSISTED)
349+
282350
return QueryResponse(
283351
conversation_id=conversation_id,
284352
response=turn_summary.llm_response,

src/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@
259259
# Special RAG ID that activates the OKP provider when listed in rag.inline or rag.tool
260260
OKP_RAG_ID: Final[str] = "okp"
261261

262+
# OpenTelemetry anonymization configuration
263+
# Environment variable for HMAC secret used to anonymize sensitive trace data
264+
OTEL_ANONYMIZATION_SECRET_ENV_VAR: Final[str] = "OTEL_ANONYMIZATION_SECRET"
265+
262266
# Logging configuration constants
263267
# Environment variable name for configurable log level
264268
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR: Final[str] = "LIGHTSPEED_STACK_LOG_LEVEL"

src/utils/agents/query.py

Lines changed: 106 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from fastapi import HTTPException
99
from ogx_client import APIConnectionError, APIStatusError, AsyncOgxClient
10+
from opentelemetry import trace
1011
from pydantic_ai.exceptions import (
1112
AgentRunError,
1213
)
@@ -36,6 +37,12 @@
3637
process_native_tool_result,
3738
)
3839
from utils.conversations import append_turn_items_to_conversation
40+
from utils.otel_tracing import (
41+
SpanAttributes,
42+
SpanEvents,
43+
add_span_event,
44+
set_span_attributes,
45+
)
3946
from utils.pydantic_ai_helpers import build_agent
4047
from utils.query import (
4148
build_multimodal_input,
@@ -45,6 +52,7 @@
4552
from utils.token_counter import TokenCounter
4653

4754
logger = get_logger(__name__)
55+
tracer = trace.get_tracer(__name__)
4856

4957
AgentInferenceError: TypeAlias = (
5058
AgentRunError | APIStatusError | APIConnectionError | RuntimeError
@@ -180,20 +188,41 @@ def build_turn_summary_from_agent_run(
180188
turn_summary=TurnSummary(),
181189
)
182190

191+
# Track tool calls for OTEL instrumentation
192+
tool_call_names: list[str] = []
193+
183194
for message in run_result.new_messages():
184195
if isinstance(message, ModelResponse):
185196
if message.text:
186197
state.turn_summary.llm_response = message.text
187198
for tool_call_part in message.tool_calls:
188199
process_function_tool_call(state, tool_call_part)
200+
tool_call_names.append(tool_call_part.tool_name)
189201
for call_part, return_part in message.native_tool_calls:
190202
process_native_tool_call(state, call_part)
191203
process_native_tool_result(state, return_part)
204+
tool_call_names.append(call_part.tool_name)
192205
elif isinstance(message, ModelRequest):
193206
for request_part in message.parts:
194207
if isinstance(request_part, ToolReturnPart):
195208
process_function_tool_result(state, request_part)
196209

210+
# Add tool execution attributes to current span (parent llm.inference span)
211+
current_span = trace.get_current_span()
212+
if current_span.is_recording() and tool_call_names:
213+
set_span_attributes(
214+
current_span,
215+
{
216+
SpanAttributes.TOOL_CALLS_COUNT: len(tool_call_names),
217+
SpanAttributes.TOOL_CALLS_NAMES: tool_call_names,
218+
},
219+
)
220+
add_span_event(
221+
current_span,
222+
SpanEvents.TOOL_EXECUTION_COMPLETED,
223+
{"tool.calls": ", ".join(tool_call_names)},
224+
)
225+
197226
state.turn_summary.id = run_result.response.provider_response_id or ""
198227
state.turn_summary.token_usage = extract_agent_token_usage(
199228
run_result.usage,
@@ -231,44 +260,83 @@ async def retrieve_agent_response(
231260
Raises:
232261
HTTPException: On moderation is not applicable; on agent or provider failure.
233262
"""
234-
if moderation_result.decision == "blocked":
235-
await append_turn_items_to_conversation(
236-
client,
237-
responses_params.conversation,
238-
responses_params.input,
239-
[moderation_result.refusal_response],
263+
with tracer.start_as_current_span("llm.inference") as span:
264+
# Extract provider and model from model_id
265+
provider_id, model_id = extract_provider_and_model_from_model_id(
266+
responses_params.model
240267
)
241-
return TurnSummary(
242-
id=moderation_result.moderation_id,
243-
llm_response=moderation_result.message,
244-
)
245-
try:
246-
agent = build_agent(
247-
client,
248-
responses_params,
249-
configuration,
250-
shields=shield_ids,
251-
no_tools=no_tools,
268+
269+
# Set LLM attributes
270+
set_span_attributes(
271+
span,
272+
{
273+
SpanAttributes.LLM_MODEL_ID: model_id,
274+
SpanAttributes.LLM_PROVIDER_ID: provider_id,
275+
},
252276
)
253-
logger.debug("Starting agent non-streaming response processing")
254-
if image_attachments:
255-
prompt = build_multimodal_input(
256-
cast(str, responses_params.input),
257-
image_attachments,
277+
278+
if moderation_result.decision == "blocked":
279+
await append_turn_items_to_conversation(
280+
client,
281+
responses_params.conversation,
282+
responses_params.input,
283+
[moderation_result.refusal_response],
258284
)
259-
else:
260-
prompt = cast(str, responses_params.input)
261-
run_result = await agent.run(prompt)
262-
except (AgentRunError, APIStatusError, APIConnectionError, RuntimeError) as exc:
263-
response = map_agent_inference_error(exc, responses_params.model)
264-
raise HTTPException(**response.model_dump()) from exc
265-
266-
vector_store_ids = extract_vector_store_ids_from_tools(responses_params.tools)
267-
rag_id_mapping = configuration.rag_id_mapping
268-
return build_turn_summary_from_agent_run(
269-
run_result,
270-
model_id=responses_params.model,
271-
endpoint_path=endpoint_path,
272-
vector_store_ids=vector_store_ids,
273-
rag_id_mapping=rag_id_mapping,
274-
)
285+
return TurnSummary(
286+
id=moderation_result.moderation_id,
287+
llm_response=moderation_result.message,
288+
)
289+
290+
# Emit inference started event
291+
add_span_event(span, SpanEvents.LLM_INFERENCE_STARTED)
292+
293+
try:
294+
agent = build_agent(
295+
client,
296+
responses_params,
297+
configuration,
298+
shields=shield_ids,
299+
no_tools=no_tools,
300+
)
301+
logger.debug("Starting agent non-streaming response processing")
302+
if image_attachments:
303+
prompt = build_multimodal_input(
304+
cast(str, responses_params.input),
305+
image_attachments,
306+
)
307+
else:
308+
prompt = cast(str, responses_params.input)
309+
run_result = await agent.run(prompt)
310+
except (
311+
AgentRunError,
312+
APIStatusError,
313+
APIConnectionError,
314+
RuntimeError,
315+
) as exc:
316+
response = map_agent_inference_error(exc, responses_params.model)
317+
raise HTTPException(**response.model_dump()) from exc
318+
319+
# Set token usage attributes
320+
if run_result.usage:
321+
set_span_attributes(
322+
span,
323+
{
324+
SpanAttributes.LLM_USAGE_INPUT_TOKENS: run_result.usage.input_tokens,
325+
SpanAttributes.LLM_USAGE_OUTPUT_TOKENS: run_result.usage.output_tokens,
326+
},
327+
)
328+
329+
vector_store_ids = extract_vector_store_ids_from_tools(responses_params.tools)
330+
rag_id_mapping = configuration.rag_id_mapping
331+
turn_summary = build_turn_summary_from_agent_run(
332+
run_result,
333+
model_id=responses_params.model,
334+
endpoint_path=endpoint_path,
335+
vector_store_ids=vector_store_ids,
336+
rag_id_mapping=rag_id_mapping,
337+
)
338+
339+
# Emit inference completed event after successful summary build
340+
add_span_event(span, SpanEvents.LLM_INFERENCE_COMPLETED)
341+
342+
return turn_summary

0 commit comments

Comments
 (0)