Skip to main content

bamboo_engine/metrics/
types.rs

1//! Metrics types for tracking agent performance and usage
2//!
3//! This module provides data structures for collecting and aggregating
4//! metrics about agent sessions, token usage, tool calls, and performance.
5
6use std::collections::HashMap;
7
8use chrono::{DateTime, NaiveDate, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Re-exported shared token usage type.
12///
13/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
14pub use bamboo_domain::TokenUsage;
15
16/// Round execution status
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum RoundStatus {
20    /// Round is currently running
21    Running,
22    /// Round completed successfully
23    Success,
24    /// Round ended with an error
25    Error,
26    /// Round was cancelled by user
27    Cancelled,
28}
29
30impl RoundStatus {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::Running => "running",
34            Self::Success => "success",
35            Self::Error => "error",
36            Self::Cancelled => "cancelled",
37        }
38    }
39
40    pub fn from_db(value: &str) -> Option<Self> {
41        match value {
42            "running" => Some(Self::Running),
43            "success" => Some(Self::Success),
44            "error" => Some(Self::Error),
45            "cancelled" => Some(Self::Cancelled),
46            _ => None,
47        }
48    }
49}
50
51/// Session execution status
52#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "snake_case")]
54pub enum SessionStatus {
55    /// Session is currently active
56    Running,
57    /// Session completed successfully
58    Completed,
59    /// Session ended with an error
60    Error,
61    /// Session was cancelled by user
62    Cancelled,
63}
64
65impl SessionStatus {
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::Running => "running",
69            Self::Completed => "completed",
70            Self::Error => "error",
71            Self::Cancelled => "cancelled",
72        }
73    }
74
75    pub fn from_db(value: &str) -> Option<Self> {
76        match value {
77            "running" => Some(Self::Running),
78            "completed" => Some(Self::Completed),
79            "error" => Some(Self::Error),
80            "cancelled" => Some(Self::Cancelled),
81            _ => None,
82        }
83    }
84}
85
86/// Metrics for a single tool call
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct ToolCallMetrics {
89    /// Unique identifier for the tool call
90    pub tool_call_id: String,
91    /// Name of the tool that was called
92    pub tool_name: String,
93    /// When the tool call started
94    pub started_at: DateTime<Utc>,
95    /// When the tool call completed
96    pub completed_at: Option<DateTime<Utc>>,
97    /// Whether the call succeeded
98    pub success: Option<bool>,
99    /// Error message if the call failed
100    pub error: Option<String>,
101    /// Duration of the call in milliseconds
102    pub duration_ms: Option<u64>,
103}
104
105/// Metrics for a single conversation round
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub struct RoundMetrics {
108    /// Unique round identifier
109    pub round_id: String,
110    /// Session this round belongs to
111    pub session_id: String,
112    /// Model used for this round
113    pub model: String,
114    /// When the round started
115    pub started_at: DateTime<Utc>,
116    /// When the round completed
117    pub completed_at: Option<DateTime<Utc>>,
118    /// Token usage for this round
119    pub token_usage: TokenUsage,
120    /// Tool calls made during this round
121    pub tool_calls: Vec<ToolCallMetrics>,
122    /// Round execution status
123    pub status: RoundStatus,
124    /// Error message if round failed
125    pub error: Option<String>,
126    /// Round duration in milliseconds
127    pub duration_ms: Option<u64>,
128    /// Number of tool outputs compacted into prompt-side cache summaries in this round.
129    #[serde(default)]
130    pub prompt_cached_tool_outputs: u32,
131    /// Number of context compression events applied during this round.
132    #[serde(default)]
133    pub compression_count: u32,
134    /// Tokens saved by context compression during this round.
135    #[serde(default)]
136    pub tokens_saved: u32,
137}
138
139/// Metrics for an entire session
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141pub struct SessionMetrics {
142    /// Unique session identifier
143    pub session_id: String,
144    /// Model used for this session
145    pub model: String,
146    /// When the session started
147    pub started_at: DateTime<Utc>,
148    /// When the session completed
149    pub completed_at: Option<DateTime<Utc>>,
150    /// Total number of rounds in the session
151    pub total_rounds: u32,
152    /// Total token usage across all rounds
153    pub total_token_usage: TokenUsage,
154    /// Total number of tool calls
155    pub tool_call_count: u32,
156    /// Breakdown of tool calls by tool name
157    pub tool_breakdown: HashMap<String, u32>,
158    /// Session execution status
159    pub status: SessionStatus,
160    /// Total number of messages exchanged
161    pub message_count: u32,
162    /// Session duration in milliseconds
163    pub duration_ms: Option<u64>,
164    /// Total number of prompt-side cached tool outputs observed across rounds.
165    #[serde(default)]
166    pub prompt_cached_tool_outputs: u64,
167    /// Total number of context compression events across all rounds.
168    #[serde(default)]
169    pub total_compression_events: u64,
170    /// Total tokens saved by context compression across all rounds.
171    #[serde(default)]
172    pub total_tokens_saved: u64,
173}
174
175/// Detailed session metrics with round information
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177pub struct SessionDetail {
178    /// Session-level metrics
179    pub session: SessionMetrics,
180    /// Metrics for each round in the session
181    pub rounds: Vec<RoundMetrics>,
182}
183
184/// Aggregated metrics for a single day
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct DailyMetrics {
187    /// Date for these metrics
188    pub date: NaiveDate,
189    /// Total number of sessions
190    pub total_sessions: u32,
191    /// Total number of rounds
192    pub total_rounds: u32,
193    /// Total token usage
194    pub total_token_usage: TokenUsage,
195    /// Total number of tool calls
196    pub total_tool_calls: u32,
197    /// Token usage breakdown by model
198    pub model_breakdown: HashMap<String, TokenUsage>,
199    /// Tool call breakdown by tool name
200    pub tool_breakdown: HashMap<String, u32>,
201    /// Total number of prompt-side cached tool outputs observed on this day.
202    #[serde(default)]
203    pub prompt_cached_tool_outputs: u64,
204}
205
206/// Overall metrics summary
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208pub struct MetricsSummary {
209    /// Total number of sessions
210    pub total_sessions: u64,
211    /// Total token usage
212    pub total_tokens: TokenUsage,
213    /// Total number of tool calls
214    pub total_tool_calls: u64,
215    /// Number of currently active sessions
216    pub active_sessions: u64,
217    /// Total number of prompt-side cached tool outputs.
218    #[serde(default)]
219    pub prompt_cached_tool_outputs: u64,
220    /// Total number of execute sync mismatches observed for the filtered period.
221    #[serde(default)]
222    pub total_sync_mismatches: u64,
223    /// Breakdown of execute sync mismatches by stable reason label.
224    #[serde(default)]
225    pub sync_mismatch_breakdown: HashMap<String, u64>,
226}
227
228/// Metrics aggregated by model
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230pub struct ModelMetrics {
231    /// Model name
232    pub model: String,
233    /// Number of sessions using this model
234    pub sessions: u64,
235    /// Number of rounds using this model
236    pub rounds: u64,
237    /// Token usage for this model
238    pub tokens: TokenUsage,
239    /// Number of tool calls using this model
240    pub tool_calls: u64,
241    /// Number of prompt-side cached tool outputs for this model.
242    #[serde(default)]
243    pub prompt_cached_tool_outputs: u64,
244}
245
246/// Date filter for metrics queries
247#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
248pub struct MetricsDateFilter {
249    /// Start date (inclusive)
250    pub start_date: Option<NaiveDate>,
251    /// End date (inclusive)
252    pub end_date: Option<NaiveDate>,
253}
254
255/// Filter for session metrics queries
256#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
257pub struct SessionMetricsFilter {
258    /// Start date filter
259    pub start_date: Option<NaiveDate>,
260    /// End date filter
261    pub end_date: Option<NaiveDate>,
262    /// Filter by model name
263    pub model: Option<String>,
264    /// Limit number of results
265    pub limit: Option<u32>,
266}
267
268/// Status of a forwarded request
269#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(rename_all = "snake_case")]
271pub enum ForwardStatus {
272    /// Request succeeded
273    Success,
274    /// Request failed
275    Error,
276}
277
278impl ForwardStatus {
279    pub fn as_str(self) -> &'static str {
280        match self {
281            Self::Success => "success",
282            Self::Error => "error",
283        }
284    }
285
286    pub fn from_db(value: &str) -> Option<Self> {
287        match value {
288            "success" => Some(Self::Success),
289            "error" => Some(Self::Error),
290            _ => None,
291        }
292    }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct ForwardRequestMetrics {
297    pub forward_id: String,
298    pub endpoint: String,
299    pub model: String,
300    pub is_stream: bool,
301    pub started_at: DateTime<Utc>,
302    pub completed_at: Option<DateTime<Utc>>,
303    pub status_code: Option<u16>,
304    pub status: Option<ForwardStatus>,
305    pub token_usage: Option<TokenUsage>,
306    pub error: Option<String>,
307    pub duration_ms: Option<u64>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
311pub struct ForwardMetricsSummary {
312    pub total_requests: u64,
313    pub successful_requests: u64,
314    pub failed_requests: u64,
315    pub total_tokens: TokenUsage,
316    pub avg_duration_ms: Option<u64>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
320pub struct ForwardEndpointMetrics {
321    pub endpoint: String,
322    pub requests: u64,
323    pub successful: u64,
324    pub failed: u64,
325    pub tokens: TokenUsage,
326    pub avg_duration_ms: Option<u64>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
330pub struct ForwardMetricsFilter {
331    pub start_date: Option<NaiveDate>,
332    pub end_date: Option<NaiveDate>,
333    pub endpoint: Option<String>,
334    pub model: Option<String>,
335    pub limit: Option<u32>,
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_token_usage_default() {
344        let usage = TokenUsage::default();
345        assert_eq!(usage.prompt_tokens, 0);
346        assert_eq!(usage.completion_tokens, 0);
347        assert_eq!(usage.total_tokens, 0);
348    }
349
350    #[test]
351    fn test_token_usage_add_assign() {
352        let mut usage1 = TokenUsage {
353            prompt_tokens: 100,
354            completion_tokens: 50,
355            total_tokens: 150,
356        };
357        let usage2 = TokenUsage {
358            prompt_tokens: 200,
359            completion_tokens: 100,
360            total_tokens: 300,
361        };
362
363        usage1.add_assign(usage2);
364
365        assert_eq!(usage1.prompt_tokens, 300);
366        assert_eq!(usage1.completion_tokens, 150);
367        assert_eq!(usage1.total_tokens, 450);
368    }
369
370    #[test]
371    fn test_token_usage_serialization() {
372        let usage = TokenUsage {
373            prompt_tokens: 100,
374            completion_tokens: 50,
375            total_tokens: 150,
376        };
377
378        let json = serde_json::to_string(&usage).unwrap();
379        assert!(json.contains("\"prompt_tokens\":100"));
380        assert!(json.contains("\"completion_tokens\":50"));
381    }
382
383    #[test]
384    fn test_round_status_as_str() {
385        assert_eq!(RoundStatus::Running.as_str(), "running");
386        assert_eq!(RoundStatus::Success.as_str(), "success");
387        assert_eq!(RoundStatus::Error.as_str(), "error");
388        assert_eq!(RoundStatus::Cancelled.as_str(), "cancelled");
389    }
390
391    #[test]
392    fn test_round_status_from_db() {
393        assert_eq!(RoundStatus::from_db("running"), Some(RoundStatus::Running));
394        assert_eq!(RoundStatus::from_db("success"), Some(RoundStatus::Success));
395        assert_eq!(RoundStatus::from_db("invalid"), None);
396    }
397
398    #[test]
399    fn test_round_status_serialization() {
400        let status = RoundStatus::Success;
401        let json = serde_json::to_string(&status).unwrap();
402        assert!(json.contains("\"success\""));
403    }
404
405    #[test]
406    fn test_session_status_as_str() {
407        assert_eq!(SessionStatus::Running.as_str(), "running");
408        assert_eq!(SessionStatus::Completed.as_str(), "completed");
409        assert_eq!(SessionStatus::Error.as_str(), "error");
410        assert_eq!(SessionStatus::Cancelled.as_str(), "cancelled");
411    }
412
413    #[test]
414    fn test_session_status_from_db() {
415        assert_eq!(
416            SessionStatus::from_db("running"),
417            Some(SessionStatus::Running)
418        );
419        assert_eq!(
420            SessionStatus::from_db("completed"),
421            Some(SessionStatus::Completed)
422        );
423        assert_eq!(SessionStatus::from_db("invalid"), None);
424    }
425
426    #[test]
427    fn test_forward_status_as_str() {
428        assert_eq!(ForwardStatus::Success.as_str(), "success");
429        assert_eq!(ForwardStatus::Error.as_str(), "error");
430    }
431
432    #[test]
433    fn test_forward_status_from_db() {
434        assert_eq!(
435            ForwardStatus::from_db("success"),
436            Some(ForwardStatus::Success)
437        );
438        assert_eq!(ForwardStatus::from_db("error"), Some(ForwardStatus::Error));
439        assert_eq!(ForwardStatus::from_db("invalid"), None);
440    }
441
442    #[test]
443    fn test_metrics_date_filter_default() {
444        let filter = MetricsDateFilter::default();
445        assert!(filter.start_date.is_none());
446        assert!(filter.end_date.is_none());
447    }
448
449    #[test]
450    fn test_session_metrics_filter_default() {
451        let filter = SessionMetricsFilter::default();
452        assert!(filter.start_date.is_none());
453        assert!(filter.model.is_none());
454        assert!(filter.limit.is_none());
455    }
456
457    #[test]
458    fn test_forward_metrics_filter_default() {
459        let filter = ForwardMetricsFilter::default();
460        assert!(filter.start_date.is_none());
461        assert!(filter.endpoint.is_none());
462        assert!(filter.limit.is_none());
463    }
464
465    #[test]
466    fn test_tool_call_metrics_serialization() {
467        let metrics = ToolCallMetrics {
468            tool_call_id: "call-123".to_string(),
469            tool_name: "bash".to_string(),
470            started_at: Utc::now(),
471            completed_at: Some(Utc::now()),
472            success: Some(true),
473            error: None,
474            duration_ms: Some(150),
475        };
476
477        let json = serde_json::to_string(&metrics).unwrap();
478        assert!(json.contains("\"tool_call_id\":\"call-123\""));
479        assert!(json.contains("\"tool_name\":\"bash\""));
480    }
481
482    #[test]
483    fn test_round_metrics_serialization() {
484        let metrics = RoundMetrics {
485            round_id: "round-1".to_string(),
486            session_id: "session-1".to_string(),
487            model: "gpt-4".to_string(),
488            started_at: Utc::now(),
489            completed_at: None,
490            token_usage: TokenUsage::default(),
491            tool_calls: vec![],
492            status: RoundStatus::Running,
493            error: None,
494            duration_ms: None,
495            prompt_cached_tool_outputs: 0,
496            compression_count: 0,
497            tokens_saved: 0,
498        };
499
500        let json = serde_json::to_string(&metrics).unwrap();
501        assert!(json.contains("\"model\":\"gpt-4\""));
502    }
503
504    #[test]
505    fn test_session_metrics_serialization() {
506        let metrics = SessionMetrics {
507            session_id: "session-1".to_string(),
508            model: "gpt-4".to_string(),
509            started_at: Utc::now(),
510            completed_at: None,
511            total_rounds: 5,
512            total_token_usage: TokenUsage::default(),
513            tool_call_count: 10,
514            tool_breakdown: HashMap::new(),
515            status: SessionStatus::Running,
516            message_count: 15,
517            duration_ms: None,
518            prompt_cached_tool_outputs: 0,
519            total_compression_events: 0,
520            total_tokens_saved: 0,
521        };
522
523        let json = serde_json::to_string(&metrics).unwrap();
524        assert!(json.contains("\"session_id\":\"session-1\""));
525        assert!(json.contains("\"total_rounds\":5"));
526    }
527
528    #[test]
529    fn test_daily_metrics_serialization() {
530        let metrics = DailyMetrics {
531            date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
532            total_sessions: 10,
533            total_rounds: 50,
534            total_token_usage: TokenUsage::default(),
535            total_tool_calls: 100,
536            prompt_cached_tool_outputs: 0,
537            model_breakdown: HashMap::new(),
538            tool_breakdown: HashMap::new(),
539        };
540
541        let json = serde_json::to_string(&metrics).unwrap();
542        assert!(json.contains("\"total_sessions\":10"));
543        assert!(json.contains("\"total_rounds\":50"));
544    }
545
546    #[test]
547    fn test_metrics_summary_serialization() {
548        let summary = MetricsSummary {
549            total_sessions: 100,
550            total_tokens: TokenUsage::default(),
551            total_tool_calls: 500,
552            active_sessions: 5,
553            prompt_cached_tool_outputs: 0,
554            total_sync_mismatches: 0,
555            sync_mismatch_breakdown: HashMap::new(),
556        };
557
558        let json = serde_json::to_string(&summary).unwrap();
559        assert!(json.contains("\"total_sessions\":100"));
560        assert!(json.contains("\"active_sessions\":5"));
561    }
562
563    #[test]
564    fn test_model_metrics_serialization() {
565        let metrics = ModelMetrics {
566            model: "gpt-4".to_string(),
567            sessions: 50,
568            rounds: 200,
569            tokens: TokenUsage::default(),
570            tool_calls: 100,
571            prompt_cached_tool_outputs: 0,
572        };
573
574        let json = serde_json::to_string(&metrics).unwrap();
575        assert!(json.contains("\"model\":\"gpt-4\""));
576        assert!(json.contains("\"sessions\":50"));
577    }
578
579    #[test]
580    fn test_forward_request_metrics_serialization() {
581        let metrics = ForwardRequestMetrics {
582            forward_id: "fwd-123".to_string(),
583            endpoint: "/api/chat".to_string(),
584            model: "gpt-4".to_string(),
585            is_stream: true,
586            started_at: Utc::now(),
587            completed_at: Some(Utc::now()),
588            status_code: Some(200),
589            status: Some(ForwardStatus::Success),
590            token_usage: None,
591            error: None,
592            duration_ms: Some(250),
593        };
594
595        let json = serde_json::to_string(&metrics).unwrap();
596        assert!(json.contains("\"forward_id\":\"fwd-123\""));
597        assert!(json.contains("\"endpoint\":\"/api/chat\""));
598    }
599
600    #[test]
601    fn test_forward_metrics_summary_serialization() {
602        let summary = ForwardMetricsSummary {
603            total_requests: 1000,
604            successful_requests: 950,
605            failed_requests: 50,
606            total_tokens: TokenUsage::default(),
607            avg_duration_ms: Some(200),
608        };
609
610        let json = serde_json::to_string(&summary).unwrap();
611        assert!(json.contains("\"total_requests\":1000"));
612        assert!(json.contains("\"successful_requests\":950"));
613    }
614
615    #[test]
616    fn test_token_usage_clone() {
617        let usage = TokenUsage {
618            prompt_tokens: 100,
619            completion_tokens: 50,
620            total_tokens: 150,
621        };
622        let cloned = usage.clone();
623        assert_eq!(usage.prompt_tokens, cloned.prompt_tokens);
624    }
625
626    #[test]
627    fn test_round_status_clone() {
628        let status = RoundStatus::Success;
629        let cloned = status.clone();
630        assert_eq!(status, cloned);
631    }
632
633    #[test]
634    fn test_session_status_clone() {
635        let status = SessionStatus::Completed;
636        let cloned = status.clone();
637        assert_eq!(status, cloned);
638    }
639
640    #[test]
641    fn test_forward_status_clone() {
642        let status = ForwardStatus::Success;
643        let cloned = status.clone();
644        assert_eq!(status, cloned);
645    }
646
647    #[test]
648    fn test_token_usage_eq() {
649        let usage1 = TokenUsage {
650            prompt_tokens: 100,
651            completion_tokens: 50,
652            total_tokens: 150,
653        };
654        let usage2 = TokenUsage {
655            prompt_tokens: 100,
656            completion_tokens: 50,
657            total_tokens: 150,
658        };
659        assert_eq!(usage1, usage2);
660    }
661
662    #[test]
663    fn test_round_status_eq() {
664        assert_eq!(RoundStatus::Running, RoundStatus::Running);
665        assert_ne!(RoundStatus::Running, RoundStatus::Success);
666    }
667
668    #[test]
669    fn test_session_status_eq() {
670        assert_eq!(SessionStatus::Running, SessionStatus::Running);
671        assert_ne!(SessionStatus::Running, SessionStatus::Completed);
672    }
673
674    #[test]
675    fn test_round_metrics_compression_fields_deserialize_with_defaults() {
676        let json = r#"{"round_id":"r1","session_id":"s1","model":"m","started_at":"2026-01-01T00:00:00Z","completed_at":null,"token_usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0},"tool_calls":[],"status":"running","error":null,"duration_ms":null,"prompt_cached_tool_outputs":0}"#;
677        let metrics: RoundMetrics = serde_json::from_str(json).unwrap();
678        assert_eq!(metrics.compression_count, 0);
679        assert_eq!(metrics.tokens_saved, 0);
680    }
681
682    #[test]
683    fn test_session_metrics_compression_fields_deserialize_with_defaults() {
684        let json = r#"{"session_id":"s1","model":"m","started_at":"2026-01-01T00:00:00Z","completed_at":null,"total_rounds":0,"total_token_usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0},"tool_call_count":0,"tool_breakdown":{},"status":"running","message_count":0,"duration_ms":null,"prompt_cached_tool_outputs":0}"#;
685        let metrics: SessionMetrics = serde_json::from_str(json).unwrap();
686        assert_eq!(metrics.total_compression_events, 0);
687        assert_eq!(metrics.total_tokens_saved, 0);
688    }
689}