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