Skip to main content

lc_callbacks/tracing/
span.rs

1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3
4/// Unique span identifier.
5pub type SpanId = String;
6
7/// Kind of span for categorization.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum SpanKind {
11    /// LLM inference call
12    Llm,
13    /// Chain execution
14    Chain,
15    /// Tool invocation
16    Tool,
17    /// Retriever query
18    Retriever,
19    /// Agent execution
20    Agent,
21    /// Custom span kind
22    Custom(String),
23}
24
25impl std::fmt::Display for SpanKind {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            SpanKind::Llm => write!(f, "llm"),
29            SpanKind::Chain => write!(f, "chain"),
30            SpanKind::Tool => write!(f, "tool"),
31            SpanKind::Retriever => write!(f, "retriever"),
32            SpanKind::Agent => write!(f, "agent"),
33            SpanKind::Custom(name) => write!(f, "custom:{}", name),
34        }
35    }
36}
37
38impl From<crate::RunType> for SpanKind {
39    fn from(run_type: crate::RunType) -> Self {
40        match run_type {
41            crate::RunType::Llm => SpanKind::Llm,
42            crate::RunType::Chain => SpanKind::Chain,
43            crate::RunType::Tool => SpanKind::Tool,
44            crate::RunType::Retriever => SpanKind::Retriever,
45            // RunType variants without a dedicated SpanKind collapse to Custom
46            crate::RunType::Embedding => SpanKind::Custom("embedding".to_string()),
47            crate::RunType::Prompt => SpanKind::Custom("prompt".to_string()),
48            crate::RunType::Parser => SpanKind::Custom("parser".to_string()),
49        }
50    }
51}
52
53/// Token usage recorded in a span.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct SpanTokenUsage {
56    /// Number of tokens in the prompt.
57    pub prompt_tokens: usize,
58    /// Number of tokens in the completion.
59    pub completion_tokens: usize,
60    /// Total number of tokens.
61    pub total_tokens: usize,
62}
63
64/// Status of a span.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66#[serde(rename_all = "snake_case")]
67pub enum SpanStatus {
68    /// Span completed successfully
69    Ok,
70    /// Span ended with an error
71    Error(String),
72}
73
74/// A single trace span with parent-child relationships.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct TraceSpan {
77    /// Unique span identifier
78    pub id: SpanId,
79    /// Parent span ID (None for root spans)
80    pub parent_id: Option<SpanId>,
81    /// Human-readable span name
82    pub name: String,
83    /// Span category
84    pub kind: SpanKind,
85    /// ISO 8601 start time
86    pub start_time: Option<String>,
87    /// ISO 8601 end time
88    pub end_time: Option<String>,
89    /// Token usage (for LLM spans)
90    pub tokens: Option<SpanTokenUsage>,
91    /// Estimated cost in USD
92    pub cost: Option<f64>,
93    /// Measured latency in milliseconds
94    pub latency_ms: Option<u64>,
95    /// Arbitrary key-value metadata
96    pub metadata: serde_json::Value,
97    /// Span completion status
98    pub status: SpanStatus,
99
100    // --- OTel GenAI SemConv fields ---
101    /// gen_ai.system: The LLM provider name (e.g., "openai", "anthropic")
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub gen_ai_system: Option<String>,
104    /// gen_ai.request.model: The model requested
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub gen_ai_request_model: Option<String>,
107    /// gen_ai.response.model: The actual model used
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub gen_ai_response_model: Option<String>,
110    /// gen_ai.response.finish_reason: Why the model stopped generating
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub gen_ai_finish_reason: Option<String>,
113    /// gen_ai.request.max_tokens: Maximum tokens requested
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub gen_ai_request_max_tokens: Option<u64>,
116    /// gen_ai.request.temperature: Temperature parameter
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub gen_ai_request_temperature: Option<f64>,
119    /// gen_ai.operation.name: The operation name (chat, completion)
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub gen_ai_operation_name: Option<String>,
122    /// gen_ai.tool.name: The tool name (for tool spans)
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub gen_ai_tool_name: Option<String>,
125}
126
127/// A node in the trace tree (span + children).
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct TraceNode {
130    /// The span itself.
131    pub span: TraceSpan,
132    /// Child nodes of this span.
133    pub children: Vec<TraceNode>,
134}
135
136/// Estimates the USD cost of a single span from its token usage and gen_ai model.
137///
138/// Returns `None` when the span has no tokens or no recognizable model.
139fn estimate_span_cost(span: &TraceSpan) -> Option<f64> {
140    let tokens = span.tokens.as_ref()?;
141    let model = span
142        .gen_ai_request_model
143        .as_deref()
144        .or(span.gen_ai_response_model.as_deref())?;
145    crate::pricing::estimate_cost_usd(tokens.prompt_tokens, tokens.completion_tokens, model)
146}
147
148/// Sums the USD cost across spans (E3 agent roll-up).
149///
150/// Uses each span's recorded `cost` when set; otherwise estimates it from token
151/// usage + gen_ai model. Unknown models contribute zero (an unestimated span can't
152/// be trusted, so it doesn't deflate the aggregate with a guessed value).
153pub fn aggregate_cost(spans: &[TraceSpan]) -> f64 {
154    spans
155        .iter()
156        .map(|s| s.cost.or_else(|| estimate_span_cost(s)).unwrap_or(0.0))
157        .sum()
158}
159
160pub(crate) fn build_tree(root: &TraceSpan, all_spans: &[TraceSpan]) -> TraceNode {
161    let children: Vec<TraceNode> = all_spans
162        .iter()
163        .filter(|s| s.parent_id.as_deref() == Some(root.id.as_str()))
164        .map(|child| build_tree(child, all_spans))
165        .collect();
166
167    TraceNode {
168        span: root.clone(),
169        children,
170    }
171}
172
173/// Helper to create a new span with common defaults.
174pub(crate) fn make_span(
175    id: String,
176    parent_id: Option<SpanId>,
177    name: &str,
178    kind: SpanKind,
179) -> TraceSpan {
180    TraceSpan {
181        id,
182        parent_id,
183        name: name.to_string(),
184        kind,
185        start_time: Some(Utc::now().to_rfc3339()),
186        end_time: None,
187        tokens: None,
188        cost: None,
189        latency_ms: None,
190        metadata: serde_json::Value::Object(serde_json::Map::new()),
191        status: SpanStatus::Ok,
192        gen_ai_system: None,
193        gen_ai_request_model: None,
194        gen_ai_response_model: None,
195        gen_ai_finish_reason: None,
196        gen_ai_request_max_tokens: None,
197        gen_ai_request_temperature: None,
198        gen_ai_operation_name: None,
199        gen_ai_tool_name: None,
200    }
201}