1use 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 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 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 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 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}