Files
ImageApi/tests/otel_llm_spans.rs
T
Cameron CordesandClaude Opus 5.5 1c508cdabd Tracing: LLM clients + streaming spans on tracing, drop cx threading (#140)
LlmClient methods no longer take an OpenTelemetry Context. llm.request is
now a tracing span (otel.kind=client, llm.provider/operation/model) that
parents on the caller's ambient span; traceparent injection reads the
span's OTel context via OpenTelemetrySpanExt.

The src/ai feature spans (handlers, insight generation, chat, persona
chat, daily summaries, TTS) move to #[instrument] / info_span!, and every
LLM-bearing tokio::spawn carries its parent span with .instrument(): the
streaming chat turn (chat_turn_stream_inner), async chat / persona turns,
background insight jobs and TTS jobs. Sub-spans in the agentic loops
(ai.chat.loop, ai.agentic.loop) instrument each LLM / tool await.

DAO calls keep their explicit Context (derived from the tracing span)
until the DAO migration (#141).

tests/otel_llm_spans.rs pins nesting + traceparent for the blocking and
streaming paths across a spawn on a multi-thread runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00

182 lines
7.3 KiB
Rust

//! Verifies the LLM-client tracing migration (#140 / map #135).
//!
//! The `LlmClient` methods no longer take an OpenTelemetry `Context`; the
//! `llm.request` span parents on the caller's ambient `tracing` span. Two
//! properties are pinned here, in the production shape of the streaming chat
//! path (`insight_chat::chat_turn_stream_inner`: a `tokio::spawn` carrying
//! the turn span via `.instrument`, on a multi-thread runtime):
//!
//! 1. **Nesting.** Both the blocking and the streaming `llm.request` spans
//! nest under the caller's span (same trace_id, parent link) and keep the
//! `llm.provider` / `llm.operation` / `llm.model` / `http.status_code`
//! attributes.
//! 2. **Propagation.** The outgoing `traceparent` header names the
//! `llm.request` span itself, so the downstream server parents onto it.
//!
//! One process-global subscriber per test binary, so this file holds a
//! single test.
use futures::StreamExt;
use image_api::ai::llamacpp::LlamaCppClient;
use image_api::ai::llm_client::{ChatMessage, LlmClient, LlmStreamEvent};
use opentelemetry::Value;
use opentelemetry::trace::TracerProvider;
use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider, SpanData};
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::Instrument;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
const CHAT_BODY: &str = r#"{"choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":3,"completion_tokens":1}}"#;
const STREAM_BODY: &str = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\n\
data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1}}\n\n\
data: [DONE]\n\n";
/// Minimal OpenAI-compatible server: records each request's `traceparent`
/// and answers with an SSE stream when the body asks for one, JSON otherwise.
async fn serve(listener: tokio::net::TcpListener, seen: Arc<Mutex<Vec<String>>>) {
loop {
let (mut sock, _) = listener.accept().await.expect("accept");
let seen = seen.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let header_end = loop {
let n = sock.read(&mut chunk).await.expect("read");
buf.extend_from_slice(&chunk[..n]);
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break i + 4;
}
};
let head = String::from_utf8_lossy(&buf[..header_end]).to_lowercase();
let content_length: usize = head
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.map(|v| v.trim().parse().unwrap())
.unwrap_or(0);
while buf.len() < header_end + content_length {
let n = sock.read(&mut chunk).await.expect("read body");
buf.extend_from_slice(&chunk[..n]);
}
if let Some(tp) = head.lines().find_map(|l| l.strip_prefix("traceparent:")) {
seen.lock().unwrap().push(tp.trim().to_string());
}
let body = String::from_utf8_lossy(&buf[header_end..]);
let (ctype, payload) = if body.contains("\"stream\":true") {
("text/event-stream", STREAM_BODY)
} else {
("application/json", CHAT_BODY)
};
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: {ctype}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}",
payload.len()
);
sock.write_all(resp.as_bytes()).await.expect("write");
});
}
}
fn attr(span: &SpanData, key: &str) -> Option<Value> {
span.attributes
.iter()
.find(|kv| kv.key.as_str() == key)
.map(|kv| kv.value.clone())
}
#[test]
fn llm_spans_nest_under_caller_across_spawn() {
let exporter = InMemorySpanExporter::default();
let provider = SdkTracerProvider::builder()
.with_simple_exporter(exporter.clone())
.build();
Registry::default()
.with(tracing_opentelemetry::OpenTelemetryLayer::new(
provider.tracer("llm-span-test"),
))
.try_init()
.expect("global subscriber not yet set");
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(serve(listener, seen.clone()));
let client = Arc::new(LlamaCppClient::new(Some(base), Some("chat".into())));
let turn = tracing::info_span!("test.turn");
tokio::spawn(
async move {
let (reply, _, _) = client
.chat_with_tools(vec![ChatMessage::user("hello")], vec![])
.await
.expect("chat");
assert_eq!(reply.content, "hi");
let mut stream = client
.chat_with_tools_stream(vec![ChatMessage::user("hello")], vec![])
.await
.expect("stream");
let mut done = false;
while let Some(ev) = stream.next().await {
if let LlmStreamEvent::Done { .. } = ev.expect("stream event") {
done = true;
}
}
assert!(done, "stream ended without Done");
}
.instrument(turn),
)
.await
.expect("spawned turn");
});
let _ = provider.force_flush();
let spans = exporter.get_finished_spans().unwrap();
let turn = spans
.iter()
.find(|s| s.name == "test.turn")
.expect("turn span exported");
let llm: Vec<&SpanData> = spans.iter().filter(|s| s.name == "llm.request").collect();
assert_eq!(llm.len(), 2, "one llm.request per HTTP call");
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "every LLM request carries a traceparent");
for (span, op) in llm.iter().zip(["chat", "chat_stream"]) {
assert_eq!(
span.span_context.trace_id(),
turn.span_context.trace_id(),
"{op}: llm.request must share the caller's trace"
);
assert_eq!(
span.parent_span_id,
turn.span_context.span_id(),
"{op}: llm.request must be a child of the caller span"
);
assert_eq!(span.span_kind, opentelemetry::trace::SpanKind::Client);
assert_eq!(attr(span, "llm.provider"), Some("llamacpp".into()));
assert_eq!(attr(span, "llm.operation"), Some(op.into()));
assert_eq!(attr(span, "llm.model"), Some("chat".into()));
assert_eq!(attr(span, "http.status_code"), Some(200i64.into()));
// Downstream parents onto the llm.request span itself.
let expected = format!(
"00-{}-{}-01",
span.span_context.trace_id(),
span.span_context.span_id()
);
assert!(
seen.contains(&expected),
"{op}: traceparent {expected} not sent (saw {seen:?})"
);
}
}