Skip to main content

laminar_ai/
call_log.rs

1//! Bounded in-memory log of inference calls, surfaced as `laminar.ai_calls`.
2//!
3//! One record per batch call — local and remote alike. Remote calls carry
4//! tokens and cost; local calls report [`Usage::ZERO`](crate::provider::Usage::ZERO).
5//! The log is written from the Ring 1 inference worker and read by queries
6//! against `laminar.ai_calls`, so it is behind a lock; it is never touched on
7//! Ring 0. The buffer is bounded — the oldest record is dropped once full — and
8//! a monotonic counter records how many calls were ever logged.
9
10use std::collections::VecDeque;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use parking_lot::Mutex;
14
15use crate::provider::Usage;
16use crate::registry::{BackendKind, Task};
17
18/// Outcome of a batch call.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CallOutcome {
21    /// The batch completed.
22    Success,
23    /// The batch failed; carries the surfaced error message.
24    Failure(String),
25}
26
27impl CallOutcome {
28    /// Short status string for the `laminar.ai_calls` view (`ok` / `error`).
29    #[must_use]
30    pub fn status(&self) -> &'static str {
31        match self {
32            CallOutcome::Success => "ok",
33            CallOutcome::Failure(_) => "error",
34        }
35    }
36}
37
38/// One logged inference call.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct AiCallRecord {
41    /// Wall-clock time the call completed (epoch milliseconds).
42    pub timestamp_ms: i64,
43    /// Registry model name (e.g. `finbert`, `haiku`).
44    pub model: String,
45    /// Backend-kind identity reported by the provider (e.g. `anthropic`).
46    pub provider: &'static str,
47    /// The task performed.
48    pub task: Task,
49    /// The backend kind.
50    pub kind: BackendKind,
51    /// Number of rows in the batch.
52    pub batch_size: u32,
53    /// Token/cost accounting (zero for local).
54    pub usage: Usage,
55    /// End-to-end latency of the batch call.
56    pub latency_ms: u64,
57    /// Outcome.
58    pub outcome: CallOutcome,
59}
60
61/// Bounded ring buffer of [`AiCallRecord`]s.
62#[derive(Debug)]
63pub struct AiCallLog {
64    records: Mutex<VecDeque<AiCallRecord>>,
65    capacity: usize,
66    recorded: AtomicU64,
67}
68
69impl AiCallLog {
70    /// Create a log retaining at most `capacity` of the most recent records.
71    #[must_use]
72    pub fn new(capacity: usize) -> Self {
73        Self {
74            records: Mutex::new(VecDeque::with_capacity(capacity.min(1024))),
75            capacity: capacity.max(1),
76            recorded: AtomicU64::new(0),
77        }
78    }
79
80    /// Create a log with a default capacity of 10,000 records.
81    #[must_use]
82    pub fn with_defaults() -> Self {
83        Self::new(10_000)
84    }
85
86    /// Append a record, evicting the oldest if at capacity.
87    pub fn record(&self, record: AiCallRecord) {
88        let mut records = self.records.lock();
89        if records.len() >= self.capacity {
90            records.pop_front();
91        }
92        records.push_back(record);
93        self.recorded.fetch_add(1, Ordering::Relaxed);
94    }
95
96    /// Snapshot the retained records, oldest first. Backs `laminar.ai_calls`.
97    #[must_use]
98    pub fn snapshot(&self) -> Vec<AiCallRecord> {
99        self.records.lock().iter().cloned().collect()
100    }
101
102    /// Number of records currently retained.
103    #[must_use]
104    pub fn len(&self) -> usize {
105        self.records.lock().len()
106    }
107
108    /// Whether the log currently holds no records.
109    #[must_use]
110    pub fn is_empty(&self) -> bool {
111        self.records.lock().is_empty()
112    }
113
114    /// Total calls ever logged, including those evicted from the buffer.
115    #[must_use]
116    pub fn total_recorded(&self) -> u64 {
117        self.recorded.load(Ordering::Relaxed)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn record(model: &str, ts: i64, outcome: CallOutcome) -> AiCallRecord {
126        AiCallRecord {
127            timestamp_ms: ts,
128            model: model.to_string(),
129            provider: "anthropic",
130            task: Task::Classify,
131            kind: BackendKind::Remote,
132            batch_size: 4,
133            usage: Usage {
134                input_tokens: 10,
135                output_tokens: 2,
136                cost_micros: 5,
137            },
138            latency_ms: 42,
139            outcome,
140        }
141    }
142
143    #[test]
144    fn records_and_snapshots_in_order() {
145        let log = AiCallLog::with_defaults();
146        assert!(log.is_empty());
147        log.record(record("haiku", 1, CallOutcome::Success));
148        log.record(record("haiku", 2, CallOutcome::Failure("timeout".into())));
149        let snap = log.snapshot();
150        assert_eq!(snap.len(), 2);
151        assert_eq!(snap[0].timestamp_ms, 1);
152        assert_eq!(snap[1].outcome, CallOutcome::Failure("timeout".into()));
153        assert_eq!(log.total_recorded(), 2);
154    }
155
156    #[test]
157    fn evicts_oldest_when_full() {
158        let log = AiCallLog::new(2);
159        log.record(record("m", 1, CallOutcome::Success));
160        log.record(record("m", 2, CallOutcome::Success));
161        log.record(record("m", 3, CallOutcome::Success));
162        let snap = log.snapshot();
163        assert_eq!(snap.len(), 2);
164        assert_eq!(snap[0].timestamp_ms, 2, "oldest dropped");
165        assert_eq!(snap[1].timestamp_ms, 3);
166        assert_eq!(log.total_recorded(), 3, "monotonic count survives eviction");
167    }
168}