Skip to main content

agentic_forge_core/metrics/
audit.rs

1//! Audit log generation for every MCP call.
2
3use super::tokens::Layer;
4use serde::{Deserialize, Serialize};
5use std::sync::Mutex;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct AuditEntry {
9    pub timestamp: i64,
10    pub tool: String,
11    pub layer: Layer,
12    pub tokens_used: u64,
13    pub tokens_saved: u64,
14    pub cache_hit: bool,
15    pub intent: String,
16    pub source_size: u64,
17    pub result_size: u64,
18}
19
20impl AuditEntry {
21    pub fn waste_ratio(&self) -> f64 {
22        if self.source_size == 0 {
23            return 0.0;
24        }
25        self.result_size as f64 / self.source_size as f64
26    }
27}
28
29pub struct AuditLog {
30    entries: Mutex<Vec<AuditEntry>>,
31    max_entries: usize,
32}
33
34impl AuditLog {
35    pub fn new(max_entries: usize) -> Self {
36        Self {
37            entries: Mutex::new(Vec::new()),
38            max_entries,
39        }
40    }
41
42    pub fn record(&self, entry: AuditEntry) {
43        let mut entries = self.entries.lock().unwrap();
44        if entries.len() >= self.max_entries {
45            entries.remove(0);
46        }
47        entries.push(entry);
48    }
49
50    pub fn entries(&self) -> Vec<AuditEntry> {
51        self.entries.lock().unwrap().clone()
52    }
53
54    pub fn len(&self) -> usize {
55        self.entries.lock().unwrap().len()
56    }
57
58    pub fn is_empty(&self) -> bool {
59        self.entries.lock().unwrap().is_empty()
60    }
61
62    pub fn clear(&self) {
63        self.entries.lock().unwrap().clear();
64    }
65
66    pub fn average_waste_ratio(&self) -> f64 {
67        let entries = self.entries.lock().unwrap();
68        if entries.is_empty() {
69            return 0.0;
70        }
71        let total: f64 = entries.iter().map(|e| e.waste_ratio()).sum();
72        total / entries.len() as f64
73    }
74
75    pub fn total_tokens_used(&self) -> u64 {
76        self.entries
77            .lock()
78            .unwrap()
79            .iter()
80            .map(|e| e.tokens_used)
81            .sum()
82    }
83
84    pub fn total_tokens_saved(&self) -> u64 {
85        self.entries
86            .lock()
87            .unwrap()
88            .iter()
89            .map(|e| e.tokens_saved)
90            .sum()
91    }
92
93    pub fn cache_hit_rate(&self) -> f64 {
94        let entries = self.entries.lock().unwrap();
95        if entries.is_empty() {
96            return 0.0;
97        }
98        let hits = entries.iter().filter(|e| e.cache_hit).count();
99        hits as f64 / entries.len() as f64
100    }
101
102    pub fn layer_distribution(&self) -> std::collections::HashMap<u8, usize> {
103        let entries = self.entries.lock().unwrap();
104        let mut dist = std::collections::HashMap::new();
105        for entry in entries.iter() {
106            *dist.entry(entry.layer.number()).or_insert(0) += 1;
107        }
108        dist
109    }
110}
111
112impl Default for AuditLog {
113    fn default() -> Self {
114        Self::new(10_000)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn make_entry(
123        tool: &str,
124        layer: Layer,
125        tokens: u64,
126        saved: u64,
127        cache_hit: bool,
128    ) -> AuditEntry {
129        AuditEntry {
130            timestamp: chrono::Utc::now().timestamp_micros(),
131            tool: tool.into(),
132            layer,
133            tokens_used: tokens,
134            tokens_saved: saved,
135            cache_hit,
136            intent: "test".into(),
137            source_size: 1000,
138            result_size: tokens,
139        }
140    }
141
142    #[test]
143    fn test_audit_log_record() {
144        let log = AuditLog::new(100);
145        log.record(make_entry("test_tool", Layer::Full, 500, 0, false));
146        assert_eq!(log.len(), 1);
147    }
148
149    #[test]
150    fn test_audit_log_max_entries() {
151        let log = AuditLog::new(3);
152        for i in 0..5 {
153            let tool = format!("tool_{}", i);
154            log.record(make_entry(&tool, Layer::Full, 100, 0, false));
155        }
156        assert_eq!(log.len(), 3);
157    }
158
159    #[test]
160    fn test_audit_log_cache_hit_rate() {
161        let log = AuditLog::new(100);
162        log.record(make_entry("a", Layer::Cache, 0, 500, true));
163        log.record(make_entry("b", Layer::Full, 500, 0, false));
164        log.record(make_entry("c", Layer::Cache, 0, 500, true));
165        assert!((log.cache_hit_rate() - 0.6667).abs() < 0.01);
166    }
167
168    #[test]
169    fn test_audit_log_total_tokens() {
170        let log = AuditLog::new(100);
171        log.record(make_entry("a", Layer::Full, 100, 0, false));
172        log.record(make_entry("b", Layer::Scoped, 50, 450, false));
173        assert_eq!(log.total_tokens_used(), 150);
174        assert_eq!(log.total_tokens_saved(), 450);
175    }
176
177    #[test]
178    fn test_audit_log_layer_distribution() {
179        let log = AuditLog::new(100);
180        log.record(make_entry("a", Layer::Cache, 0, 500, true));
181        log.record(make_entry("b", Layer::Cache, 0, 500, true));
182        log.record(make_entry("c", Layer::Full, 500, 0, false));
183        let dist = log.layer_distribution();
184        assert_eq!(dist.get(&0), Some(&2)); // Layer::Cache = 0
185        assert_eq!(dist.get(&4), Some(&1)); // Layer::Full = 4
186    }
187
188    #[test]
189    fn test_audit_log_clear() {
190        let log = AuditLog::new(100);
191        log.record(make_entry("a", Layer::Full, 100, 0, false));
192        log.clear();
193        assert!(log.is_empty());
194    }
195
196    #[test]
197    fn test_waste_ratio() {
198        let entry = AuditEntry {
199            timestamp: 0,
200            tool: "test".into(),
201            layer: Layer::Full,
202            tokens_used: 500,
203            tokens_saved: 0,
204            cache_hit: false,
205            intent: "full".into(),
206            source_size: 50000,
207            result_size: 150,
208        };
209        assert!(entry.waste_ratio() < 0.01);
210    }
211}