1use std::collections::{HashMap, HashSet};
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::Mutex;
44
45use thiserror::Error;
46
47#[derive(Debug, Clone)]
51pub enum AuditEvent {
52 QueryStarted {
54 goal: String,
56 session_id: String,
58 timestamp_ms: u64,
60 },
61
62 PeerQueried {
64 peer_id: String,
66 goal: String,
68 timestamp_ms: u64,
70 },
71
72 RuleFired {
74 rule_id: String,
76 bindings: HashMap<String, String>,
78 timestamp_ms: u64,
80 },
81
82 ResultReceived {
84 peer_id: String,
86 binding_count: usize,
88 is_final: bool,
90 timestamp_ms: u64,
92 },
93
94 QueryCompleted {
96 session_id: String,
98 total_results: usize,
100 duration_ms: u64,
102 timestamp_ms: u64,
104 },
105
106 QueryFailed {
108 session_id: String,
110 reason: String,
112 timestamp_ms: u64,
114 },
115}
116
117impl AuditEvent {
118 pub fn event_type(&self) -> &str {
122 match self {
123 AuditEvent::QueryStarted { .. } => "query_started",
124 AuditEvent::PeerQueried { .. } => "peer_queried",
125 AuditEvent::RuleFired { .. } => "rule_fired",
126 AuditEvent::ResultReceived { .. } => "result_received",
127 AuditEvent::QueryCompleted { .. } => "query_completed",
128 AuditEvent::QueryFailed { .. } => "query_failed",
129 }
130 }
131
132 pub fn timestamp_ms(&self) -> u64 {
134 match self {
135 AuditEvent::QueryStarted { timestamp_ms, .. } => *timestamp_ms,
136 AuditEvent::PeerQueried { timestamp_ms, .. } => *timestamp_ms,
137 AuditEvent::RuleFired { timestamp_ms, .. } => *timestamp_ms,
138 AuditEvent::ResultReceived { timestamp_ms, .. } => *timestamp_ms,
139 AuditEvent::QueryCompleted { timestamp_ms, .. } => *timestamp_ms,
140 AuditEvent::QueryFailed { timestamp_ms, .. } => *timestamp_ms,
141 }
142 }
143}
144
145#[derive(Debug, Clone)]
149pub struct AuditEntry {
150 pub sequence: u64,
152 pub event: AuditEvent,
154 pub trace_id: String,
156}
157
158#[derive(Debug, Error)]
162pub enum AuditError {
163 #[error("audit log capacity exceeded: current={current}, max={max}")]
165 CapacityExceeded {
166 current: usize,
168 max: usize,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct AuditStatsSnapshot {
178 pub total_appended: u64,
180 pub total_trimmed: u64,
182 pub total_queries_started: u64,
184 pub total_queries_completed: u64,
186 pub total_queries_failed: u64,
188}
189
190#[derive(Debug)]
195pub struct AuditStats {
196 pub total_appended: AtomicU64,
198 pub total_trimmed: AtomicU64,
200 pub total_queries_started: AtomicU64,
202 pub total_queries_completed: AtomicU64,
204 pub total_queries_failed: AtomicU64,
206}
207
208impl Default for AuditStats {
209 fn default() -> Self {
210 Self {
211 total_appended: AtomicU64::new(0),
212 total_trimmed: AtomicU64::new(0),
213 total_queries_started: AtomicU64::new(0),
214 total_queries_completed: AtomicU64::new(0),
215 total_queries_failed: AtomicU64::new(0),
216 }
217 }
218}
219
220impl AuditStats {
221 pub fn snapshot(&self) -> AuditStatsSnapshot {
227 AuditStatsSnapshot {
228 total_appended: self.total_appended.load(Ordering::Acquire),
229 total_trimmed: self.total_trimmed.load(Ordering::Acquire),
230 total_queries_started: self.total_queries_started.load(Ordering::Acquire),
231 total_queries_completed: self.total_queries_completed.load(Ordering::Acquire),
232 total_queries_failed: self.total_queries_failed.load(Ordering::Acquire),
233 }
234 }
235}
236
237pub struct InferenceAuditLog {
256 pub entries: Mutex<Vec<AuditEntry>>,
258 pub next_sequence: AtomicU64,
260 pub max_entries: usize,
262 pub stats: AuditStats,
264}
265
266impl Default for InferenceAuditLog {
267 fn default() -> Self {
268 Self::new(100_000)
269 }
270}
271
272impl InferenceAuditLog {
273 pub fn new(max_entries: usize) -> Self {
275 Self {
276 entries: Mutex::new(Vec::new()),
277 next_sequence: AtomicU64::new(0),
278 max_entries,
279 stats: AuditStats::default(),
280 }
281 }
282
283 pub fn append(&self, trace_id: &str, event: AuditEvent) -> Result<u64, AuditError> {
289 match &event {
292 AuditEvent::QueryStarted { .. } => {
293 self.stats
294 .total_queries_started
295 .fetch_add(1, Ordering::Relaxed);
296 }
297 AuditEvent::QueryCompleted { .. } => {
298 self.stats
299 .total_queries_completed
300 .fetch_add(1, Ordering::Relaxed);
301 }
302 AuditEvent::QueryFailed { .. } => {
303 self.stats
304 .total_queries_failed
305 .fetch_add(1, Ordering::Relaxed);
306 }
307 _ => {}
308 }
309
310 let mut guard = self.entries.lock().expect("audit log mutex poisoned");
311
312 let current = guard.len();
313 if current >= self.max_entries {
314 return Err(AuditError::CapacityExceeded {
315 current,
316 max: self.max_entries,
317 });
318 }
319
320 let seq = self.next_sequence.fetch_add(1, Ordering::AcqRel);
321 guard.push(AuditEntry {
322 sequence: seq,
323 event,
324 trace_id: trace_id.to_string(),
325 });
326
327 self.stats.total_appended.fetch_add(1, Ordering::Relaxed);
328
329 Ok(seq)
330 }
331
332 pub fn entries_for_trace(&self, trace_id: &str) -> Vec<AuditEntry> {
336 let guard = self.entries.lock().expect("audit log mutex poisoned");
337 guard
338 .iter()
339 .filter(|e| e.trace_id == trace_id)
340 .cloned()
341 .collect()
342 }
343
344 pub fn events_since(&self, sequence: u64) -> Vec<AuditEntry> {
348 let guard = self.entries.lock().expect("audit log mutex poisoned");
349 guard
350 .iter()
351 .filter(|e| e.sequence > sequence)
352 .cloned()
353 .collect()
354 }
355
356 pub fn trim_before(&self, sequence: u64) -> usize {
361 let mut guard = self.entries.lock().expect("audit log mutex poisoned");
362 let before = guard.len();
363 guard.retain(|e| e.sequence >= sequence);
364 let removed = before - guard.len();
365 if removed > 0 {
366 self.stats
367 .total_trimmed
368 .fetch_add(removed as u64, Ordering::Relaxed);
369 }
370 removed
371 }
372
373 pub fn entry_count(&self) -> usize {
375 self.entries.lock().expect("audit log mutex poisoned").len()
376 }
377
378 pub fn trace_ids(&self) -> Vec<String> {
380 let guard = self.entries.lock().expect("audit log mutex poisoned");
381 let set: HashSet<&str> = guard.iter().map(|e| e.trace_id.as_str()).collect();
382 let mut ids: Vec<String> = set.into_iter().map(str::to_string).collect();
383 ids.sort();
384 ids
385 }
386}
387
388#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn make_started(session_id: &str, ts: u64) -> AuditEvent {
395 AuditEvent::QueryStarted {
396 goal: format!("parent(X, {})", session_id),
397 session_id: session_id.to_string(),
398 timestamp_ms: ts,
399 }
400 }
401
402 fn make_completed(session_id: &str, ts: u64) -> AuditEvent {
403 AuditEvent::QueryCompleted {
404 session_id: session_id.to_string(),
405 total_results: 3,
406 duration_ms: ts,
407 timestamp_ms: ts,
408 }
409 }
410
411 fn make_failed(session_id: &str, ts: u64) -> AuditEvent {
412 AuditEvent::QueryFailed {
413 session_id: session_id.to_string(),
414 reason: "timeout".to_string(),
415 timestamp_ms: ts,
416 }
417 }
418
419 fn make_peer_queried(peer_id: &str, ts: u64) -> AuditEvent {
420 AuditEvent::PeerQueried {
421 peer_id: peer_id.to_string(),
422 goal: "grandparent(X, Y)".to_string(),
423 timestamp_ms: ts,
424 }
425 }
426
427 fn make_rule_fired(rule_id: &str, ts: u64) -> AuditEvent {
428 AuditEvent::RuleFired {
429 rule_id: rule_id.to_string(),
430 bindings: {
431 let mut m = HashMap::new();
432 m.insert("X".to_string(), "alice".to_string());
433 m
434 },
435 timestamp_ms: ts,
436 }
437 }
438
439 fn make_result_received(peer_id: &str, ts: u64) -> AuditEvent {
440 AuditEvent::ResultReceived {
441 peer_id: peer_id.to_string(),
442 binding_count: 2,
443 is_final: true,
444 timestamp_ms: ts,
445 }
446 }
447
448 #[test]
451 fn test_append_and_retrieve_by_trace_id() {
452 let log = InferenceAuditLog::default();
453 log.append("trace-a", make_started("trace-a", 1000))
454 .expect("test: should succeed");
455 log.append("trace-b", make_started("trace-b", 2000))
456 .expect("test: should succeed");
457 log.append("trace-a", make_peer_queried("peer-1", 1001))
458 .expect("test: should succeed");
459
460 let entries = log.entries_for_trace("trace-a");
461 assert_eq!(entries.len(), 2, "trace-a should have 2 entries");
462 for e in &entries {
463 assert_eq!(e.trace_id, "trace-a");
464 }
465 }
466
467 #[test]
470 fn test_events_since_filters_by_sequence() {
471 let log = InferenceAuditLog::default();
472 for i in 0..5_u64 {
473 log.append("t", make_started("t", i * 100))
474 .expect("test: should succeed");
475 }
476 let since = log.events_since(2);
478 assert_eq!(since.len(), 2);
479 assert!(since.iter().all(|e| e.sequence > 2));
480 }
481
482 #[test]
485 fn test_trim_before_removes_correct_entries() {
486 let log = InferenceAuditLog::default();
487 for i in 0..10_u64 {
488 log.append("t", make_started("t", i))
489 .expect("test: should succeed");
490 }
491 let removed = log.trim_before(5);
493 assert_eq!(removed, 5, "should have removed 5 entries");
494
495 let remaining = log.entries_for_trace("t");
496 assert!(remaining.iter().all(|e| e.sequence >= 5));
497 }
498
499 #[test]
502 fn test_trace_ids_unique_sorted() {
503 let log = InferenceAuditLog::default();
504 log.append("zebra", make_started("zebra", 1))
505 .expect("test: should succeed");
506 log.append("alpha", make_started("alpha", 2))
507 .expect("test: should succeed");
508 log.append("zebra", make_peer_queried("p", 3))
509 .expect("test: should succeed");
510 log.append("mango", make_started("mango", 4))
511 .expect("test: should succeed");
512
513 let ids = log.trace_ids();
514 assert_eq!(ids, vec!["alpha", "mango", "zebra"]);
515 }
516
517 #[test]
520 fn test_entry_count_decreases_after_trim() {
521 let log = InferenceAuditLog::default();
522 for i in 0..20_u64 {
523 log.append("t", make_started("t", i))
524 .expect("test: should succeed");
525 }
526 assert_eq!(log.entry_count(), 20);
527 log.trim_before(10);
528 assert_eq!(log.entry_count(), 10);
529 }
530
531 #[test]
534 fn test_event_type_query_started() {
535 assert_eq!(make_started("s", 0).event_type(), "query_started");
536 }
537
538 #[test]
539 fn test_event_type_peer_queried() {
540 assert_eq!(make_peer_queried("p", 0).event_type(), "peer_queried");
541 }
542
543 #[test]
544 fn test_event_type_rule_fired() {
545 assert_eq!(make_rule_fired("r", 0).event_type(), "rule_fired");
546 }
547
548 #[test]
549 fn test_event_type_result_received() {
550 assert_eq!(make_result_received("p", 0).event_type(), "result_received");
551 }
552
553 #[test]
554 fn test_event_type_query_completed() {
555 assert_eq!(make_completed("s", 0).event_type(), "query_completed");
556 }
557
558 #[test]
559 fn test_event_type_query_failed() {
560 assert_eq!(make_failed("s", 0).event_type(), "query_failed");
561 }
562
563 #[test]
566 fn test_stats_queries_started_increments() {
567 let log = InferenceAuditLog::default();
568 log.append("t", make_started("t", 0))
569 .expect("test: should succeed");
570 log.append("t", make_started("t", 1))
571 .expect("test: should succeed");
572 let snap = log.stats.snapshot();
573 assert_eq!(snap.total_queries_started, 2);
574 }
575
576 #[test]
577 fn test_stats_queries_completed_increments() {
578 let log = InferenceAuditLog::default();
579 log.append("t", make_completed("t", 0))
580 .expect("test: should succeed");
581 let snap = log.stats.snapshot();
582 assert_eq!(snap.total_queries_completed, 1);
583 assert_eq!(snap.total_queries_started, 0);
584 }
585
586 #[test]
587 fn test_stats_queries_failed_increments() {
588 let log = InferenceAuditLog::default();
589 log.append("t", make_failed("t", 0))
590 .expect("test: should succeed");
591 log.append("t", make_failed("t", 1))
592 .expect("test: should succeed");
593 log.append("t", make_failed("t", 2))
594 .expect("test: should succeed");
595 let snap = log.stats.snapshot();
596 assert_eq!(snap.total_queries_failed, 3);
597 }
598
599 #[test]
600 fn test_stats_total_appended_and_trimmed() {
601 let log = InferenceAuditLog::default();
602 for i in 0..6_u64 {
603 log.append("t", make_started("t", i))
604 .expect("test: should succeed");
605 }
606 let removed = log.trim_before(3);
607 let snap = log.stats.snapshot();
608 assert_eq!(snap.total_appended, 6);
609 assert_eq!(snap.total_trimmed, removed as u64);
610 }
611
612 #[test]
615 fn test_capacity_exceeded_error() {
616 let log = InferenceAuditLog::new(3);
617 log.append("t", make_started("t", 0))
618 .expect("test: should succeed");
619 log.append("t", make_started("t", 1))
620 .expect("test: should succeed");
621 log.append("t", make_started("t", 2))
622 .expect("test: should succeed");
623
624 let err = log.append("t", make_started("t", 3)).unwrap_err();
625 match err {
626 AuditError::CapacityExceeded { current, max } => {
627 assert_eq!(current, 3);
628 assert_eq!(max, 3);
629 }
630 }
631 }
632
633 #[test]
636 fn test_multiple_trace_ids_coexist() {
637 let log = InferenceAuditLog::default();
638 for i in 0..5_u64 {
639 log.append("alpha", make_started("alpha", i))
640 .expect("test: should succeed");
641 log.append("beta", make_peer_queried("peer", i))
642 .expect("test: should succeed");
643 }
644 assert_eq!(log.entries_for_trace("alpha").len(), 5);
645 assert_eq!(log.entries_for_trace("beta").len(), 5);
646 assert_eq!(log.entry_count(), 10);
647 }
648
649 #[test]
652 fn test_sequence_monotonically_increasing() {
653 let log = InferenceAuditLog::default();
654 let mut seqs = Vec::new();
655 for i in 0..10_u64 {
656 let seq = log
657 .append("t", make_started("t", i))
658 .expect("test: should succeed");
659 seqs.push(seq);
660 }
661 for w in seqs.windows(2) {
663 assert!(
664 w[1] > w[0],
665 "sequences must be strictly increasing: {} <= {}",
666 w[1],
667 w[0]
668 );
669 }
670 }
671
672 #[test]
675 fn test_events_since_zero_excludes_first() {
676 let log = InferenceAuditLog::default();
677 log.append("t", make_started("t", 0))
678 .expect("test: should succeed");
679 log.append("t", make_started("t", 1))
680 .expect("test: should succeed");
681 log.append("t", make_started("t", 2))
682 .expect("test: should succeed");
683 let since = log.events_since(0);
685 assert_eq!(since.len(), 2);
686 }
687
688 #[test]
691 fn test_trim_before_zero_removes_nothing() {
692 let log = InferenceAuditLog::default();
693 log.append("t", make_started("t", 0))
694 .expect("test: should succeed");
695 log.append("t", make_started("t", 1))
696 .expect("test: should succeed");
697 let removed = log.trim_before(0);
698 assert_eq!(removed, 0);
699 assert_eq!(log.entry_count(), 2);
700 }
701
702 #[test]
705 fn test_timestamp_ms_accessor() {
706 assert_eq!(make_started("s", 42).timestamp_ms(), 42);
707 assert_eq!(make_peer_queried("p", 99).timestamp_ms(), 99);
708 assert_eq!(make_rule_fired("r", 7).timestamp_ms(), 7);
709 assert_eq!(make_result_received("p", 500).timestamp_ms(), 500);
710 assert_eq!(make_completed("s", 1234).timestamp_ms(), 1234);
711 assert_eq!(make_failed("s", 9999).timestamp_ms(), 9999);
712 }
713
714 #[test]
717 fn test_trim_before_high_seq_removes_all() {
718 let log = InferenceAuditLog::default();
719 for i in 0..5_u64 {
720 log.append("t", make_started("t", i))
721 .expect("test: should succeed");
722 }
723 let removed = log.trim_before(100);
725 assert_eq!(removed, 5);
726 assert_eq!(log.entry_count(), 0);
727 }
728
729 #[test]
732 fn test_trace_ids_empty_log() {
733 let log = InferenceAuditLog::default();
734 assert!(log.trace_ids().is_empty());
735 }
736
737 #[test]
740 fn test_append_succeeds_after_trim_frees_capacity() {
741 let log = InferenceAuditLog::new(3);
742 log.append("t", make_started("t", 0))
743 .expect("test: should succeed");
744 log.append("t", make_started("t", 1))
745 .expect("test: should succeed");
746 log.append("t", make_started("t", 2))
747 .expect("test: should succeed");
748 log.trim_before(1);
750 log.append("t", make_started("t", 3))
752 .expect("test: should succeed");
753 assert_eq!(log.entry_count(), 3);
754 }
755
756 #[test]
759 fn test_rule_fired_bindings_preserved() {
760 let log = InferenceAuditLog::default();
761 let mut bindings = HashMap::new();
762 bindings.insert("X".to_string(), "alice".to_string());
763 bindings.insert("Y".to_string(), "bob".to_string());
764 log.append(
765 "t",
766 AuditEvent::RuleFired {
767 rule_id: "rule:parent/2".to_string(),
768 bindings: bindings.clone(),
769 timestamp_ms: 55,
770 },
771 )
772 .expect("test: should succeed");
773
774 let entries = log.entries_for_trace("t");
775 if let AuditEvent::RuleFired {
776 rule_id,
777 bindings: b,
778 ..
779 } = &entries[0].event
780 {
781 assert_eq!(rule_id, "rule:parent/2");
782 assert_eq!(b["X"], "alice");
783 assert_eq!(b["Y"], "bob");
784 } else {
785 panic!("expected RuleFired event");
786 }
787 }
788}