1use std::path::PathBuf;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::OnceLock;
29
30pub const OUTCOME_HIT: &str = "hit";
31pub const OUTCOME_MISS: &str = "miss";
32pub const OUTCOME_UNCACHED: &str = "uncached";
33pub const OUTCOME_INVALIDATE: &str = "invalidate";
34pub const OUTCOME_DECLINED: &str = "declined";
35
36pub mod reason {
39 pub const NO_RULE: &str = "no_rule";
40 pub const NOT_ALLOWLISTED: &str = "not_allowlisted";
41 pub const OUT_OF_ROOT: &str = "out_of_root";
42 pub const NO_EXEC_CACHE: &str = "no_exec_cache";
43 pub const SANDBOX_UNAVAILABLE: &str = "sandbox_unavailable";
44 pub const SANDBOX_ERROR: &str = "sandbox_error";
45 pub const NETWORK: &str = "network";
46 pub const SYSTEM_SOCKET: &str = "system_socket";
47 pub const FAILED_RUN: &str = "failed_run";
48 pub const TIMEOUT: &str = "timeout";
49 pub const PATH_CHANGED: &str = "path_changed";
50 pub const OVERSIZED: &str = "oversized";
51 pub const NO_INPUTS: &str = "no_inputs";
52 pub const UNHASHABLE_INPUT: &str = "unhashable_input";
53 pub const INPUT_CHANGED: &str = "input_changed";
54 pub const OUTSIDE_WRITE: &str = "outside_write";
55 pub const NOT_CACHEABLE: &str = "not_cacheable";
56 pub const ERROR: &str = "error";
57}
58
59#[derive(Clone, Debug, Default)]
60pub struct StatsRecorder {
61 path: Option<PathBuf>,
62}
63
64impl StatsRecorder {
65 pub fn disabled() -> Self {
66 Self { path: None }
67 }
68
69 pub fn new(path: Option<PathBuf>) -> Self {
70 Self { path }
71 }
72
73 pub fn from_env(var: &str) -> Self {
75 let path = std::env::var_os(var)
76 .map(PathBuf::from)
77 .filter(|p| !p.as_os_str().is_empty());
78 Self { path }
79 }
80
81 pub fn is_enabled(&self) -> bool {
82 self.path.is_some()
83 }
84
85 pub fn write(&self, line: &StatLine<'_>) {
87 let path = match &self.path {
88 Some(p) => p,
89 None => return,
90 };
91 if let Some(parent) = path.parent() {
92 let _ = std::fs::create_dir_all(parent);
93 }
94 let ts = std::time::SystemTime::now()
95 .duration_since(std::time::UNIX_EPOCH)
96 .map(|d| d.as_micros())
97 .unwrap_or(0);
98 let call_id = match line.call_id {
99 Some(id) => id.to_string(),
100 None => generate_call_id(),
101 };
102 let mut record = serde_json::Map::new();
103 for (key, value) in line.extra {
104 record.insert((*key).to_string(), value.clone());
105 }
106 let shared = [
109 ("ts_us", serde_json::json!(ts)),
110 ("tool", serde_json::json!(line.tool)),
111 ("outcome", serde_json::json!(line.outcome)),
112 ("duration_us", serde_json::json!(line.duration_us)),
113 ("args_summary", serde_json::json!(line.args_summary)),
114 ("reason", serde_json::json!(line.reason)),
115 ("call_id", serde_json::json!(call_id)),
116 ];
117 for (key, value) in shared {
118 record.insert(key.to_string(), value);
119 }
120 let mut serialized = match serde_json::to_string(&record) {
121 Ok(s) => s,
122 Err(_) => return,
123 };
124 serialized.push('\n');
125 let _ = std::fs::OpenOptions::new()
129 .create(true)
130 .append(true)
131 .open(path)
132 .and_then(|mut f| std::io::Write::write_all(&mut f, serialized.as_bytes()));
133 }
134}
135
136#[derive(Clone, Copy, Debug)]
142pub struct StatLine<'a> {
143 pub tool: &'a str,
144 pub outcome: &'a str,
145 pub duration_us: u128,
146 pub args_summary: &'a str,
147 pub reason: Option<&'a str>,
148 pub call_id: Option<&'a str>,
149 pub extra: &'a [(&'a str, serde_json::Value)],
150}
151
152impl<'a> StatLine<'a> {
153 pub fn new(tool: &'a str, outcome: &'a str, duration_us: u128, args_summary: &'a str) -> Self {
154 Self {
155 tool,
156 outcome,
157 duration_us,
158 args_summary,
159 reason: None,
160 call_id: None,
161 extra: &[],
162 }
163 }
164}
165
166pub const MAX_CALL_ID_BYTES: usize = 256;
167
168pub fn accept_call_id(raw: &str) -> Option<&str> {
173 (!raw.is_empty() && raw.len() <= MAX_CALL_ID_BYTES).then_some(raw)
174}
175
176fn generate_call_id() -> String {
181 static SEQ: AtomicU64 = AtomicU64::new(0);
182 static EPOCH_NS: OnceLock<u128> = OnceLock::new();
183 let epoch = *EPOCH_NS.get_or_init(|| {
184 std::time::SystemTime::now()
185 .duration_since(std::time::UNIX_EPOCH)
186 .map(|d| d.as_nanos())
187 .unwrap_or(0)
188 });
189 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
190 let mut hasher = blake3::Hasher::new();
191 hasher.update(&std::process::id().to_le_bytes());
192 hasher.update(&epoch.to_le_bytes());
193 hasher.update(&seq.to_le_bytes());
194 format!("verdant-{}", &hasher.finalize().to_hex()[..32])
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::sync::Arc;
201
202 fn lines(path: &std::path::Path) -> Vec<serde_json::Value> {
203 std::fs::read_to_string(path)
204 .unwrap()
205 .lines()
206 .map(|l| serde_json::from_str(l).unwrap())
207 .collect()
208 }
209
210 #[test]
211 fn writes_one_json_line_per_call() {
212 let dir = tempfile::tempdir().unwrap();
213 let path = dir.path().join("stats.jsonl");
214 let recorder = StatsRecorder::new(Some(path.clone()));
215
216 recorder.write(&StatLine::new(
217 "llm_call",
218 OUTCOME_MISS,
219 1_500_000,
220 "qwen3.5-4b",
221 ));
222 recorder.write(&StatLine::new("llm_call", OUTCOME_HIT, 900, "qwen3.5-4b"));
223
224 let records = lines(&path);
225 assert_eq!(records.len(), 2);
226 assert_eq!(records[0]["tool"], "llm_call");
227 assert_eq!(records[0]["outcome"], "miss");
228 assert_eq!(records[0]["duration_us"], 1_500_000u64);
229 assert_eq!(records[0]["args_summary"], "qwen3.5-4b");
230 assert_eq!(records[1]["outcome"], "hit");
231 assert!(records[0]["ts_us"].as_u64().unwrap() > 0);
232 }
233
234 #[test]
235 fn every_line_carries_a_reason_that_is_null_unless_given() {
236 let dir = tempfile::tempdir().unwrap();
237 let path = dir.path().join("stats.jsonl");
238 let recorder = StatsRecorder::new(Some(path.clone()));
239
240 recorder.write(&StatLine::new("read", OUTCOME_HIT, 1, "a.txt"));
241 recorder.write(&StatLine::new("llm_call", OUTCOME_MISS, 1, "m"));
242 recorder.write(&StatLine {
243 reason: Some(reason::NO_RULE),
244 ..StatLine::new("bash", OUTCOME_UNCACHED, 1, "ls")
245 });
246
247 let records = lines(&path);
248 assert!(records[0]["reason"].is_null());
249 assert!(records[0].as_object().unwrap().contains_key("reason"));
250 assert!(records[1]["reason"].is_null());
251 assert_eq!(records[2]["reason"], "no_rule");
252 }
253
254 #[test]
255 fn carries_a_producer_specific_field_alongside_the_shared_shape() {
256 let dir = tempfile::tempdir().unwrap();
257 let path = dir.path().join("stats.jsonl");
258
259 StatsRecorder::new(Some(path.clone())).write(&StatLine {
260 extra: &[("completion_tokens", serde_json::json!(146))],
261 ..StatLine::new("llm_call", OUTCOME_MISS, 1_000, "qwen3.5-4b abc123")
262 });
263
264 let records = lines(&path);
265 assert_eq!(records[0]["completion_tokens"], 146);
266 assert_eq!(records[0]["tool"], "llm_call");
267 assert_eq!(records[0]["outcome"], "miss");
268 }
269
270 #[test]
271 fn creates_the_parent_directory() {
272 let dir = tempfile::tempdir().unwrap();
273 let path = dir.path().join("nested/deeper/stats.jsonl");
274 StatsRecorder::new(Some(path.clone())).write(&StatLine::new(
275 "llm_call",
276 OUTCOME_HIT,
277 1,
278 "",
279 ));
280 assert_eq!(lines(&path).len(), 1);
281 }
282
283 #[test]
284 fn a_disabled_recorder_writes_nothing_and_does_not_fail() {
285 let recorder = StatsRecorder::disabled();
286 assert!(!recorder.is_enabled());
287 recorder.write(&StatLine::new("llm_call", OUTCOME_HIT, 1, ""));
288 }
289
290 #[test]
291 fn an_empty_env_var_disables_rather_than_writing_to_the_working_directory() {
292 let var = "VERDANT_STATS_PATH_EMPTY_CASE_TEST";
296 std::env::set_var(var, "");
297 assert!(!StatsRecorder::from_env(var).is_enabled());
298 std::env::remove_var(var);
299 assert!(!StatsRecorder::from_env(var).is_enabled());
300 }
301
302 #[test]
303 fn concurrent_records_do_not_interleave() {
304 let dir = tempfile::tempdir().unwrap();
305 let path = dir.path().join("stats.jsonl");
306 let recorder = Arc::new(StatsRecorder::new(Some(path.clone())));
307
308 let handles: Vec<_> = (0..8)
309 .map(|i| {
310 let recorder = recorder.clone();
311 std::thread::spawn(move || {
312 for _ in 0..25 {
313 recorder.write(&StatLine::new(
314 "llm_call",
315 OUTCOME_HIT,
316 i,
317 "model-with-a-longer-name",
318 ));
319 }
320 })
321 })
322 .collect();
323 for h in handles {
324 h.join().unwrap();
325 }
326
327 let records = lines(&path);
328 assert_eq!(records.len(), 200);
329 assert!(records.iter().all(|r| r["tool"] == "llm_call"));
330 }
331
332 fn is_generated_id(id: &str) -> bool {
333 id.strip_prefix("verdant-").is_some_and(|hex| {
334 hex.len() == 32
335 && hex
336 .bytes()
337 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
338 })
339 }
340
341 #[test]
342 fn writes_the_call_id_it_is_given() {
343 let dir = tempfile::tempdir().unwrap();
344 let path = dir.path().join("stats.jsonl");
345 StatsRecorder::new(Some(path.clone())).write(&StatLine {
346 call_id: Some("toolu_01abc"),
347 ..StatLine::new("bash", OUTCOME_MISS, 1, "ls")
348 });
349 assert_eq!(lines(&path)[0]["call_id"], "toolu_01abc");
350 }
351
352 #[test]
353 fn generates_a_call_id_when_none_is_given() {
354 let dir = tempfile::tempdir().unwrap();
355 let path = dir.path().join("stats.jsonl");
356 let recorder = StatsRecorder::new(Some(path.clone()));
357 recorder.write(&StatLine::new("bash", OUTCOME_MISS, 1, "ls"));
358 recorder.write(&StatLine::new("bash", OUTCOME_HIT, 1, "ls"));
359
360 let records = lines(&path);
361 let first = records[0]["call_id"].as_str().unwrap();
362 let second = records[1]["call_id"].as_str().unwrap();
363 assert!(is_generated_id(first), "{first}");
364 assert!(is_generated_id(second), "{second}");
365 assert_ne!(first, second);
366 }
367
368 #[test]
369 fn generated_call_ids_are_unique_across_threads() {
370 let handles: Vec<_> = (0..4)
371 .map(|_| {
372 std::thread::spawn(|| (0..500).map(|_| generate_call_id()).collect::<Vec<_>>())
373 })
374 .collect();
375 let mut all = std::collections::HashSet::new();
376 for h in handles {
377 for id in h.join().unwrap() {
378 assert!(is_generated_id(&id), "{id}");
379 assert!(all.insert(id));
380 }
381 }
382 assert_eq!(all.len(), 2_000);
383 }
384
385 #[test]
386 fn a_caller_id_is_accepted_only_between_1_and_256_bytes() {
387 assert_eq!(accept_call_id("a"), Some("a"));
388 let longest = "x".repeat(MAX_CALL_ID_BYTES);
389 assert_eq!(accept_call_id(&longest), Some(longest.as_str()));
390 assert_eq!(accept_call_id(""), None);
391 assert_eq!(accept_call_id(&"x".repeat(MAX_CALL_ID_BYTES + 1)), None);
392 }
393
394 #[test]
395 fn an_extra_cannot_replace_a_shared_field() {
396 let dir = tempfile::tempdir().unwrap();
397 let path = dir.path().join("stats.jsonl");
398 StatsRecorder::new(Some(path.clone())).write(&StatLine {
399 call_id: Some("real"),
400 extra: &[
401 ("call_id", serde_json::json!("forged")),
402 ("tool", serde_json::json!("forged")),
403 ],
404 ..StatLine::new("llm_call", OUTCOME_MISS, 1, "m")
405 });
406 let record = &lines(&path)[0];
407 assert_eq!(record["call_id"], "real");
408 assert_eq!(record["tool"], "llm_call");
409 }
410}