Skip to main content

verdant_runtime/
stats.rs

1//! Per-call JSONL statistics.
2//!
3//! One line per cached call, in the shape `verdant stats` and
4//! `verdant audit` already parse: `{ts_us, tool, outcome, duration_us,
5//! args_summary}`, with `outcome` one of `hit`, `miss`, `uncached`,
6//! `invalidate` or `declined`, plus a `reason` that is null on every line
7//! except an `uncached` one, where it names why the call bypassed the cache,
8//! and a `declined` one, where it names why `bash_match` did not match. A
9//! `declined` line is a query that ran nothing, so the reports keep it out of
10//! every hit and miss rate. Both the
11//! MCP tool layer and the LLM proxy emit it, and
12//! the reporting side reads whichever file it is pointed at, so the
13//! shape is one rule and lives here rather than once per producer.
14//!
15//! Recording is best-effort throughout: a statistics write that failed
16//! must never turn a served request into an error, so every failure
17//! path returns quietly. The file is opened per call rather than held,
18//! which keeps the recorder cheap to clone and share across the
19//! proxy's per-request threads; a cached call is already in the
20//! millisecond regime or slower, so the open is not on any hot path.
21
22use std::path::PathBuf;
23
24pub const OUTCOME_HIT: &str = "hit";
25pub const OUTCOME_MISS: &str = "miss";
26pub const OUTCOME_UNCACHED: &str = "uncached";
27pub const OUTCOME_INVALIDATE: &str = "invalidate";
28pub const OUTCOME_DECLINED: &str = "declined";
29
30/// Why a call ran uncached. The set is open (a reader treats an unknown name
31/// as opaque text), but producers use these so a count by reason is stable.
32pub mod reason {
33    pub const NO_RULE: &str = "no_rule";
34    pub const NOT_ALLOWLISTED: &str = "not_allowlisted";
35    pub const OUT_OF_ROOT: &str = "out_of_root";
36    pub const NO_EXEC_CACHE: &str = "no_exec_cache";
37    pub const SANDBOX_UNAVAILABLE: &str = "sandbox_unavailable";
38    pub const SANDBOX_ERROR: &str = "sandbox_error";
39    pub const NETWORK: &str = "network";
40    pub const SYSTEM_SOCKET: &str = "system_socket";
41    pub const FAILED_RUN: &str = "failed_run";
42    pub const TIMEOUT: &str = "timeout";
43    pub const OVERSIZED: &str = "oversized";
44    pub const NO_INPUTS: &str = "no_inputs";
45    pub const UNHASHABLE_INPUT: &str = "unhashable_input";
46    pub const NOT_CACHEABLE: &str = "not_cacheable";
47    pub const ERROR: &str = "error";
48}
49
50#[derive(Clone, Debug, Default)]
51pub struct StatsRecorder {
52    path: Option<PathBuf>,
53}
54
55impl StatsRecorder {
56    pub fn disabled() -> Self {
57        Self { path: None }
58    }
59
60    pub fn new(path: Option<PathBuf>) -> Self {
61        Self { path }
62    }
63
64    /// Reads the destination from `var`, treating unset or empty as disabled.
65    pub fn from_env(var: &str) -> Self {
66        let path = std::env::var_os(var)
67            .map(PathBuf::from)
68            .filter(|p| !p.as_os_str().is_empty());
69        Self { path }
70    }
71
72    pub fn is_enabled(&self) -> bool {
73        self.path.is_some()
74    }
75
76    pub fn record(&self, tool: &str, outcome: &str, duration_us: u128, args_summary: &str) {
77        self.record_reasoned(tool, outcome, duration_us, args_summary, None);
78    }
79
80    /// `reason` is only meaningful for an `uncached` or `declined` outcome; every other
81    /// caller passes `None` and the line carries `"reason": null`.
82    pub fn record_reasoned(
83        &self,
84        tool: &str,
85        outcome: &str,
86        duration_us: u128,
87        args_summary: &str,
88        reason: Option<&str>,
89    ) {
90        self.write_line(tool, outcome, duration_us, args_summary, reason, &[]);
91    }
92
93    /// The same record with producer-specific fields alongside it. The reader
94    /// ignores names it does not know, so a producer can carry what only it can
95    /// measure (the proxy's completion-token count, say) without every producer
96    /// having to know about it.
97    pub fn record_with(
98        &self,
99        tool: &str,
100        outcome: &str,
101        duration_us: u128,
102        args_summary: &str,
103        extra: &[(&str, serde_json::Value)],
104    ) {
105        self.write_line(tool, outcome, duration_us, args_summary, None, extra);
106    }
107
108    fn write_line(
109        &self,
110        tool: &str,
111        outcome: &str,
112        duration_us: u128,
113        args_summary: &str,
114        reason: Option<&str>,
115        extra: &[(&str, serde_json::Value)],
116    ) {
117        let path = match &self.path {
118            Some(p) => p,
119            None => return,
120        };
121        if let Some(parent) = path.parent() {
122            let _ = std::fs::create_dir_all(parent);
123        }
124        let ts = std::time::SystemTime::now()
125            .duration_since(std::time::UNIX_EPOCH)
126            .map(|d| d.as_micros())
127            .unwrap_or(0);
128        let mut line = serde_json::json!({
129            "ts_us": ts,
130            "tool": tool,
131            "outcome": outcome,
132            "duration_us": duration_us,
133            "args_summary": args_summary,
134            "reason": reason,
135        });
136        if let Some(obj) = line.as_object_mut() {
137            for (key, value) in extra {
138                obj.insert((*key).to_string(), value.clone());
139            }
140        }
141        let mut serialized = match serde_json::to_string(&line) {
142            Ok(s) => s,
143            Err(_) => return,
144        };
145        serialized.push('\n');
146        // O_APPEND plus a single write syscall, because the proxy dispatches
147        // each request on its own thread and two concurrent records must not
148        // interleave into one corrupt line.
149        let _ = std::fs::OpenOptions::new()
150            .create(true)
151            .append(true)
152            .open(path)
153            .and_then(|mut f| std::io::Write::write_all(&mut f, serialized.as_bytes()));
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::sync::Arc;
161
162    fn lines(path: &std::path::Path) -> Vec<serde_json::Value> {
163        std::fs::read_to_string(path)
164            .unwrap()
165            .lines()
166            .map(|l| serde_json::from_str(l).unwrap())
167            .collect()
168    }
169
170    #[test]
171    fn writes_one_json_line_per_call() {
172        let dir = tempfile::tempdir().unwrap();
173        let path = dir.path().join("stats.jsonl");
174        let recorder = StatsRecorder::new(Some(path.clone()));
175
176        recorder.record("llm_call", OUTCOME_MISS, 1_500_000, "qwen3.5-4b");
177        recorder.record("llm_call", OUTCOME_HIT, 900, "qwen3.5-4b");
178
179        let records = lines(&path);
180        assert_eq!(records.len(), 2);
181        assert_eq!(records[0]["tool"], "llm_call");
182        assert_eq!(records[0]["outcome"], "miss");
183        assert_eq!(records[0]["duration_us"], 1_500_000u64);
184        assert_eq!(records[0]["args_summary"], "qwen3.5-4b");
185        assert_eq!(records[1]["outcome"], "hit");
186        assert!(records[0]["ts_us"].as_u64().unwrap() > 0);
187    }
188
189    #[test]
190    fn every_line_carries_a_reason_that_is_null_unless_given() {
191        let dir = tempfile::tempdir().unwrap();
192        let path = dir.path().join("stats.jsonl");
193        let recorder = StatsRecorder::new(Some(path.clone()));
194
195        recorder.record("read", OUTCOME_HIT, 1, "a.txt");
196        recorder.record_with("llm_call", OUTCOME_MISS, 1, "m", &[]);
197        recorder.record_reasoned("bash", OUTCOME_UNCACHED, 1, "ls", Some(reason::NO_RULE));
198
199        let records = lines(&path);
200        assert!(records[0]["reason"].is_null());
201        assert!(records[0].as_object().unwrap().contains_key("reason"));
202        assert!(records[1]["reason"].is_null());
203        assert_eq!(records[2]["reason"], "no_rule");
204    }
205
206    #[test]
207    fn carries_a_producer_specific_field_alongside_the_shared_shape() {
208        let dir = tempfile::tempdir().unwrap();
209        let path = dir.path().join("stats.jsonl");
210
211        StatsRecorder::new(Some(path.clone())).record_with(
212            "llm_call",
213            OUTCOME_MISS,
214            1_000,
215            "qwen3.5-4b abc123",
216            &[("completion_tokens", serde_json::json!(146))],
217        );
218
219        let records = lines(&path);
220        assert_eq!(records[0]["completion_tokens"], 146);
221        assert_eq!(records[0]["tool"], "llm_call");
222        assert_eq!(records[0]["outcome"], "miss");
223    }
224
225    #[test]
226    fn creates_the_parent_directory() {
227        let dir = tempfile::tempdir().unwrap();
228        let path = dir.path().join("nested/deeper/stats.jsonl");
229        StatsRecorder::new(Some(path.clone())).record("llm_call", OUTCOME_HIT, 1, "");
230        assert_eq!(lines(&path).len(), 1);
231    }
232
233    #[test]
234    fn a_disabled_recorder_writes_nothing_and_does_not_fail() {
235        let recorder = StatsRecorder::disabled();
236        assert!(!recorder.is_enabled());
237        recorder.record("llm_call", OUTCOME_HIT, 1, "");
238    }
239
240    #[test]
241    fn an_empty_env_var_disables_rather_than_writing_to_the_working_directory() {
242        // An unset variable often reaches a process as an empty string, and
243        // treating that as a path would drop a stats file wherever the process
244        // happened to start.
245        let var = "VERDANT_STATS_PATH_EMPTY_CASE_TEST";
246        std::env::set_var(var, "");
247        assert!(!StatsRecorder::from_env(var).is_enabled());
248        std::env::remove_var(var);
249        assert!(!StatsRecorder::from_env(var).is_enabled());
250    }
251
252    #[test]
253    fn concurrent_records_do_not_interleave() {
254        let dir = tempfile::tempdir().unwrap();
255        let path = dir.path().join("stats.jsonl");
256        let recorder = Arc::new(StatsRecorder::new(Some(path.clone())));
257
258        let handles: Vec<_> = (0..8)
259            .map(|i| {
260                let recorder = recorder.clone();
261                std::thread::spawn(move || {
262                    for _ in 0..25 {
263                        recorder.record("llm_call", OUTCOME_HIT, i, "model-with-a-longer-name");
264                    }
265                })
266            })
267            .collect();
268        for h in handles {
269            h.join().unwrap();
270        }
271
272        let records = lines(&path);
273        assert_eq!(records.len(), 200);
274        assert!(records.iter().all(|r| r["tool"] == "llm_call"));
275    }
276}