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>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 17:02:40 -04:00
1 parent 7a59afc62b
commit 1c508cdabd
20 files changed
+973 -1030

No files matched your search

+29 -72
View File
@@ -1,14 +1,13 @@
use anyhow::Result;
use chrono::{NaiveDate, Utc};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use opentelemetry::trace::Status;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::time::sleep;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::ai::{EMBEDDING_MODEL, OllamaClient, SmsApiClient, SmsMessage, user_display_name};
use crate::database::{DailySummaryDao, InsertDailySummary};
use crate::otel::global_tracer;
/// Strip boilerplate prefixes and common phrases from summaries before embedding.
/// This improves embedding diversity by removing structural similarity.
@@ -155,6 +154,7 @@ pub fn strip_summary_boilerplate(summary: &str) -> String {
/// Generate and embed daily conversation summaries for a date range
/// Default: August 2024 ±30 days (July 1 - September 30, 2024)
#[tracing::instrument(name = "ai.daily_summary.generate_batch", skip_all)]
pub async fn generate_daily_summaries(
contact: &str,
start_date: Option<NaiveDate>,
@@ -163,30 +163,16 @@ pub async fn generate_daily_summaries(
sms_client: &SmsApiClient,
summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
) -> Result<()> {
let tracer = global_tracer();
// Get current context (empty in background task) and start span with it
let current_cx = opentelemetry::Context::current();
let mut span = tracer.start_with_context("ai.daily_summary.generate_batch", &current_cx);
span.set_attribute(KeyValue::new("contact", contact.to_string()));
// Create context with this span for child operations
let parent_cx = current_cx.with_span(span);
let span = tracing::Span::current();
span.set_attribute("contact", contact.to_string());
// Default to August 2024 ±30 days
let start = start_date.unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 7, 1).unwrap());
let end = end_date.unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 9, 30).unwrap());
parent_cx
.span()
.set_attribute(KeyValue::new("start_date", start.to_string()));
parent_cx
.span()
.set_attribute(KeyValue::new("end_date", end.to_string()));
parent_cx.span().set_attribute(KeyValue::new(
"date_range_days",
(end - start).num_days() + 1,
));
span.set_attribute("start_date", start.to_string());
span.set_attribute("end_date", end.to_string());
span.set_attribute("date_range_days", (end - start).num_days() + 1);
log::info!("========================================");
log::info!("Starting daily summary generation for {}", contact);
@@ -267,15 +253,8 @@ pub async fn generate_daily_summaries(
}
// Generate summary for this day
match generate_and_store_daily_summary(
&parent_cx,
date,
contact,
messages,
ollama,
summary_dao.clone(),
)
.await
match generate_and_store_daily_summary(date, contact, messages, ollama, summary_dao.clone())
.await
{
Ok(_) => {
processed += 1;
@@ -322,55 +301,38 @@ pub async fn generate_daily_summaries(
log::info!("========================================");
// Record final metrics in span
parent_cx
.span()
.set_attribute(KeyValue::new("days_processed", processed as i64));
parent_cx
.span()
.set_attribute(KeyValue::new("days_skipped", skipped as i64));
parent_cx
.span()
.set_attribute(KeyValue::new("days_failed", failed as i64));
parent_cx
.span()
.set_attribute(KeyValue::new("total_days", total_days as i64));
span.set_attribute("days_processed", processed as i64);
span.set_attribute("days_skipped", skipped as i64);
span.set_attribute("days_failed", failed as i64);
span.set_attribute("total_days", total_days as i64);
if failed > 0 {
parent_cx
.span()
.set_status(Status::error(format!("{} days failed to process", failed)));
span.set_status(Status::error(format!("{} days failed to process", failed)));
} else {
parent_cx.span().set_status(Status::Ok);
span.set_status(Status::Ok);
}
Ok(())
}
/// Generate and store a single day's summary
#[tracing::instrument(name = "ai.daily_summary.generate_single", skip_all)]
async fn generate_and_store_daily_summary(
parent_cx: &opentelemetry::Context,
date: &NaiveDate,
contact: &str,
messages: &[SmsMessage],
ollama: &OllamaClient,
summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>>,
) -> Result<()> {
let tracer = global_tracer();
let mut span = tracer.start_with_context("ai.daily_summary.generate_single", parent_cx);
span.set_attribute(KeyValue::new("date", date.to_string()));
span.set_attribute(KeyValue::new("contact", contact.to_string()));
span.set_attribute(KeyValue::new("message_count", messages.len() as i64));
// Parent context for the child llm.request spans; the span itself stays
// borrowed here for the attribute updates below.
let llm_cx =
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
let span = tracing::Span::current();
span.set_attribute("date", date.to_string());
span.set_attribute("contact", contact.to_string());
span.set_attribute("message_count", messages.len() as i64);
let (prompt, system_prompt) = build_daily_summary_prompt(contact, date, messages);
// Generate summary with LLM
let summary = ollama
.generate(&llm_cx, &prompt, Some(system_prompt))
.await?;
let summary = ollama.generate(&prompt, Some(system_prompt)).await?;
log::debug!(
"Generated summary for {}: {}",
@@ -378,7 +340,7 @@ async fn generate_and_store_daily_summary(
summary.chars().take(100).collect::<String>()
);
span.set_attribute(KeyValue::new("summary_length", summary.len() as i64));
span.set_attribute("summary_length", summary.len() as i64);
// Strip boilerplate before embedding to improve vector diversity
let stripped_summary = strip_summary_boilerplate(&summary);
@@ -388,14 +350,9 @@ async fn generate_and_store_daily_summary(
);
// Embed the stripped summary (store original summary in DB)
let embedding = ollama
.generate_embedding(&llm_cx, &stripped_summary)
.await?;
let embedding = ollama.generate_embedding(&stripped_summary).await?;
span.set_attribute(KeyValue::new(
"embedding_dimensions",
embedding.len() as i64,
));
span.set_attribute("embedding_dimensions", embedding.len() as i64);
// Store in database
let insert = InsertDailySummary {
@@ -408,8 +365,8 @@ async fn generate_and_store_daily_summary(
model_version: EMBEDDING_MODEL.to_string(),
};
// Create context from current span for DB operation
let child_cx = opentelemetry::Context::current_with_span(span);
// DAO calls still take an explicit OTel context (#141).
let child_cx = span.context();
let mut dao = summary_dao.lock().expect("Unable to lock DailySummaryDao");
let result = dao
@@ -417,8 +374,8 @@ async fn generate_and_store_daily_summary(
.map_err(|e| anyhow::anyhow!("Failed to store summary: {:?}", e));
match &result {
Ok(_) => child_cx.span().set_status(Status::Ok),
Err(e) => child_cx.span().set_status(Status::error(e.to_string())),
Ok(_) => span.set_status(Status::Ok),
Err(e) => span.set_status(Status::error(e.to_string())),
}
result?;