use std::collections::HashMap;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum SpanKind {
Span,
Generation,
Event,
Agent,
Tool,
Chain,
Retriever,
Embedding,
Guardrail,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum ObservationLevel {
#[default]
Default,
Debug,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Span {
pub run_id: Uuid,
pub parent_run_id: Option<Uuid>,
pub trace_id: Uuid,
pub kind: SpanKind,
pub name: String,
pub started_at: SystemTime,
pub ended_at: Option<SystemTime>,
pub level: ObservationLevel,
pub status_message: Option<String>,
pub input: Option<serde_json::Value>,
pub output: Option<serde_json::Value>,
pub session_id: Option<String>,
pub user_id: Option<String>,
pub tags: Vec<String>,
pub metadata: HashMap<String, serde_json::Value>,
pub generation: Option<Generation>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Generation {
pub model: String,
pub provider: String,
#[serde(default)]
pub model_parameters: HashMap<String, serde_json::Value>,
pub usage: TokenUsage,
pub cost: Option<CostDetails>,
pub completion_start_time: Option<SystemTime>,
pub finish_reason: Option<String>,
pub prompt_name: Option<String>,
pub prompt_version: Option<u32>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input: u32,
pub output: u32,
pub cache_read: u32,
pub cache_write: u32,
}
impl TokenUsage {
pub fn total(&self) -> u32 {
self.input + self.output + self.cache_read + self.cache_write
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct CostDetails {
pub input: f64,
pub output: f64,
pub cache_read: f64,
pub cache_write: f64,
pub total: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ScoreValue {
Numeric(f64),
Categorical(String),
Boolean(bool),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreRecord {
pub run_id: Uuid,
pub trace_id: Option<Uuid>,
pub session_id: Option<String>,
pub name: String,
pub value: ScoreValue,
pub comment: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SpanBuilder {
pub span: Span,
}
impl SpanBuilder {
pub fn open(
run_id: Uuid,
parent_run_id: Option<Uuid>,
trace_id: Uuid,
kind: SpanKind,
name: impl Into<String>,
input: Option<serde_json::Value>,
now: SystemTime,
) -> Self {
Self {
span: Span {
run_id,
parent_run_id,
trace_id,
kind,
name: name.into(),
started_at: now,
ended_at: None,
level: ObservationLevel::Default,
status_message: None,
input,
output: None,
session_id: None,
user_id: None,
tags: Vec::new(),
metadata: HashMap::new(),
generation: None,
},
}
}
pub fn finish_ok(mut self, output: Option<serde_json::Value>, now: SystemTime) -> Span {
self.span.ended_at = Some(now);
self.span.output = output;
self.span
}
pub fn finish_error(mut self, message: impl Into<String>, now: SystemTime) -> Span {
self.span.ended_at = Some(now);
self.span.level = ObservationLevel::Error;
self.span.status_message = Some(message.into());
self.span
}
pub fn with_generation(mut self, gen: Generation) -> Self {
self.span.kind = SpanKind::Generation;
self.span.generation = Some(gen);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn span_kind_serializes_uppercase() {
let s = serde_json::to_string(&SpanKind::Generation).unwrap();
assert_eq!(s, "\"GENERATION\"");
}
#[test]
fn observation_level_default_is_default() {
assert_eq!(ObservationLevel::default(), ObservationLevel::Default);
}
#[test]
fn token_usage_total_sums_categories() {
let u = TokenUsage {
input: 10,
output: 20,
cache_read: 5,
cache_write: 3,
};
assert_eq!(u.total(), 38);
}
#[test]
fn score_value_numeric_serializes_as_number() {
let v = ScoreValue::Numeric(0.9);
assert_eq!(serde_json::to_string(&v).unwrap(), "0.9");
}
#[test]
fn score_value_categorical_serializes_as_string() {
let v = ScoreValue::Categorical("good".into());
assert_eq!(serde_json::to_string(&v).unwrap(), "\"good\"");
}
#[test]
fn span_builder_opens_with_default_level() {
let id = Uuid::new_v4();
let now = SystemTime::now();
let b = SpanBuilder::open(id, None, id, SpanKind::Chain, "x", None, now);
assert_eq!(b.span.level, ObservationLevel::Default);
assert!(b.span.ended_at.is_none());
}
#[test]
fn span_builder_finish_ok_sets_end_and_output() {
let id = Uuid::new_v4();
let now = SystemTime::now();
let b = SpanBuilder::open(id, None, id, SpanKind::Chain, "x", None, now);
let span = b.finish_ok(Some(serde_json::json!({"k": "v"})), now);
assert!(span.ended_at.is_some());
assert_eq!(span.level, ObservationLevel::Default);
assert_eq!(span.output, Some(serde_json::json!({"k": "v"})));
}
#[test]
fn span_builder_finish_error_sets_level_and_message() {
let id = Uuid::new_v4();
let now = SystemTime::now();
let b = SpanBuilder::open(id, None, id, SpanKind::Tool, "t", None, now);
let span = b.finish_error("boom", now);
assert_eq!(span.level, ObservationLevel::Error);
assert_eq!(span.status_message.as_deref(), Some("boom"));
}
#[test]
fn span_builder_with_generation_flips_kind() {
let id = Uuid::new_v4();
let b = SpanBuilder::open(id, None, id, SpanKind::Span, "g", None, SystemTime::now())
.with_generation(Generation {
model: "gpt-4o".into(),
provider: "openai".into(),
..Default::default()
});
assert_eq!(b.span.kind, SpanKind::Generation);
assert!(b.span.generation.is_some());
}
}