Skip to main content

llm_request_log/
lib.rs

1/*!
2llm-request-log: structured audit log of LLM API requests.
3
4Records each request with a generated ID, model, token counts, latency,
5and optional metadata. Useful for cost attribution, debugging, and replay.
6
7```rust
8use llm_request_log::RequestLog;
9
10let mut log = RequestLog::new();
11log.record("req-1", "claude-opus-4-7", 200, 150, 1200);
12assert_eq!(log.len(), 1);
13```
14*/
15
16use serde_json::Value;
17
18/// A single logged LLM request.
19#[derive(Debug, Clone)]
20pub struct LogEntry {
21    pub request_id: String,
22    pub model: String,
23    pub input_tokens: u64,
24    pub output_tokens: u64,
25    pub latency_ms: u64,
26    pub metadata: Option<Value>,
27    pub error: Option<String>,
28}
29
30impl LogEntry {
31    pub fn total_tokens(&self) -> u64 {
32        self.input_tokens + self.output_tokens
33    }
34
35    pub fn is_error(&self) -> bool {
36        self.error.is_some()
37    }
38}
39
40/// Append-only log of LLM API requests.
41#[derive(Debug, Default)]
42pub struct RequestLog {
43    entries: Vec<LogEntry>,
44}
45
46impl RequestLog {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Record a successful request.
52    pub fn record(
53        &mut self,
54        request_id: impl Into<String>,
55        model: impl Into<String>,
56        input_tokens: u64,
57        output_tokens: u64,
58        latency_ms: u64,
59    ) {
60        self.entries.push(LogEntry {
61            request_id: request_id.into(),
62            model: model.into(),
63            input_tokens,
64            output_tokens,
65            latency_ms,
66            metadata: None,
67            error: None,
68        });
69    }
70
71    /// Record a request with metadata.
72    pub fn record_with_meta(
73        &mut self,
74        request_id: impl Into<String>,
75        model: impl Into<String>,
76        input_tokens: u64,
77        output_tokens: u64,
78        latency_ms: u64,
79        metadata: Value,
80    ) {
81        self.entries.push(LogEntry {
82            request_id: request_id.into(),
83            model: model.into(),
84            input_tokens,
85            output_tokens,
86            latency_ms,
87            metadata: Some(metadata),
88            error: None,
89        });
90    }
91
92    /// Record a failed request.
93    pub fn record_error(
94        &mut self,
95        request_id: impl Into<String>,
96        model: impl Into<String>,
97        error: impl Into<String>,
98        latency_ms: u64,
99    ) {
100        self.entries.push(LogEntry {
101            request_id: request_id.into(),
102            model: model.into(),
103            input_tokens: 0,
104            output_tokens: 0,
105            latency_ms,
106            metadata: None,
107            error: Some(error.into()),
108        });
109    }
110
111    pub fn len(&self) -> usize { self.entries.len() }
112    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
113    pub fn entries(&self) -> &[LogEntry] { &self.entries }
114
115    pub fn get(&self, request_id: &str) -> Option<&LogEntry> {
116        self.entries.iter().find(|e| e.request_id == request_id)
117    }
118
119    pub fn by_model(&self, model: &str) -> Vec<&LogEntry> {
120        self.entries.iter().filter(|e| e.model == model).collect()
121    }
122
123    pub fn errors(&self) -> Vec<&LogEntry> {
124        self.entries.iter().filter(|e| e.is_error()).collect()
125    }
126
127    pub fn total_input_tokens(&self) -> u64 {
128        self.entries.iter().map(|e| e.input_tokens).sum()
129    }
130
131    pub fn total_output_tokens(&self) -> u64 {
132        self.entries.iter().map(|e| e.output_tokens).sum()
133    }
134
135    pub fn avg_latency_ms(&self) -> f64 {
136        if self.entries.is_empty() { return 0.0; }
137        let sum: u64 = self.entries.iter().map(|e| e.latency_ms).sum();
138        sum as f64 / self.entries.len() as f64
139    }
140
141    pub fn slowest(&self) -> Option<&LogEntry> {
142        self.entries.iter().max_by_key(|e| e.latency_ms)
143    }
144
145    pub fn clear(&mut self) { self.entries.clear(); }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152
153    #[test]
154    fn empty_log() {
155        let log = RequestLog::new();
156        assert!(log.is_empty());
157        assert_eq!(log.len(), 0);
158    }
159
160    #[test]
161    fn record_single_entry() {
162        let mut log = RequestLog::new();
163        log.record("r1", "claude-opus-4-7", 100, 50, 500);
164        assert_eq!(log.len(), 1);
165    }
166
167    #[test]
168    fn get_by_id() {
169        let mut log = RequestLog::new();
170        log.record("abc", "gpt-5.4", 200, 100, 300);
171        let e = log.get("abc").unwrap();
172        assert_eq!(e.model, "gpt-5.4");
173    }
174
175    #[test]
176    fn get_missing_returns_none() {
177        let log = RequestLog::new();
178        assert!(log.get("nope").is_none());
179    }
180
181    #[test]
182    fn total_tokens() {
183        let mut e = LogEntry {
184            request_id: "x".into(), model: "m".into(),
185            input_tokens: 100, output_tokens: 50, latency_ms: 0,
186            metadata: None, error: None,
187        };
188        assert_eq!(e.total_tokens(), 150);
189    }
190
191    #[test]
192    fn record_error_entry() {
193        let mut log = RequestLog::new();
194        log.record_error("e1", "claude-opus-4-7", "rate limit", 50);
195        assert_eq!(log.errors().len(), 1);
196        assert!(log.get("e1").unwrap().is_error());
197    }
198
199    #[test]
200    fn by_model_filter() {
201        let mut log = RequestLog::new();
202        log.record("r1", "model-a", 10, 5, 100);
203        log.record("r2", "model-b", 10, 5, 100);
204        log.record("r3", "model-a", 10, 5, 100);
205        assert_eq!(log.by_model("model-a").len(), 2);
206        assert_eq!(log.by_model("model-b").len(), 1);
207    }
208
209    #[test]
210    fn total_input_tokens() {
211        let mut log = RequestLog::new();
212        log.record("r1", "m", 100, 0, 0);
213        log.record("r2", "m", 200, 0, 0);
214        assert_eq!(log.total_input_tokens(), 300);
215    }
216
217    #[test]
218    fn total_output_tokens() {
219        let mut log = RequestLog::new();
220        log.record("r1", "m", 0, 50, 0);
221        log.record("r2", "m", 0, 75, 0);
222        assert_eq!(log.total_output_tokens(), 125);
223    }
224
225    #[test]
226    fn avg_latency() {
227        let mut log = RequestLog::new();
228        log.record("r1", "m", 0, 0, 100);
229        log.record("r2", "m", 0, 0, 200);
230        assert!((log.avg_latency_ms() - 150.0).abs() < 0.01);
231    }
232
233    #[test]
234    fn avg_latency_empty() {
235        assert_eq!(RequestLog::new().avg_latency_ms(), 0.0);
236    }
237
238    #[test]
239    fn slowest_entry() {
240        let mut log = RequestLog::new();
241        log.record("r1", "m", 0, 0, 100);
242        log.record("r2", "m", 0, 0, 999);
243        assert_eq!(log.slowest().unwrap().request_id, "r2");
244    }
245
246    #[test]
247    fn record_with_metadata() {
248        let mut log = RequestLog::new();
249        log.record_with_meta("r1", "m", 10, 5, 100, json!({"tag": "test"}));
250        let e = log.get("r1").unwrap();
251        assert!(e.metadata.is_some());
252    }
253
254    #[test]
255    fn clear_resets_log() {
256        let mut log = RequestLog::new();
257        log.record("r1", "m", 10, 5, 100);
258        log.clear();
259        assert!(log.is_empty());
260    }
261}