Otel: llm.request client spans + traceparent on LLM calls (#130)
Add OpenTelemetry tracing to every LLM client call so an insight/chat turn is a single trace from HTTP entry to each downstream inference. - LlmClient trait: thread a &opentelemetry::Context through all 5 methods (generate, chat_with_tools, chat_with_tools_stream, generate_embeddings, describe_image), matching the DAO convention of explicit context threading rather than ambient Context::current(). - otel::start_llm_request_span: single shared helper that every provider (openrouter / llamacpp / ollama) funnels through, so the span shape is identical: name llm.request, SpanKind::Client, llm.provider / llm.operation / llm.model. - Per-method spans in all three clients with the spec's operation enum (chat | chat_stream | embeddings | describe_image | generate); stream spans are owned and moved into the async_stream generator so they end when the response drains, not when headers arrive. - llm.tokens.input / llm.tokens.output recorded on non-stream and stream completion; llm.cost_usd from usage.cost (OpenRouter); http.status_code + error status on failure. - W3C traceparent injected on outgoing LLM HTTP requests (llama-swap, Ollama, OpenRouter) gated on span_context.is_valid() so debug no-op-tracer builds don't send an all-zero parent. - Turn-level spans (ai.chat.turn.execute, ai.persona_chat.turn.execute) now carry llm.backend + llm.model; with_entry streaming paths thread the turn context (turn_cx) to child describe_image / agentic-loop calls instead of the ambient context. clippy clean; 690 lib + 775 integration + 4 doc tests green.
This commit is contained in:
1 parent
e84ac82f86
commit
696d9fa8f3
17 files changed
+910
-234
No files matched your search
+13
-3
@@ -26,7 +26,7 @@ use actix_web::{HttpRequest, HttpResponse, Responder, delete, get, post, web};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::Utc;
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry::trace::{Span, Status, Tracer};
|
||||
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
@@ -685,6 +685,7 @@ impl PersonaChatSession {
|
||||
entry_clone.clone(),
|
||||
user_id,
|
||||
library_id,
|
||||
&mut span,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
@@ -721,6 +722,7 @@ impl PersonaChatSession {
|
||||
entry: Arc<TurnEntry>,
|
||||
user_id: i32,
|
||||
_library_id: i32,
|
||||
turn_span: &mut opentelemetry::global::BoxedSpan,
|
||||
) -> Result<()> {
|
||||
// Per-conversation mutex — serializes this turn against a
|
||||
// concurrent rewind or branch switch on the same transcript.
|
||||
@@ -808,6 +810,12 @@ impl PersonaChatSession {
|
||||
.resolve_backend(backend_kind, &overrides)
|
||||
.await?;
|
||||
let model_used = backend.model().to_string();
|
||||
// llm.* attributes on the turn-level span (issue #130), plus the
|
||||
// parent context for the child llm.request spans.
|
||||
turn_span.set_attribute(KeyValue::new("llm.backend", backend.kind.as_str()));
|
||||
turn_span.set_attribute(KeyValue::new("llm.model", model_used.clone()));
|
||||
let turn_cx = opentelemetry::Context::new()
|
||||
.with_remote_span_context(turn_span.span_context().clone());
|
||||
|
||||
// Build a no-photo tool catalog. `current_gate_opts_for_persona`
|
||||
// already probes each tool's backing table for presence and
|
||||
@@ -874,6 +882,7 @@ impl PersonaChatSession {
|
||||
&persona.persona_id,
|
||||
max_iterations,
|
||||
&entry,
|
||||
&turn_cx,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("persona chat loop failed: {e:?}"))?;
|
||||
@@ -942,7 +951,7 @@ impl PersonaChatSession {
|
||||
// list falls back to the persona name while the title is empty.
|
||||
if needs_title {
|
||||
let title = self
|
||||
.generate_title(&backend, &messages)
|
||||
.generate_title(&backend, &messages, &turn_cx)
|
||||
.await
|
||||
.unwrap_or_else(|| fallback_title(&messages));
|
||||
if !title.is_empty() {
|
||||
@@ -1047,6 +1056,7 @@ impl PersonaChatSession {
|
||||
&self,
|
||||
backend: &crate::ai::backend::ResolvedBackend,
|
||||
messages: &[ChatMessage],
|
||||
cx: &opentelemetry::Context,
|
||||
) -> Option<String> {
|
||||
let user_message = messages.iter().find(|m| m.role == "user")?;
|
||||
// The last assistant message with actual prose — earlier ones may be
|
||||
@@ -1057,7 +1067,7 @@ impl PersonaChatSession {
|
||||
.find(|m| m.role == "assistant" && !m.content.trim().is_empty())?;
|
||||
|
||||
let prompt = title_prompt(&user_message.content, &reply.content);
|
||||
match backend.chat().generate(&prompt, None, None).await {
|
||||
match backend.chat().generate(cx, &prompt, None, None).await {
|
||||
Ok(raw) => {
|
||||
let title = sanitize_title(&crate::ai::llm_client::strip_think_blocks(&raw));
|
||||
if title.is_empty() { None } else { Some(title) }
|
||||
|
||||
Reference in new issue
Block a user