1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11pub type Id = Uuid;
13
14#[derive(Clone, Debug, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct Trace {
22 pub id: Id,
24 pub name: String,
26 pub user_id: Option<String>,
28 pub session_id: Option<String>,
31 pub tags: Vec<String>,
33 pub metadata: serde_json::Value,
35 pub environment: Option<String>,
37 pub release: Option<String>,
39 pub input: Option<serde_json::Value>,
41 pub output: Option<serde_json::Value>,
43 pub start_time: DateTime<Utc>,
45 pub end_time: Option<DateTime<Utc>>,
47 pub total_cost: Option<f64>,
49 pub total_tokens: Option<u64>,
51}
52
53impl Trace {
54 #[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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum ObservationType {
96 Span,
98 Generation,
100 ToolCall,
102 Retrieval,
104}
105
106impl ObservationType {
107 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
131pub enum ObservationLevel {
132 Debug,
134 Default,
136 Warning,
138 Error,
140}
141
142impl ObservationLevel {
143 #[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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct TokenUsage {
168 pub input_tokens: u64,
170 pub output_tokens: u64,
172 pub total_tokens: u64,
174 #[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#[derive(Clone, Debug, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct Observation {
198 pub id: Id,
200 pub trace_id: Id,
202 pub parent_observation_id: Option<Id>,
204 pub name: String,
206 pub observation_type: ObservationType,
208 pub start_time: DateTime<Utc>,
210 pub end_time: Option<DateTime<Utc>>,
212 pub input: Option<serde_json::Value>,
214 pub output: Option<serde_json::Value>,
216 pub metadata: serde_json::Value,
218 pub level: ObservationLevel,
220 pub status_message: Option<String>,
222 pub model: Option<String>,
225 pub model_parameters: Option<serde_json::Value>,
227 pub usage: Option<TokenUsage>,
229 pub cost: Option<f64>,
231}
232
233impl Observation {
234 #[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 #[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 #[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 #[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 pub fn complete(&mut self, output: Option<serde_json::Value>) {
312 self.end_time = Some(Utc::now());
313 self.output = output;
314 }
315
316 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 #[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#[derive(Clone, Debug, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct Session {
340 pub id: String,
342 pub user_id: Option<String>,
344 pub created_at: DateTime<Utc>,
346}
347
348impl Session {
349 #[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#[derive(Clone, Debug, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct CaptureConfig {
368 pub max_prompt_chars: usize,
371 pub max_response_chars: usize,
373 pub capture_full_messages: bool,
375 pub capture_tool_io: bool,
377 pub sensitive_keys: Vec<String>,
379}
380
381#[derive(Clone, Debug, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct ModelStats {
385 pub model: String,
387 pub call_count: u64,
389 pub input_tokens: u64,
391 pub output_tokens: u64,
393 pub total_cost: f64,
395 pub avg_latency_ms: f64,
397}
398
399#[derive(Clone, Debug, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct SummaryStats {
403 pub total_traces: u64,
405 pub total_observations: u64,
407 pub total_cost: f64,
409 pub total_tokens: u64,
411 pub error_count: u64,
413 pub active_sessions: u64,
415 pub latency_p50_ms: f64,
417 pub latency_p95_ms: f64,
419 pub latency_p99_ms: f64,
421}
422
423#[derive(Clone, Debug, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct EnrichedSession {
427 pub id: String,
429 pub user_id: Option<String>,
431 pub created_at: String,
433 pub trace_count: u64,
435 pub total_cost: f64,
437 pub total_tokens: u64,
439 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 #[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}