1use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use std::collections::{HashMap, VecDeque};
34use std::io::Write;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct AuditEntry {
41 pub id: u64,
43 pub timestamp: DateTime<Utc>,
45 pub model_id: String,
47 pub input: String,
49 pub output: Vec<String>,
51 pub latency_ms: u64,
53 pub cache_hit: bool,
55 pub tags: Vec<String>,
57}
58
59#[derive(Debug, Clone, Default)]
65pub struct AuditFilter {
66 pub since: Option<DateTime<Utc>>,
68 pub model_id: Option<String>,
70 pub cache_hit: Option<bool>,
72 pub min_latency_ms: Option<u64>,
74}
75
76impl AuditFilter {
77 pub fn matches(&self, entry: &AuditEntry) -> bool {
79 if let Some(since) = self.since {
80 if entry.timestamp < since {
81 return false;
82 }
83 }
84 if let Some(ref model) = self.model_id {
85 if &entry.model_id != model {
86 return false;
87 }
88 }
89 if let Some(hit) = self.cache_hit {
90 if entry.cache_hit != hit {
91 return false;
92 }
93 }
94 if let Some(min_lat) = self.min_latency_ms {
95 if entry.latency_ms < min_lat {
96 return false;
97 }
98 }
99 true
100 }
101}
102
103#[derive(Debug, Clone)]
107pub struct AuditStats {
108 pub total_entries: usize,
110 pub cache_hit_rate: f64,
112 pub avg_latency_ms: f64,
114 pub by_model: HashMap<String, u64>,
116}
117
118pub struct AuditLog {
125 entries: VecDeque<AuditEntry>,
126 capacity: usize,
127 next_id: u64,
128}
129
130impl AuditLog {
131 pub fn new(capacity: usize) -> Self {
136 Self {
137 entries: VecDeque::with_capacity(capacity.min(4096)),
138 capacity,
139 next_id: 1,
140 }
141 }
142
143 pub fn append(&mut self, mut entry: AuditEntry) {
147 if self.capacity == 0 {
148 return;
149 }
150 if self.entries.len() >= self.capacity {
151 self.entries.pop_front();
152 }
153 entry.id = self.next_id;
154 self.next_id += 1;
155 self.entries.push_back(entry);
156 }
157
158 pub fn query(&self, filter: &AuditFilter) -> Vec<&AuditEntry> {
160 self.entries.iter().filter(|e| filter.matches(e)).collect()
161 }
162
163 pub fn stats(&self) -> AuditStats {
165 let total = self.entries.len();
166 if total == 0 {
167 return AuditStats {
168 total_entries: 0,
169 cache_hit_rate: 0.0,
170 avg_latency_ms: 0.0,
171 by_model: HashMap::new(),
172 };
173 }
174 let hits = self.entries.iter().filter(|e| e.cache_hit).count();
175 let total_latency: u64 = self.entries.iter().map(|e| e.latency_ms).sum();
176 let mut by_model: HashMap<String, u64> = HashMap::new();
177 for e in &self.entries {
178 *by_model.entry(e.model_id.clone()).or_insert(0) += 1;
179 }
180 AuditStats {
181 total_entries: total,
182 cache_hit_rate: hits as f64 / total as f64,
183 avg_latency_ms: total_latency as f64 / total as f64,
184 by_model,
185 }
186 }
187
188 pub fn export_jsonl(&self, writer: &mut dyn Write) -> std::io::Result<()> {
192 for entry in &self.entries {
193 let line = serde_json::to_string(entry)
194 .map_err(std::io::Error::other)?;
195 writeln!(writer, "{}", line)?;
196 }
197 Ok(())
198 }
199
200 pub fn len(&self) -> usize {
202 self.entries.len()
203 }
204
205 pub fn is_empty(&self) -> bool {
207 self.entries.is_empty()
208 }
209
210 pub fn capacity(&self) -> usize {
212 self.capacity
213 }
214}
215
216#[derive(Debug, Serialize)]
223pub struct AuditQueryResponse {
224 pub entries: Vec<AuditEntry>,
226 pub count: usize,
228}
229
230#[derive(Debug, Serialize)]
232pub struct AuditStatsResponse {
233 pub total_entries: usize,
235 pub cache_hit_rate: f64,
237 pub avg_latency_ms: f64,
239 pub by_model: HashMap<String, u64>,
241}
242
243impl From<AuditStats> for AuditStatsResponse {
244 fn from(s: AuditStats) -> Self {
245 Self {
246 total_entries: s.total_entries,
247 cache_hit_rate: s.cache_hit_rate,
248 avg_latency_ms: s.avg_latency_ms,
249 by_model: s.by_model,
250 }
251 }
252}
253
254#[cfg(test)]
257mod tests {
258 use super::*;
259 use chrono::{Duration, Utc};
260
261 fn make_entry(model: &str, latency_ms: u64, cache_hit: bool) -> AuditEntry {
262 AuditEntry {
263 id: 0, timestamp: Utc::now(),
265 model_id: model.to_string(),
266 input: "test input".to_string(),
267 output: vec!["test output".to_string()],
268 latency_ms,
269 cache_hit,
270 tags: vec![],
271 }
272 }
273
274 #[test]
277 fn append_and_query_all() {
278 let mut log = AuditLog::new(100);
279 log.append(make_entry("gpt-4o", 200, false));
280 log.append(make_entry("claude-3", 150, true));
281 let all = log.query(&AuditFilter::default());
282 assert_eq!(all.len(), 2);
283 }
284
285 #[test]
286 fn ids_are_assigned_sequentially() {
287 let mut log = AuditLog::new(100);
288 log.append(make_entry("m", 100, false));
289 log.append(make_entry("m", 100, false));
290 log.append(make_entry("m", 100, false));
291 let all = log.query(&AuditFilter::default());
292 let ids: Vec<u64> = all.iter().map(|e| e.id).collect();
293 assert_eq!(ids, vec![1, 2, 3]);
294 }
295
296 #[test]
297 fn capacity_evicts_oldest() {
298 let mut log = AuditLog::new(2);
299 log.append(make_entry("m", 100, false));
300 log.append(make_entry("m", 200, false));
301 log.append(make_entry("m", 300, false)); assert_eq!(log.len(), 2);
303 let latencies: Vec<u64> = log.query(&AuditFilter::default())
304 .iter().map(|e| e.latency_ms).collect();
305 assert_eq!(latencies, vec![200, 300]);
306 }
307
308 #[test]
309 fn capacity_zero_stores_nothing() {
310 let mut log = AuditLog::new(0);
311 log.append(make_entry("m", 100, false));
312 assert!(log.is_empty());
313 }
314
315 #[test]
318 fn filter_by_model_id() {
319 let mut log = AuditLog::new(100);
320 log.append(make_entry("gpt-4o", 100, false));
321 log.append(make_entry("claude-3", 200, false));
322 let filter = AuditFilter { model_id: Some("gpt-4o".to_string()), ..Default::default() };
323 let results = log.query(&filter);
324 assert_eq!(results.len(), 1);
325 assert_eq!(results[0].model_id, "gpt-4o");
326 }
327
328 #[test]
329 fn filter_by_cache_hit() {
330 let mut log = AuditLog::new(100);
331 log.append(make_entry("m", 100, false));
332 log.append(make_entry("m", 200, true));
333 log.append(make_entry("m", 300, true));
334 let filter = AuditFilter { cache_hit: Some(true), ..Default::default() };
335 let results = log.query(&filter);
336 assert_eq!(results.len(), 2);
337 }
338
339 #[test]
340 fn filter_by_min_latency() {
341 let mut log = AuditLog::new(100);
342 log.append(make_entry("m", 50, false));
343 log.append(make_entry("m", 100, false));
344 log.append(make_entry("m", 500, false));
345 let filter = AuditFilter { min_latency_ms: Some(100), ..Default::default() };
346 let results = log.query(&filter);
347 assert_eq!(results.len(), 2);
348 }
349
350 #[test]
351 fn filter_by_since() {
352 let mut log = AuditLog::new(100);
353 let old_entry = AuditEntry {
354 id: 0,
355 timestamp: Utc::now() - Duration::hours(2),
356 model_id: "m".to_string(),
357 input: "old".to_string(),
358 output: vec![],
359 latency_ms: 100,
360 cache_hit: false,
361 tags: vec![],
362 };
363 let new_entry = make_entry("m", 100, false);
364 log.append(old_entry);
365 log.append(new_entry);
366 let cutoff = Utc::now() - Duration::minutes(30);
367 let filter = AuditFilter { since: Some(cutoff), ..Default::default() };
368 let results = log.query(&filter);
369 assert_eq!(results.len(), 1);
370 }
371
372 #[test]
373 fn filter_combined_predicates() {
374 let mut log = AuditLog::new(100);
375 log.append(make_entry("gpt-4o", 100, false));
376 log.append(make_entry("gpt-4o", 500, true));
377 log.append(make_entry("claude-3", 500, true));
378 let filter = AuditFilter {
379 model_id: Some("gpt-4o".to_string()),
380 cache_hit: Some(true),
381 ..Default::default()
382 };
383 let results = log.query(&filter);
384 assert_eq!(results.len(), 1);
385 assert_eq!(results[0].latency_ms, 500);
386 }
387
388 #[test]
391 fn stats_empty_log() {
392 let log = AuditLog::new(100);
393 let stats = log.stats();
394 assert_eq!(stats.total_entries, 0);
395 assert_eq!(stats.cache_hit_rate, 0.0);
396 assert_eq!(stats.avg_latency_ms, 0.0);
397 assert!(stats.by_model.is_empty());
398 }
399
400 #[test]
401 fn stats_cache_hit_rate() {
402 let mut log = AuditLog::new(100);
403 log.append(make_entry("m", 100, true));
404 log.append(make_entry("m", 100, false));
405 let stats = log.stats();
406 assert!((stats.cache_hit_rate - 0.5).abs() < 1e-9);
407 }
408
409 #[test]
410 fn stats_avg_latency() {
411 let mut log = AuditLog::new(100);
412 log.append(make_entry("m", 100, false));
413 log.append(make_entry("m", 200, false));
414 let stats = log.stats();
415 assert!((stats.avg_latency_ms - 150.0).abs() < 1e-9);
416 }
417
418 #[test]
419 fn stats_by_model_counts() {
420 let mut log = AuditLog::new(100);
421 log.append(make_entry("gpt-4o", 100, false));
422 log.append(make_entry("gpt-4o", 100, false));
423 log.append(make_entry("claude-3", 100, false));
424 let stats = log.stats();
425 assert_eq!(stats.by_model["gpt-4o"], 2);
426 assert_eq!(stats.by_model["claude-3"], 1);
427 }
428
429 #[test]
432 fn export_jsonl_produces_valid_lines() {
433 let mut log = AuditLog::new(100);
434 log.append(make_entry("gpt-4o", 100, false));
435 log.append(make_entry("claude-3", 200, true));
436
437 let mut buf: Vec<u8> = Vec::new();
438 log.export_jsonl(&mut buf).unwrap();
439
440 let text = String::from_utf8(buf).unwrap();
441 let lines: Vec<&str> = text.lines().collect();
442 assert_eq!(lines.len(), 2);
443
444 for line in &lines {
446 let val: serde_json::Value = serde_json::from_str(line).unwrap();
447 assert!(val.get("model_id").is_some());
448 assert!(val.get("latency_ms").is_some());
449 }
450 }
451
452 #[test]
453 fn export_jsonl_empty_log_empty_output() {
454 let log = AuditLog::new(100);
455 let mut buf: Vec<u8> = Vec::new();
456 log.export_jsonl(&mut buf).unwrap();
457 assert!(buf.is_empty());
458 }
459
460 #[test]
463 fn audit_stats_response_from_stats() {
464 let mut log = AuditLog::new(100);
465 log.append(make_entry("m", 100, true));
466 let stats = log.stats();
467 let resp = AuditStatsResponse::from(stats.clone());
468 assert_eq!(resp.total_entries, stats.total_entries);
469 assert!((resp.cache_hit_rate - stats.cache_hit_rate).abs() < 1e-9);
470 }
471}