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` or
6//! `invalidate`. Both the MCP tool layer and the LLM proxy emit it, and
7//! the reporting side reads whichever file it is pointed at, so the
8//! shape is one rule and lives here rather than once per producer.
9//!
10//! Recording is best-effort throughout: a statistics write that failed
11//! must never turn a served request into an error, so every failure
12//! path returns quietly. The file is opened per call rather than held,
13//! which keeps the recorder cheap to clone and share across the
14//! proxy's per-request threads; a cached call is already in the
15//! millisecond regime or slower, so the open is not on any hot path.
16
17use std::path::PathBuf;
18
19pub const OUTCOME_HIT: &str = "hit";
20pub const OUTCOME_MISS: &str = "miss";
21pub const OUTCOME_UNCACHED: &str = "uncached";
22pub const OUTCOME_INVALIDATE: &str = "invalidate";
23
24#[derive(Clone, Debug, Default)]
25pub struct StatsRecorder {
26    path: Option<PathBuf>,
27}
28
29impl StatsRecorder {
30    pub fn disabled() -> Self {
31        Self { path: None }
32    }
33
34    pub fn new(path: Option<PathBuf>) -> Self {
35        Self { path }
36    }
37
38    /// Reads the destination from `var`, treating unset or empty as disabled.
39    pub fn from_env(var: &str) -> Self {
40        let path = std::env::var_os(var)
41            .map(PathBuf::from)
42            .filter(|p| !p.as_os_str().is_empty());
43        Self { path }
44    }
45
46    pub fn is_enabled(&self) -> bool {
47        self.path.is_some()
48    }
49
50    pub fn record(&self, tool: &str, outcome: &str, duration_us: u128, args_summary: &str) {
51        self.record_with(tool, outcome, duration_us, args_summary, &[]);
52    }
53
54    /// The same record with producer-specific fields alongside it. The reader
55    /// ignores names it does not know, so a producer can carry what only it can
56    /// measure (the proxy's completion-token count, say) without every producer
57    /// having to know about it.
58    pub fn record_with(
59        &self,
60        tool: &str,
61        outcome: &str,
62        duration_us: u128,
63        args_summary: &str,
64        extra: &[(&str, serde_json::Value)],
65    ) {
66        let path = match &self.path {
67            Some(p) => p,
68            None => return,
69        };
70        if let Some(parent) = path.parent() {
71            let _ = std::fs::create_dir_all(parent);
72        }
73        let ts = std::time::SystemTime::now()
74            .duration_since(std::time::UNIX_EPOCH)
75            .map(|d| d.as_micros())
76            .unwrap_or(0);
77        let mut line = serde_json::json!({
78            "ts_us": ts,
79            "tool": tool,
80            "outcome": outcome,
81            "duration_us": duration_us,
82            "args_summary": args_summary,
83        });
84        if let Some(obj) = line.as_object_mut() {
85            for (key, value) in extra {
86                obj.insert((*key).to_string(), value.clone());
87            }
88        }
89        let mut serialized = match serde_json::to_string(&line) {
90            Ok(s) => s,
91            Err(_) => return,
92        };
93        serialized.push('\n');
94        // O_APPEND plus a single write syscall, because the proxy dispatches
95        // each request on its own thread and two concurrent records must not
96        // interleave into one corrupt line.
97        let _ = std::fs::OpenOptions::new()
98            .create(true)
99            .append(true)
100            .open(path)
101            .and_then(|mut f| std::io::Write::write_all(&mut f, serialized.as_bytes()));
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::sync::Arc;
109
110    fn lines(path: &std::path::Path) -> Vec<serde_json::Value> {
111        std::fs::read_to_string(path)
112            .unwrap()
113            .lines()
114            .map(|l| serde_json::from_str(l).unwrap())
115            .collect()
116    }
117
118    #[test]
119    fn writes_one_json_line_per_call() {
120        let dir = tempfile::tempdir().unwrap();
121        let path = dir.path().join("stats.jsonl");
122        let recorder = StatsRecorder::new(Some(path.clone()));
123
124        recorder.record("llm_call", OUTCOME_MISS, 1_500_000, "qwen3.5-4b");
125        recorder.record("llm_call", OUTCOME_HIT, 900, "qwen3.5-4b");
126
127        let records = lines(&path);
128        assert_eq!(records.len(), 2);
129        assert_eq!(records[0]["tool"], "llm_call");
130        assert_eq!(records[0]["outcome"], "miss");
131        assert_eq!(records[0]["duration_us"], 1_500_000u64);
132        assert_eq!(records[0]["args_summary"], "qwen3.5-4b");
133        assert_eq!(records[1]["outcome"], "hit");
134        assert!(records[0]["ts_us"].as_u64().unwrap() > 0);
135    }
136
137    #[test]
138    fn carries_a_producer_specific_field_alongside_the_shared_shape() {
139        let dir = tempfile::tempdir().unwrap();
140        let path = dir.path().join("stats.jsonl");
141
142        StatsRecorder::new(Some(path.clone())).record_with(
143            "llm_call",
144            OUTCOME_MISS,
145            1_000,
146            "qwen3.5-4b abc123",
147            &[("completion_tokens", serde_json::json!(146))],
148        );
149
150        let records = lines(&path);
151        assert_eq!(records[0]["completion_tokens"], 146);
152        assert_eq!(records[0]["tool"], "llm_call");
153        assert_eq!(records[0]["outcome"], "miss");
154    }
155
156    #[test]
157    fn creates_the_parent_directory() {
158        let dir = tempfile::tempdir().unwrap();
159        let path = dir.path().join("nested/deeper/stats.jsonl");
160        StatsRecorder::new(Some(path.clone())).record("llm_call", OUTCOME_HIT, 1, "");
161        assert_eq!(lines(&path).len(), 1);
162    }
163
164    #[test]
165    fn a_disabled_recorder_writes_nothing_and_does_not_fail() {
166        let recorder = StatsRecorder::disabled();
167        assert!(!recorder.is_enabled());
168        recorder.record("llm_call", OUTCOME_HIT, 1, "");
169    }
170
171    #[test]
172    fn an_empty_env_var_disables_rather_than_writing_to_the_working_directory() {
173        // An unset variable often reaches a process as an empty string, and
174        // treating that as a path would drop a stats file wherever the process
175        // happened to start.
176        let var = "VERDANT_STATS_PATH_EMPTY_CASE_TEST";
177        std::env::set_var(var, "");
178        assert!(!StatsRecorder::from_env(var).is_enabled());
179        std::env::remove_var(var);
180        assert!(!StatsRecorder::from_env(var).is_enabled());
181    }
182
183    #[test]
184    fn concurrent_records_do_not_interleave() {
185        let dir = tempfile::tempdir().unwrap();
186        let path = dir.path().join("stats.jsonl");
187        let recorder = Arc::new(StatsRecorder::new(Some(path.clone())));
188
189        let handles: Vec<_> = (0..8)
190            .map(|i| {
191                let recorder = recorder.clone();
192                std::thread::spawn(move || {
193                    for _ in 0..25 {
194                        recorder.record("llm_call", OUTCOME_HIT, i, "model-with-a-longer-name");
195                    }
196                })
197            })
198            .collect();
199        for h in handles {
200            h.join().unwrap();
201        }
202
203        let records = lines(&path);
204        assert_eq!(records.len(), 200);
205        assert!(records.iter().all(|r| r["tool"] == "llm_call"));
206    }
207}