Skip to main content

phi_telemetry/
storage.rs

1//! Metrics persistence — save, load, and list session metrics from disk.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6
7use crate::types::{SessionMetrics, SessionSummary};
8
9/// Metrics file name within a session directory.
10const METRICS_FILE: &str = "session_metrics.json";
11
12/// Incrementally write session metrics to `session_metrics.json`.
13/// Overwrites the file on each call — safe for per-turn writes.
14pub fn save_metrics(metrics: &SessionMetrics, session_dir: &Path) -> Result<()> {
15    let path = session_dir.join(METRICS_FILE);
16    let json = serde_json::to_string_pretty(metrics)?;
17    std::fs::write(&path, json)?;
18    tracing::debug!(path = %path.display(), turns = metrics.total_turns, "session_metrics saved");
19    Ok(())
20}
21
22/// Load session metrics from `session_metrics.json` in a session directory.
23pub fn load_metrics(session_dir: &Path) -> Result<SessionMetrics> {
24    let path = session_dir.join(METRICS_FILE);
25    let content = std::fs::read_to_string(&path)
26        .with_context(|| format!("Failed to read metrics file: {}", path.display()))?;
27    let metrics: SessionMetrics = serde_json::from_str(&content)
28        .with_context(|| format!("Failed to parse metrics file: {}", path.display()))?;
29    Ok(metrics)
30}
31
32/// Try to load session metrics, returning `None` if the file doesn't exist.
33pub fn try_load_metrics(session_dir: &Path) -> Option<SessionMetrics> {
34    load_metrics(session_dir).ok()
35}
36
37/// List all session summaries by scanning the sessions directory.
38/// Reads each `session_metrics.json` and extracts summary fields.
39pub fn list_all_metrics(base_dir: &Path) -> Result<Vec<SessionSummary>> {
40    let sessions_dir = base_dir.join("sessions");
41    if !sessions_dir.exists() {
42        return Ok(Vec::new());
43    }
44
45    let mut summaries = Vec::new();
46    for entry in std::fs::read_dir(&sessions_dir)? {
47        let entry = entry?;
48        let path = entry.path();
49        if !path.is_dir() {
50            continue;
51        }
52
53        if let Some(metrics) = try_load_metrics(&path) {
54            let product = metrics
55                .custom
56                .get("product")
57                .and_then(|v| v.as_str())
58                .map(|s| s.to_string());
59
60            summaries.push(SessionSummary {
61                session_id: metrics.session_id,
62                node_id: metrics.node_id,
63                created_at: metrics.created_at,
64                model: metrics.model,
65                total_turns: metrics.total_turns,
66                total_chars: metrics.total_chars,
67                outcome: metrics.outcome,
68                product,
69            });
70        }
71    }
72
73    // Sort by created_at descending (newest first)
74    summaries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
75    Ok(summaries)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::types::{SessionMetrics, SessionOutcome, TurnMetrics, TurnOutcome};
82    use serde_json::Value;
83    use std::fs;
84
85    /// Helper: create a temporary directory for tests.
86    fn temp_dir(name: &str) -> std::path::PathBuf {
87        let dir = std::env::temp_dir().join(format!(
88            "phi_telemetry_test_{}_{}",
89            name,
90            std::time::SystemTime::now()
91                .duration_since(std::time::UNIX_EPOCH)
92                .unwrap()
93                .as_nanos()
94        ));
95        fs::create_dir_all(&dir).unwrap();
96        dir
97    }
98
99    /// Helper: build a sample SessionMetrics with one turn.
100    fn sample_metrics(session_id: &str, model: &str) -> SessionMetrics {
101        let mut m = SessionMetrics::new(
102            session_id.to_string(),
103            "node-1".to_string(),
104            model.to_string(),
105        );
106        let turn = TurnMetrics::new(
107            1,
108            "2026-08-01T12:00:00Z".to_string(),
109            1000,
110            model.to_string(),
111            "test input".to_string(),
112            TurnOutcome::Completed,
113        );
114        m.append_turn(turn);
115        m
116    }
117
118    // ── save_metrics ──
119
120    #[test]
121    fn save_metrics_creates_file() {
122        let dir = temp_dir("save_creates");
123        let m = sample_metrics("s1", "gpt-4o");
124        save_metrics(&m, &dir).unwrap();
125
126        let path = dir.join(METRICS_FILE);
127        assert!(path.exists());
128
129        // Verify content is valid JSON
130        let content = fs::read_to_string(&path).unwrap();
131        let parsed: SessionMetrics = serde_json::from_str(&content).unwrap();
132        assert_eq!(parsed.session_id, "s1");
133
134        let _ = fs::remove_dir_all(&dir);
135    }
136
137    #[test]
138    fn save_metrics_overwrites_existing() {
139        let dir = temp_dir("save_overwrite");
140        let m1 = sample_metrics("s1", "gpt-4o");
141        save_metrics(&m1, &dir).unwrap();
142
143        let mut m2 = sample_metrics("s2", "claude-sonnet");
144        m2.total_turns = 5;
145        save_metrics(&m2, &dir).unwrap();
146
147        let loaded = load_metrics(&dir).unwrap();
148        assert_eq!(loaded.session_id, "s2");
149        assert_eq!(loaded.total_turns, 5);
150
151        let _ = fs::remove_dir_all(&dir);
152    }
153
154    // ── load_metrics ──
155
156    #[test]
157    fn load_metrics_roundtrip() {
158        let dir = temp_dir("load_roundtrip");
159        let original = sample_metrics("s1", "gpt-4o");
160        save_metrics(&original, &dir).unwrap();
161
162        let loaded = load_metrics(&dir).unwrap();
163        assert_eq!(loaded.session_id, "s1");
164        assert_eq!(loaded.model, "gpt-4o");
165        assert_eq!(loaded.total_turns, 1);
166        assert_eq!(loaded.turns.len(), 1);
167        assert_eq!(loaded.node_id, "node-1");
168
169        let _ = fs::remove_dir_all(&dir);
170    }
171
172    #[test]
173    fn load_metrics_file_not_found() {
174        let dir = temp_dir("load_not_found");
175        let result = load_metrics(&dir);
176        assert!(result.is_err());
177        assert!(
178            result
179                .unwrap_err()
180                .to_string()
181                .contains("Failed to read metrics file")
182        );
183
184        let _ = fs::remove_dir_all(&dir);
185    }
186
187    #[test]
188    fn load_metrics_invalid_json() {
189        let dir = temp_dir("load_invalid");
190        fs::write(dir.join(METRICS_FILE), "not valid json").unwrap();
191
192        let result = load_metrics(&dir);
193        assert!(result.is_err());
194        assert!(
195            result
196                .unwrap_err()
197                .to_string()
198                .contains("Failed to parse metrics file")
199        );
200
201        let _ = fs::remove_dir_all(&dir);
202    }
203
204    // ── try_load_metrics ──
205
206    #[test]
207    fn try_load_metrics_returns_some_when_exists() {
208        let dir = temp_dir("try_some");
209        let m = sample_metrics("s1", "gpt-4o");
210        save_metrics(&m, &dir).unwrap();
211
212        let result = try_load_metrics(&dir);
213        assert!(result.is_some());
214        assert_eq!(result.unwrap().session_id, "s1");
215
216        let _ = fs::remove_dir_all(&dir);
217    }
218
219    #[test]
220    fn try_load_metrics_returns_none_when_missing() {
221        let dir = temp_dir("try_none");
222        let result = try_load_metrics(&dir);
223        assert!(result.is_none());
224
225        let _ = fs::remove_dir_all(&dir);
226    }
227
228    #[test]
229    fn try_load_metrics_returns_none_on_invalid_json() {
230        let dir = temp_dir("try_invalid");
231        fs::write(dir.join(METRICS_FILE), "not json").unwrap();
232
233        let result = try_load_metrics(&dir);
234        assert!(result.is_none());
235
236        let _ = fs::remove_dir_all(&dir);
237    }
238
239    // ── list_all_metrics ──
240
241    #[test]
242    fn list_all_metrics_empty_when_no_sessions_dir() {
243        let dir = temp_dir("list_no_sessions");
244        let result = list_all_metrics(&dir).unwrap();
245        assert!(result.is_empty());
246
247        let _ = fs::remove_dir_all(&dir);
248    }
249
250    #[test]
251    fn list_all_metrics_returns_summaries_sorted_by_date() {
252        let base = temp_dir("list_sorted");
253        let sessions_dir = base.join("sessions");
254        fs::create_dir_all(&sessions_dir).unwrap();
255
256        // Create two sessions with different created_at
257        let dir1 = sessions_dir.join("s1");
258        fs::create_dir_all(&dir1).unwrap();
259        let mut m1 = sample_metrics("s1", "gpt-4o");
260        m1.created_at = "2026-01-01T00:00:00Z".to_string();
261        save_metrics(&m1, &dir1).unwrap();
262
263        let dir2 = sessions_dir.join("s2");
264        fs::create_dir_all(&dir2).unwrap();
265        let mut m2 = sample_metrics("s2", "claude-sonnet");
266        m2.created_at = "2026-08-01T00:00:00Z".to_string();
267        save_metrics(&m2, &dir2).unwrap();
268
269        let summaries = list_all_metrics(&base).unwrap();
270        assert_eq!(summaries.len(), 2);
271        // Sorted descending by created_at — s2 first
272        assert_eq!(summaries[0].session_id, "s2");
273        assert_eq!(summaries[1].session_id, "s1");
274        assert_eq!(summaries[0].model, "claude-sonnet");
275        assert_eq!(summaries[0].total_turns, 1);
276
277        let _ = fs::remove_dir_all(&base);
278    }
279
280    #[test]
281    fn list_all_metrics_skips_non_session_dirs() {
282        let base = temp_dir("list_skip");
283        let sessions_dir = base.join("sessions");
284        fs::create_dir_all(&sessions_dir).unwrap();
285
286        // A subdirectory without metrics — should be skipped gracefully
287        let empty_dir = sessions_dir.join("empty_session");
288        fs::create_dir_all(&empty_dir).unwrap();
289
290        // A regular file (not a dir) — should be skipped
291        fs::write(sessions_dir.join("not_a_dir.txt"), "data").unwrap();
292
293        // A valid session
294        let valid_dir = sessions_dir.join("valid");
295        fs::create_dir_all(&valid_dir).unwrap();
296        save_metrics(&sample_metrics("valid", "gpt-4o"), &valid_dir).unwrap();
297
298        let summaries = list_all_metrics(&base).unwrap();
299        assert_eq!(summaries.len(), 1);
300        assert_eq!(summaries[0].session_id, "valid");
301
302        let _ = fs::remove_dir_all(&base);
303    }
304
305    #[test]
306    fn list_all_metrics_extracts_product_from_custom() {
307        let base = temp_dir("list_product");
308        let sessions_dir = base.join("sessions");
309        let sdir = sessions_dir.join("s1");
310        fs::create_dir_all(&sdir).unwrap();
311
312        let mut m = sample_metrics("s1", "gpt-4o");
313        if let Value::Object(ref mut map) = m.custom {
314            map.insert("product".to_string(), Value::String("phi-bard".to_string()));
315        }
316        save_metrics(&m, &sdir).unwrap();
317
318        let summaries = list_all_metrics(&base).unwrap();
319        assert_eq!(summaries.len(), 1);
320        assert_eq!(summaries[0].product, Some("phi-bard".to_string()));
321
322        let _ = fs::remove_dir_all(&base);
323    }
324
325    #[test]
326    fn list_all_metrics_no_product_when_custom_empty() {
327        let base = temp_dir("list_no_product");
328        let sessions_dir = base.join("sessions");
329        let sdir = sessions_dir.join("s1");
330        fs::create_dir_all(&sdir).unwrap();
331
332        let m = sample_metrics("s1", "gpt-4o");
333        save_metrics(&m, &sdir).unwrap();
334
335        let summaries = list_all_metrics(&base).unwrap();
336        assert_eq!(summaries[0].product, None);
337
338        let _ = fs::remove_dir_all(&base);
339    }
340
341    #[test]
342    fn list_all_metrics_summary_fields_are_correct() {
343        let base = temp_dir("list_fields");
344        let sessions_dir = base.join("sessions");
345        let sdir = sessions_dir.join("s1");
346        fs::create_dir_all(&sdir).unwrap();
347
348        let mut m = sample_metrics("s1", "gpt-4o");
349        m.node_id = "node-42".to_string();
350        m.created_at = "2026-06-15T10:00:00Z".to_string();
351        m.total_chars = 5000;
352        m.outcome = SessionOutcome::Completed;
353        save_metrics(&m, &sdir).unwrap();
354
355        let summaries = list_all_metrics(&base).unwrap();
356        let s = &summaries[0];
357        assert_eq!(s.session_id, "s1");
358        assert_eq!(s.node_id, "node-42");
359        assert_eq!(s.created_at, "2026-06-15T10:00:00Z");
360        assert_eq!(s.model, "gpt-4o");
361        assert_eq!(s.total_turns, 1);
362        assert_eq!(s.total_chars, 5000);
363        assert_eq!(s.outcome, SessionOutcome::Completed);
364
365        let _ = fs::remove_dir_all(&base);
366    }
367}