Skip to main content

juncture_telemetry/
models.rs

1//! Data models for the Langfuse-compatible observability engine.
2//!
3//! Defines `Trace`, `Observation`, `Session`, and supporting types that
4//! form the core telemetry data model. All types are serializable for
5//! API responses and storage.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11/// Unique identifier for a trace or observation.
12pub type Id = Uuid;
13
14/// Trace represents a single graph invocation or request lifecycle.
15///
16/// A trace is the top-level container that groups all observations
17/// (spans, LLM calls, tool calls) generated during one execution.
18/// It maps to Langfuse's `Trace` concept and Juncture's `thread_id`.
19#[derive(Clone, Debug, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct Trace {
22    /// Unique trace identifier.
23    pub id: Id,
24    /// Human-readable name (typically the graph name).
25    pub name: String,
26    /// User identifier for per-user cost/quality tracking.
27    pub user_id: Option<String>,
28    /// Session identifier for multi-turn conversation grouping.
29    /// Maps to Juncture's `thread_id`.
30    pub session_id: Option<String>,
31    /// Flexible string labels for categorization and filtering.
32    pub tags: Vec<String>,
33    /// Arbitrary key-value metadata.
34    pub metadata: serde_json::Value,
35    /// Deployment environment (production, staging, development).
36    pub environment: Option<String>,
37    /// Application release version.
38    pub release: Option<String>,
39    /// Graph input captured at invocation time.
40    pub input: Option<serde_json::Value>,
41    /// Graph output captured at completion time.
42    pub output: Option<serde_json::Value>,
43    /// Trace start timestamp.
44    pub start_time: DateTime<Utc>,
45    /// Trace end timestamp (set when graph completes).
46    pub end_time: Option<DateTime<Utc>>,
47    /// Aggregated total cost in USD across all LLM calls.
48    pub total_cost: Option<f64>,
49    /// Aggregated total tokens consumed.
50    pub total_tokens: Option<u64>,
51}
52
53impl Trace {
54    /// Create a new trace with the given name.
55    #[must_use]
56    pub fn new(name: impl Into<String>) -> Self {
57        Self {
58            id: Uuid::new_v4(),
59            name: name.into(),
60            user_id: None,
61            session_id: None,
62            tags: Vec::new(),
63            metadata: serde_json::Value::Null,
64            environment: None,
65            release: None,
66            input: None,
67            output: None,
68            start_time: Utc::now(),
69            end_time: None,
70            total_cost: None,
71            total_tokens: None,
72        }
73    }
74
75    /// Mark the trace as completed with optional output and aggregated metrics.
76    pub fn complete(
77        &mut self,
78        output: Option<serde_json::Value>,
79        total_cost: Option<f64>,
80        total_tokens: Option<u64>,
81    ) {
82        self.end_time = Some(Utc::now());
83        self.output = output;
84        self.total_cost = total_cost;
85        self.total_tokens = total_tokens;
86    }
87}
88
89/// Type of observation within a trace.
90///
91/// Maps to Langfuse's observation types. Each type captures
92/// different aspects of agent execution.
93#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum ObservationType {
96    /// Generic span covering any timed operation.
97    Span,
98    /// LLM generation call with prompt/completion data.
99    Generation,
100    /// Tool invocation with input/output.
101    ToolCall,
102    /// RAG retrieval step with query and documents.
103    Retrieval,
104}
105
106impl ObservationType {
107    /// Returns the string representation for storage and API use.
108    #[must_use]
109    pub const fn as_str(&self) -> &'static str {
110        match self {
111            Self::Span => "SPAN",
112            Self::Generation => "GENERATION",
113            Self::ToolCall => "TOOL_CALL",
114            Self::Retrieval => "RETRIEVAL",
115        }
116    }
117}
118
119impl std::fmt::Display for ObservationType {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.write_str(self.as_str())
122    }
123}
124
125/// Severity level for observations.
126///
127/// Follows Langfuse's level convention for filtering
128/// and alerting on error/warning conditions.
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
131pub enum ObservationLevel {
132    /// Detailed diagnostic information.
133    Debug,
134    /// Normal operational information.
135    Default,
136    /// Potential issue that does not prevent operation.
137    Warning,
138    /// Operation failed or produced an error.
139    Error,
140}
141
142impl ObservationLevel {
143    /// Returns the string representation for storage and API use.
144    #[must_use]
145    pub const fn as_str(&self) -> &'static str {
146        match self {
147            Self::Debug => "DEBUG",
148            Self::Default => "DEFAULT",
149            Self::Warning => "WARNING",
150            Self::Error => "ERROR",
151        }
152    }
153}
154
155impl std::fmt::Display for ObservationLevel {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.write_str(self.as_str())
158    }
159}
160
161/// Token usage statistics for an LLM call.
162///
163/// Captures input/output/total tokens and optional cached tokens
164/// for cost calculation and budget tracking.
165#[derive(Clone, Debug, Default, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct TokenUsage {
168    /// Number of input/prompt tokens.
169    pub input_tokens: u64,
170    /// Number of output/completion tokens.
171    pub output_tokens: u64,
172    /// Total tokens (input + output).
173    pub total_tokens: u64,
174    /// Number of cached input tokens (prompt caching).
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub cached_tokens: Option<u64>,
177}
178
179impl From<juncture_core::state::messages::TokenUsage> for TokenUsage {
180    fn from(usage: juncture_core::state::messages::TokenUsage) -> Self {
181        Self {
182            input_tokens: usage.input_tokens,
183            output_tokens: usage.output_tokens,
184            total_tokens: usage.total_tokens,
185            cached_tokens: None,
186        }
187    }
188}
189
190/// Observation represents a single unit of work within a trace.
191///
192/// Observations form a tree structure via `parent_observation_id`.
193/// A graph invocation creates observations for each superstep,
194/// node execution, LLM call, and tool call.
195#[derive(Clone, Debug, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct Observation {
198    /// Unique observation identifier.
199    pub id: Id,
200    /// Parent trace identifier.
201    pub trace_id: Id,
202    /// Parent observation identifier for nesting (None = top-level).
203    pub parent_observation_id: Option<Id>,
204    /// Human-readable name (node name, model name, tool name).
205    pub name: String,
206    /// Type of observation.
207    pub observation_type: ObservationType,
208    /// Observation start timestamp.
209    pub start_time: DateTime<Utc>,
210    /// Observation end timestamp.
211    pub end_time: Option<DateTime<Utc>>,
212    /// Input data (prompt messages, tool arguments, etc.).
213    pub input: Option<serde_json::Value>,
214    /// Output data (completion text, tool result, etc.).
215    pub output: Option<serde_json::Value>,
216    /// Arbitrary key-value metadata.
217    pub metadata: serde_json::Value,
218    /// Severity level.
219    pub level: ObservationLevel,
220    /// Human-readable status message (error details, etc.).
221    pub status_message: Option<String>,
222    // Generation-specific fields
223    /// LLM model name (e.g., "claude-sonnet-4-20250514").
224    pub model: Option<String>,
225    /// Model parameters (temperature, `max_tokens`, etc.).
226    pub model_parameters: Option<serde_json::Value>,
227    /// Token usage statistics.
228    pub usage: Option<TokenUsage>,
229    /// Cost in USD for this call.
230    pub cost: Option<f64>,
231}
232
233impl Observation {
234    /// Create a new span observation.
235    #[must_use]
236    pub fn span(trace_id: Id, name: impl Into<String>) -> Self {
237        Self {
238            id: Uuid::new_v4(),
239            trace_id,
240            parent_observation_id: None,
241            name: name.into(),
242            observation_type: ObservationType::Span,
243            start_time: Utc::now(),
244            end_time: None,
245            input: None,
246            output: None,
247            metadata: serde_json::Value::Null,
248            level: ObservationLevel::Default,
249            status_message: None,
250            model: None,
251            model_parameters: None,
252            usage: None,
253            cost: None,
254        }
255    }
256
257    /// Create a new LLM generation observation.
258    #[must_use]
259    pub fn generation(trace_id: Id, name: impl Into<String>, model: impl Into<String>) -> Self {
260        Self {
261            id: Uuid::new_v4(),
262            trace_id,
263            parent_observation_id: None,
264            name: name.into(),
265            observation_type: ObservationType::Generation,
266            start_time: Utc::now(),
267            end_time: None,
268            input: None,
269            output: None,
270            metadata: serde_json::Value::Null,
271            level: ObservationLevel::Default,
272            status_message: None,
273            model: Some(model.into()),
274            model_parameters: None,
275            usage: None,
276            cost: None,
277        }
278    }
279
280    /// Create a new tool call observation.
281    #[must_use]
282    pub fn tool_call(trace_id: Id, name: impl Into<String>) -> Self {
283        Self {
284            id: Uuid::new_v4(),
285            trace_id,
286            parent_observation_id: None,
287            name: name.into(),
288            observation_type: ObservationType::ToolCall,
289            start_time: Utc::now(),
290            end_time: None,
291            input: None,
292            output: None,
293            metadata: serde_json::Value::Null,
294            level: ObservationLevel::Default,
295            status_message: None,
296            model: None,
297            model_parameters: None,
298            usage: None,
299            cost: None,
300        }
301    }
302
303    /// Set the parent observation for nesting.
304    #[must_use]
305    pub const fn with_parent(mut self, parent_id: Id) -> Self {
306        self.parent_observation_id = Some(parent_id);
307        self
308    }
309
310    /// Mark the observation as completed with optional output.
311    pub fn complete(&mut self, output: Option<serde_json::Value>) {
312        self.end_time = Some(Utc::now());
313        self.output = output;
314    }
315
316    /// Mark the observation as failed with an error message.
317    pub fn fail(&mut self, message: impl Into<String>) {
318        self.end_time = Some(Utc::now());
319        self.level = ObservationLevel::Error;
320        self.status_message = Some(message.into());
321    }
322
323    /// Duration in milliseconds (if completed).
324    #[must_use]
325    pub fn duration_ms(&self) -> Option<u64> {
326        self.end_time.map(|end| {
327            let duration = end.signed_duration_since(self.start_time);
328            u64::try_from(duration.num_milliseconds().max(0)).unwrap_or(0)
329        })
330    }
331}
332
333/// Session groups multiple traces from the same user interaction.
334///
335/// Maps to Juncture's `thread_id` concept. A session typically
336/// represents a multi-turn conversation or workflow.
337#[derive(Clone, Debug, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct Session {
340    /// Session identifier (typically the `thread_id`).
341    pub id: String,
342    /// User identifier.
343    pub user_id: Option<String>,
344    /// Session creation timestamp.
345    pub created_at: DateTime<Utc>,
346}
347
348impl Session {
349    /// Create a new session with the given identifier.
350    #[must_use]
351    pub fn new(id: impl Into<String>) -> Self {
352        Self {
353            id: id.into(),
354            user_id: None,
355            created_at: Utc::now(),
356        }
357    }
358}
359
360/// Configuration for LLM prompt/response capture.
361///
362/// Controls how much data is captured during LLM calls.
363/// Allows full capture in development and truncated capture
364/// in production for privacy and performance.
365#[derive(Clone, Debug, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct CaptureConfig {
368    /// Maximum prompt content length in characters.
369    /// Content beyond this limit is truncated with a marker.
370    pub max_prompt_chars: usize,
371    /// Maximum response content length in characters.
372    pub max_response_chars: usize,
373    /// Whether to capture the full messages array from LLM calls.
374    pub capture_full_messages: bool,
375    /// Whether to capture tool input/output data.
376    pub capture_tool_io: bool,
377    /// Sensitive field keys to redact from captured data.
378    pub sensitive_keys: Vec<String>,
379}
380
381/// Per-model aggregated statistics.
382#[derive(Clone, Debug, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct ModelStats {
385    /// Model name (e.g., "claude-sonnet-4-20250514").
386    pub model: String,
387    /// Number of LLM calls using this model.
388    pub call_count: u64,
389    /// Total input tokens consumed.
390    pub input_tokens: u64,
391    /// Total output tokens produced.
392    pub output_tokens: u64,
393    /// Total cost in USD.
394    pub total_cost: f64,
395    /// Average latency in milliseconds.
396    pub avg_latency_ms: f64,
397}
398
399/// Overall summary statistics.
400#[derive(Clone, Debug, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct SummaryStats {
403    /// Total number of traces.
404    pub total_traces: u64,
405    /// Total number of observations.
406    pub total_observations: u64,
407    /// Total cost in USD across all traces.
408    pub total_cost: f64,
409    /// Total tokens consumed.
410    pub total_tokens: u64,
411    /// Number of error-level observations.
412    pub error_count: u64,
413    /// Number of active (non-completed) sessions.
414    pub active_sessions: u64,
415    /// Median latency in milliseconds.
416    pub latency_p50_ms: f64,
417    /// 95th percentile latency in milliseconds.
418    pub latency_p95_ms: f64,
419    /// 99th percentile latency in milliseconds.
420    pub latency_p99_ms: f64,
421}
422
423/// Enriched session with aggregated data.
424#[derive(Clone, Debug, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct EnrichedSession {
427    /// Session identifier.
428    pub id: String,
429    /// User identifier.
430    pub user_id: Option<String>,
431    /// Session creation timestamp (ISO 8601).
432    pub created_at: String,
433    /// Number of traces in this session.
434    pub trace_count: u64,
435    /// Total cost across all traces in this session.
436    pub total_cost: f64,
437    /// Total tokens across all traces in this session.
438    pub total_tokens: u64,
439    /// Timestamp of the most recent trace activity.
440    pub last_active: Option<String>,
441}
442
443impl Default for CaptureConfig {
444    fn default() -> Self {
445        Self {
446            max_prompt_chars: 10_000,
447            max_response_chars: 10_000,
448            capture_full_messages: true,
449            capture_tool_io: true,
450            sensitive_keys: vec![
451                "authorization".to_string(),
452                "api_key".to_string(),
453                "api-key".to_string(),
454                "password".to_string(),
455                "secret".to_string(),
456                "token".to_string(),
457            ],
458        }
459    }
460}
461
462impl CaptureConfig {
463    /// Truncate a string to the configured maximum length.
464    /// Returns the original string if within limits, otherwise
465    /// truncates and appends a marker.
466    #[must_use]
467    pub fn truncate(&self, content: &str, max_chars: usize) -> String {
468        if content.len() <= max_chars {
469            content.to_string()
470        } else {
471            let truncated: String = content.chars().take(max_chars).collect();
472            format!("{truncated}\n... [truncated at {max_chars} chars]")
473        }
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn trace_new_has_id_and_name() {
483        let trace = Trace::new("test_graph");
484        assert!(!trace.name.is_empty());
485        assert!(trace.end_time.is_none());
486    }
487
488    #[test]
489    fn trace_complete_sets_end_time() {
490        let mut trace = Trace::new("test_graph");
491        trace.complete(None, Some(0.05), Some(100));
492        assert!(trace.end_time.is_some());
493        assert_eq!(trace.total_cost, Some(0.05));
494        assert_eq!(trace.total_tokens, Some(100));
495    }
496
497    #[test]
498    fn observation_span_factory() {
499        let trace_id = Uuid::new_v4();
500        let obs = Observation::span(trace_id, "juncture.node.execute");
501        assert_eq!(obs.observation_type, ObservationType::Span);
502        assert_eq!(obs.name, "juncture.node.execute");
503        assert!(obs.end_time.is_none());
504    }
505
506    #[test]
507    fn observation_generation_factory() {
508        let trace_id = Uuid::new_v4();
509        let obs = Observation::generation(trace_id, "llm_call", "claude-sonnet-4-20250514");
510        assert_eq!(obs.observation_type, ObservationType::Generation);
511        assert_eq!(obs.model.as_deref(), Some("claude-sonnet-4-20250514"));
512    }
513
514    #[test]
515    fn observation_tool_call_factory() {
516        let trace_id = Uuid::new_v4();
517        let obs = Observation::tool_call(trace_id, "search");
518        assert_eq!(obs.observation_type, ObservationType::ToolCall);
519    }
520
521    #[test]
522    fn observation_complete_and_fail() {
523        let trace_id = Uuid::new_v4();
524        let mut obs = Observation::span(trace_id, "test");
525        obs.complete(Some(serde_json::json!({"result": "ok"})));
526        assert!(obs.end_time.is_some());
527        assert_eq!(obs.level, ObservationLevel::Default);
528
529        let mut obs2 = Observation::span(trace_id, "test2");
530        obs2.fail("something broke");
531        assert!(obs2.end_time.is_some());
532        assert_eq!(obs2.level, ObservationLevel::Error);
533        assert!(obs2.status_message.is_some());
534    }
535
536    #[test]
537    fn observation_with_parent() {
538        let trace_id = Uuid::new_v4();
539        let parent_id = Uuid::new_v4();
540        let obs = Observation::span(trace_id, "child").with_parent(parent_id);
541        assert_eq!(obs.parent_observation_id, Some(parent_id));
542    }
543
544    #[test]
545    fn observation_duration_ms() {
546        let trace_id = Uuid::new_v4();
547        let mut obs = Observation::span(trace_id, "test");
548        assert!(obs.duration_ms().is_none());
549        obs.complete(None);
550        assert!(obs.duration_ms().is_some());
551    }
552
553    #[test]
554    fn observation_type_display() {
555        assert_eq!(ObservationType::Span.to_string(), "SPAN");
556        assert_eq!(ObservationType::Generation.to_string(), "GENERATION");
557        assert_eq!(ObservationType::ToolCall.to_string(), "TOOL_CALL");
558        assert_eq!(ObservationType::Retrieval.to_string(), "RETRIEVAL");
559    }
560
561    #[test]
562    fn session_new() {
563        let session = Session::new("thread-123");
564        assert_eq!(session.id, "thread-123");
565        assert!(session.user_id.is_none());
566    }
567
568    #[test]
569    fn capture_config_truncate() {
570        let config = CaptureConfig::default();
571        let short = "hello";
572        assert_eq!(config.truncate(short, 100), "hello");
573
574        let long = "a".repeat(15_000);
575        let truncated = config.truncate(&long, 10_000);
576        assert!(truncated.len() > 10_000);
577        assert!(truncated.contains("truncated"));
578    }
579
580    #[test]
581    fn capture_config_default_sensitive_keys() {
582        let config = CaptureConfig::default();
583        assert!(config.sensitive_keys.contains(&"authorization".to_string()));
584        assert!(config.sensitive_keys.contains(&"api_key".to_string()));
585    }
586
587    #[test]
588    fn token_usage_default() {
589        let usage = TokenUsage::default();
590        assert_eq!(usage.input_tokens, 0);
591        assert_eq!(usage.output_tokens, 0);
592        assert_eq!(usage.total_tokens, 0);
593        assert!(usage.cached_tokens.is_none());
594    }
595}