use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub type Id = Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Trace {
pub id: Id,
pub name: String,
pub user_id: Option<String>,
pub session_id: Option<String>,
pub tags: Vec<String>,
pub metadata: serde_json::Value,
pub environment: Option<String>,
pub release: Option<String>,
pub input: Option<serde_json::Value>,
pub output: Option<serde_json::Value>,
pub start_time: DateTime<Utc>,
pub end_time: Option<DateTime<Utc>>,
pub total_cost: Option<f64>,
pub total_tokens: Option<u64>,
}
impl Trace {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
user_id: None,
session_id: None,
tags: Vec::new(),
metadata: serde_json::Value::Null,
environment: None,
release: None,
input: None,
output: None,
start_time: Utc::now(),
end_time: None,
total_cost: None,
total_tokens: None,
}
}
pub fn complete(
&mut self,
output: Option<serde_json::Value>,
total_cost: Option<f64>,
total_tokens: Option<u64>,
) {
self.end_time = Some(Utc::now());
self.output = output;
self.total_cost = total_cost;
self.total_tokens = total_tokens;
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObservationType {
Span,
Generation,
ToolCall,
Retrieval,
}
impl ObservationType {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Span => "SPAN",
Self::Generation => "GENERATION",
Self::ToolCall => "TOOL_CALL",
Self::Retrieval => "RETRIEVAL",
}
}
}
impl std::fmt::Display for ObservationType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ObservationLevel {
Debug,
Default,
Warning,
Error,
}
impl ObservationLevel {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Debug => "DEBUG",
Self::Default => "DEFAULT",
Self::Warning => "WARNING",
Self::Error => "ERROR",
}
}
}
impl std::fmt::Display for ObservationLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cached_tokens: Option<u64>,
}
impl From<juncture_core::state::messages::TokenUsage> for TokenUsage {
fn from(usage: juncture_core::state::messages::TokenUsage) -> Self {
Self {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
cached_tokens: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Observation {
pub id: Id,
pub trace_id: Id,
pub parent_observation_id: Option<Id>,
pub name: String,
pub observation_type: ObservationType,
pub start_time: DateTime<Utc>,
pub end_time: Option<DateTime<Utc>>,
pub input: Option<serde_json::Value>,
pub output: Option<serde_json::Value>,
pub metadata: serde_json::Value,
pub level: ObservationLevel,
pub status_message: Option<String>,
pub model: Option<String>,
pub model_parameters: Option<serde_json::Value>,
pub usage: Option<TokenUsage>,
pub cost: Option<f64>,
}
impl Observation {
#[must_use]
pub fn span(trace_id: Id, name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
trace_id,
parent_observation_id: None,
name: name.into(),
observation_type: ObservationType::Span,
start_time: Utc::now(),
end_time: None,
input: None,
output: None,
metadata: serde_json::Value::Null,
level: ObservationLevel::Default,
status_message: None,
model: None,
model_parameters: None,
usage: None,
cost: None,
}
}
#[must_use]
pub fn generation(trace_id: Id, name: impl Into<String>, model: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
trace_id,
parent_observation_id: None,
name: name.into(),
observation_type: ObservationType::Generation,
start_time: Utc::now(),
end_time: None,
input: None,
output: None,
metadata: serde_json::Value::Null,
level: ObservationLevel::Default,
status_message: None,
model: Some(model.into()),
model_parameters: None,
usage: None,
cost: None,
}
}
#[must_use]
pub fn tool_call(trace_id: Id, name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
trace_id,
parent_observation_id: None,
name: name.into(),
observation_type: ObservationType::ToolCall,
start_time: Utc::now(),
end_time: None,
input: None,
output: None,
metadata: serde_json::Value::Null,
level: ObservationLevel::Default,
status_message: None,
model: None,
model_parameters: None,
usage: None,
cost: None,
}
}
#[must_use]
pub const fn with_parent(mut self, parent_id: Id) -> Self {
self.parent_observation_id = Some(parent_id);
self
}
pub fn complete(&mut self, output: Option<serde_json::Value>) {
self.end_time = Some(Utc::now());
self.output = output;
}
pub fn fail(&mut self, message: impl Into<String>) {
self.end_time = Some(Utc::now());
self.level = ObservationLevel::Error;
self.status_message = Some(message.into());
}
#[must_use]
pub fn duration_ms(&self) -> Option<u64> {
self.end_time.map(|end| {
let duration = end.signed_duration_since(self.start_time);
u64::try_from(duration.num_milliseconds().max(0)).unwrap_or(0)
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub id: String,
pub user_id: Option<String>,
pub created_at: DateTime<Utc>,
}
impl Session {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
user_id: None,
created_at: Utc::now(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureConfig {
pub max_prompt_chars: usize,
pub max_response_chars: usize,
pub capture_full_messages: bool,
pub capture_tool_io: bool,
pub sensitive_keys: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelStats {
pub model: String,
pub call_count: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub total_cost: f64,
pub avg_latency_ms: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummaryStats {
pub total_traces: u64,
pub total_observations: u64,
pub total_cost: f64,
pub total_tokens: u64,
pub error_count: u64,
pub active_sessions: u64,
pub latency_p50_ms: f64,
pub latency_p95_ms: f64,
pub latency_p99_ms: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnrichedSession {
pub id: String,
pub user_id: Option<String>,
pub created_at: String,
pub trace_count: u64,
pub total_cost: f64,
pub total_tokens: u64,
pub last_active: Option<String>,
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
max_prompt_chars: 10_000,
max_response_chars: 10_000,
capture_full_messages: true,
capture_tool_io: true,
sensitive_keys: vec![
"authorization".to_string(),
"api_key".to_string(),
"api-key".to_string(),
"password".to_string(),
"secret".to_string(),
"token".to_string(),
],
}
}
}
impl CaptureConfig {
#[must_use]
pub fn truncate(&self, content: &str, max_chars: usize) -> String {
if content.len() <= max_chars {
content.to_string()
} else {
let truncated: String = content.chars().take(max_chars).collect();
format!("{truncated}\n... [truncated at {max_chars} chars]")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trace_new_has_id_and_name() {
let trace = Trace::new("test_graph");
assert!(!trace.name.is_empty());
assert!(trace.end_time.is_none());
}
#[test]
fn trace_complete_sets_end_time() {
let mut trace = Trace::new("test_graph");
trace.complete(None, Some(0.05), Some(100));
assert!(trace.end_time.is_some());
assert_eq!(trace.total_cost, Some(0.05));
assert_eq!(trace.total_tokens, Some(100));
}
#[test]
fn observation_span_factory() {
let trace_id = Uuid::new_v4();
let obs = Observation::span(trace_id, "juncture.node.execute");
assert_eq!(obs.observation_type, ObservationType::Span);
assert_eq!(obs.name, "juncture.node.execute");
assert!(obs.end_time.is_none());
}
#[test]
fn observation_generation_factory() {
let trace_id = Uuid::new_v4();
let obs = Observation::generation(trace_id, "llm_call", "claude-sonnet-4-20250514");
assert_eq!(obs.observation_type, ObservationType::Generation);
assert_eq!(obs.model.as_deref(), Some("claude-sonnet-4-20250514"));
}
#[test]
fn observation_tool_call_factory() {
let trace_id = Uuid::new_v4();
let obs = Observation::tool_call(trace_id, "search");
assert_eq!(obs.observation_type, ObservationType::ToolCall);
}
#[test]
fn observation_complete_and_fail() {
let trace_id = Uuid::new_v4();
let mut obs = Observation::span(trace_id, "test");
obs.complete(Some(serde_json::json!({"result": "ok"})));
assert!(obs.end_time.is_some());
assert_eq!(obs.level, ObservationLevel::Default);
let mut obs2 = Observation::span(trace_id, "test2");
obs2.fail("something broke");
assert!(obs2.end_time.is_some());
assert_eq!(obs2.level, ObservationLevel::Error);
assert!(obs2.status_message.is_some());
}
#[test]
fn observation_with_parent() {
let trace_id = Uuid::new_v4();
let parent_id = Uuid::new_v4();
let obs = Observation::span(trace_id, "child").with_parent(parent_id);
assert_eq!(obs.parent_observation_id, Some(parent_id));
}
#[test]
fn observation_duration_ms() {
let trace_id = Uuid::new_v4();
let mut obs = Observation::span(trace_id, "test");
assert!(obs.duration_ms().is_none());
obs.complete(None);
assert!(obs.duration_ms().is_some());
}
#[test]
fn observation_type_display() {
assert_eq!(ObservationType::Span.to_string(), "SPAN");
assert_eq!(ObservationType::Generation.to_string(), "GENERATION");
assert_eq!(ObservationType::ToolCall.to_string(), "TOOL_CALL");
assert_eq!(ObservationType::Retrieval.to_string(), "RETRIEVAL");
}
#[test]
fn session_new() {
let session = Session::new("thread-123");
assert_eq!(session.id, "thread-123");
assert!(session.user_id.is_none());
}
#[test]
fn capture_config_truncate() {
let config = CaptureConfig::default();
let short = "hello";
assert_eq!(config.truncate(short, 100), "hello");
let long = "a".repeat(15_000);
let truncated = config.truncate(&long, 10_000);
assert!(truncated.len() > 10_000);
assert!(truncated.contains("truncated"));
}
#[test]
fn capture_config_default_sensitive_keys() {
let config = CaptureConfig::default();
assert!(config.sensitive_keys.contains(&"authorization".to_string()));
assert!(config.sensitive_keys.contains(&"api_key".to_string()));
}
#[test]
fn token_usage_default() {
let usage = TokenUsage::default();
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.total_tokens, 0);
assert!(usage.cached_tokens.is_none());
}
}