Skip to main content

aptu_coder/
metrics.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Metrics collection and daily-rotating JSONL emission.
4//!
5//! Provides a channel-based pipeline: callers emit [`MetricEvent`] values via [`MetricsSender`],
6//! and [`MetricsWriter`] drains the channel and appends events to a daily-rotated JSONL file
7//! under the XDG data directory (`~/.local/share/aptu-coder/metrics-YYYY-MM-DD.jsonl`).
8//! Files older than 30 days are deleted on startup.
9
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13use tokio::io::AsyncWriteExt;
14use tokio::sync::mpsc;
15
16/// A single metric event emitted by a tool invocation.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct MetricEvent {
19    pub ts: u64,
20    pub tool: &'static str,
21    pub duration_ms: u64,
22    pub output_chars: usize,
23    pub param_path_depth: usize,
24    pub max_depth: Option<u32>,
25    pub result: &'static str,
26    pub error_type: Option<String>,
27    #[serde(default)]
28    pub session_id: Option<String>,
29    #[serde(default)]
30    pub seq: Option<u32>,
31    #[serde(default)]
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub cache_hit: Option<bool>,
34}
35
36/// Sender half of the metrics channel; cloned and passed to tools for event emission.
37#[derive(Clone)]
38pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
39
40impl MetricsSender {
41    pub fn send(&self, event: MetricEvent) {
42        let _ = self.0.send(event);
43    }
44}
45
46/// Receiver half of the metrics channel; drains events and writes them to daily-rotated JSONL files.
47pub struct MetricsWriter {
48    rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
49    base_dir: PathBuf,
50    dir_created: bool,
51}
52
53impl MetricsWriter {
54    pub fn new(
55        rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
56        base_dir: Option<PathBuf>,
57    ) -> Self {
58        let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
59        Self {
60            rx,
61            base_dir: dir,
62            dir_created: false,
63        }
64    }
65
66    pub async fn run(mut self) {
67        cleanup_old_files(&self.base_dir).await;
68        let mut current_date = current_date_str();
69        let mut current_file: Option<PathBuf> = None;
70
71        loop {
72            let mut batch = Vec::new();
73            if let Some(event) = self.rx.recv().await {
74                batch.push(event);
75                for _ in 0..99 {
76                    match self.rx.try_recv() {
77                        Ok(e) => batch.push(e),
78                        Err(
79                            mpsc::error::TryRecvError::Empty
80                            | mpsc::error::TryRecvError::Disconnected,
81                        ) => break,
82                    }
83                }
84            } else {
85                break;
86            }
87
88            let new_date = current_date_str();
89            if new_date != current_date {
90                current_date = new_date;
91                current_file = None;
92                self.dir_created = false;
93            }
94
95            if current_file.is_none() {
96                current_file = Some(
97                    self.base_dir
98                        .join(format!("metrics-{}.jsonl", current_date)),
99                );
100            }
101
102            let path = current_file.as_ref().unwrap();
103
104            // Create directory once per day
105            if !self.dir_created
106                && let Some(parent) = path.parent()
107                && !parent.as_os_str().is_empty()
108            {
109                match tokio::fs::create_dir_all(parent).await {
110                    Ok(()) => {
111                        self.dir_created = true;
112                    }
113                    Err(e) => {
114                        tracing::warn!(
115                            error = %e,
116                            path = %parent.display(),
117                            "metrics: failed to create directory; will retry next batch"
118                        );
119                    }
120                }
121            }
122
123            // Open file once per batch
124            let file = tokio::fs::OpenOptions::new()
125                .create(true)
126                .append(true)
127                .open(path)
128                .await;
129
130            if let Ok(mut file) = file {
131                for event in batch {
132                    if let Ok(mut json) = serde_json::to_string(&event) {
133                        json.push('\n');
134                        let _ = file.write_all(json.as_bytes()).await;
135                    }
136                }
137                let _ = file.flush().await;
138            }
139        }
140    }
141}
142
143/// Returns the current UNIX timestamp in milliseconds.
144#[must_use]
145pub fn unix_ms() -> u64 {
146    SystemTime::now()
147        .duration_since(UNIX_EPOCH)
148        .unwrap_or_default()
149        .as_millis()
150        .try_into()
151        .unwrap_or(u64::MAX)
152}
153
154/// Counts the number of path segments in a file path.
155#[must_use]
156pub fn path_component_count(path: &str) -> usize {
157    Path::new(path).components().count()
158}
159
160fn xdg_metrics_dir() -> PathBuf {
161    if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
162        && !xdg_data_home.is_empty()
163    {
164        return PathBuf::from(xdg_data_home).join("aptu-coder");
165    }
166
167    if let Ok(home) = std::env::var("HOME") {
168        PathBuf::from(home)
169            .join(".local")
170            .join("share")
171            .join("aptu-coder")
172    } else {
173        PathBuf::from(".")
174    }
175}
176
177async fn cleanup_old_files(base_dir: &Path) {
178    let now_days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
179
180    let Ok(mut entries) = tokio::fs::read_dir(base_dir).await else {
181        return;
182    };
183
184    loop {
185        match entries.next_entry().await {
186            Ok(Some(entry)) => {
187                let path = entry.path();
188                let file_name = match path.file_name() {
189                    Some(n) => n.to_string_lossy().into_owned(),
190                    None => continue,
191                };
192
193                // Expected format: metrics-YYYY-MM-DD.jsonl
194                if !file_name.starts_with("metrics-")
195                    || std::path::Path::new(&*file_name)
196                        .extension()
197                        .is_none_or(|e| !e.eq_ignore_ascii_case("jsonl"))
198                {
199                    continue;
200                }
201                let date_part = &file_name[8..file_name.len() - 6];
202                if date_part.len() != 10
203                    || date_part.as_bytes().get(4) != Some(&b'-')
204                    || date_part.as_bytes().get(7) != Some(&b'-')
205                {
206                    continue;
207                }
208                let Ok(year) = date_part[0..4].parse::<u32>() else {
209                    continue;
210                };
211                let Ok(month) = date_part[5..7].parse::<u32>() else {
212                    continue;
213                };
214                let Ok(day) = date_part[8..10].parse::<u32>() else {
215                    continue;
216                };
217                if month == 0 || month > 12 || day == 0 || day > 31 {
218                    continue;
219                }
220
221                let file_days = date_to_days_since_epoch(year, month, day);
222                if now_days > file_days && (now_days - file_days) > 30 {
223                    let _ = tokio::fs::remove_file(&path).await;
224                }
225            }
226            Ok(None) => break,
227            Err(e) => {
228                tracing::warn!("error reading metrics directory entry: {e}");
229            }
230        }
231    }
232}
233
234fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
235    // Shift year so March is month 0
236    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
237    let era = y / 400;
238    let yoe = y - era * 400;
239    let doy = (153 * m + 2) / 5 + d - 1;
240    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
241    // Compute the proleptic Gregorian day number, then subtract the Unix epoch offset.
242    // The subtraction must wrap the full expression; applying .saturating_sub to `doe`
243    // alone would underflow for recent dates where doe < 719_468.
244    (era * 146_097 + doe).saturating_sub(719_468)
245}
246
247/// Returns the current UTC date as a string in YYYY-MM-DD format.
248#[must_use]
249pub fn current_date_str() -> String {
250    let days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
251    let z = days + 719_468;
252    let era = z / 146_097;
253    let doe = z - era * 146_097;
254    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
255    let y = yoe + era * 400;
256    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
257    let mp = (5 * doy + 2) / 153;
258    let d = doy - (153 * mp + 2) / 5 + 1;
259    let m = if mp < 10 { mp + 3 } else { mp - 9 };
260    let y = if m <= 2 { y + 1 } else { y };
261    format!("{y:04}-{m:02}-{d:02}")
262}
263
264/// Migrate legacy metrics directory from `code-analyze-mcp` to `aptu-coder`.
265///
266/// - If the old directory exists and the new one does not, rename it and log info.
267/// - If both exist, log a warning and do nothing.
268/// - If neither exists, do nothing.
269///
270/// Returns `Ok(())` on success, propagating any I/O errors.
271pub fn migrate_legacy_metrics_dir() -> std::io::Result<()> {
272    let home =
273        std::env::var("HOME").map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
274    migrate_legacy_metrics_dir_impl(&home)
275}
276
277#[allow(dead_code)]
278fn migrate_legacy_metrics_dir_impl(home: &str) -> std::io::Result<()> {
279    let old_dir = PathBuf::from(home).join(".local/share/code-analyze-mcp");
280    let new_dir = PathBuf::from(home).join(".local/share/aptu-coder");
281
282    let old_exists = old_dir.is_dir();
283    let new_exists = new_dir.is_dir();
284
285    if old_exists && !new_exists {
286        std::fs::rename(&old_dir, &new_dir)?;
287        tracing::info!(
288            "Migrated legacy metrics directory from {:?} to {:?}",
289            old_dir,
290            new_dir
291        );
292    } else if old_exists && new_exists {
293        tracing::warn!("Both legacy and new metrics directories exist; not migrating");
294    }
295    // If old does not exist, nothing to do.
296    Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use std::fs;
303    use tempfile::TempDir;
304
305    #[test]
306    fn test_migrate_legacy_only_old_exists() {
307        // Arrange
308        let tmp_home = TempDir::new().unwrap();
309        let home_str = tmp_home.path().to_str().unwrap();
310        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
311        let new_path = tmp_home.path().join(".local/share/aptu-coder");
312        fs::create_dir_all(&old_path).unwrap();
313        assert!(!new_path.exists());
314
315        // Act
316        let result = migrate_legacy_metrics_dir_impl(home_str);
317
318        // Assert
319        assert!(result.is_ok());
320        assert!(!old_path.exists(), "old dir should be moved");
321        assert!(new_path.is_dir(), "new dir should exist");
322    }
323
324    #[test]
325    fn test_migrate_legacy_both_exist() {
326        // Arrange
327        let tmp_home = TempDir::new().unwrap();
328        let home_str = tmp_home.path().to_str().unwrap();
329        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
330        let new_path = tmp_home.path().join(".local/share/aptu-coder");
331        fs::create_dir_all(&old_path).unwrap();
332        fs::create_dir_all(&new_path).unwrap();
333
334        // Act
335        let result = migrate_legacy_metrics_dir_impl(home_str);
336
337        // Assert
338        assert!(result.is_ok());
339        assert!(old_path.is_dir(), "old dir should remain");
340        assert!(new_path.is_dir(), "new dir should remain");
341    }
342
343    #[test]
344    fn test_migrate_legacy_neither_exists() {
345        // Arrange
346        let tmp_home = TempDir::new().unwrap();
347        let home_str = tmp_home.path().to_str().unwrap();
348        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
349        let new_path = tmp_home.path().join(".local/share/aptu-coder");
350
351        // Act
352        let result = migrate_legacy_metrics_dir_impl(home_str);
353
354        // Assert
355        assert!(result.is_ok());
356        assert!(!old_path.exists(), "old dir should not exist");
357        assert!(!new_path.exists(), "new dir should not exist");
358    }
359
360    #[test]
361    fn test_date_to_days_since_epoch_known_dates() {
362        assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
363        assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
364        assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
365    }
366
367    #[test]
368    fn test_current_date_str_format() {
369        let s = current_date_str();
370        assert_eq!(s.len(), 10);
371        assert_eq!(s.as_bytes()[4], b'-');
372        assert_eq!(s.as_bytes()[7], b'-');
373        let year: u32 = s[0..4].parse().expect("year must be numeric");
374        assert!(year >= 2020 && year <= 2100);
375    }
376
377    #[tokio::test]
378    async fn test_metrics_writer_batching() {
379        let dir = TempDir::new().unwrap();
380        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
381        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
382        let make_event = || MetricEvent {
383            ts: unix_ms(),
384            tool: "analyze_directory",
385            duration_ms: 1,
386            output_chars: 10,
387            param_path_depth: 1,
388            max_depth: None,
389            result: "ok",
390            error_type: None,
391            session_id: None,
392            seq: None,
393            cache_hit: None,
394        };
395        tx.send(make_event()).unwrap();
396        tx.send(make_event()).unwrap();
397        tx.send(make_event()).unwrap();
398        drop(tx);
399        writer.run().await;
400        let entries: Vec<_> = std::fs::read_dir(dir.path())
401            .unwrap()
402            .filter_map(|e| e.ok())
403            .filter(|e| {
404                e.path()
405                    .extension()
406                    .and_then(|x| x.to_str())
407                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
408                    .unwrap_or(false)
409            })
410            .collect();
411        assert_eq!(entries.len(), 1);
412        let content = std::fs::read_to_string(entries[0].path()).unwrap();
413        let lines: Vec<&str> = content.lines().collect();
414        assert_eq!(lines.len(), 3);
415    }
416
417    #[tokio::test]
418    async fn test_cleanup_old_files_deletes_old_keeps_recent() {
419        let dir = TempDir::new().unwrap();
420        let old_file = dir.path().join("metrics-1970-01-01.jsonl");
421        let today = current_date_str();
422        let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
423        std::fs::write(&old_file, "old\n").unwrap();
424        std::fs::write(&recent_file, "recent\n").unwrap();
425        cleanup_old_files(dir.path()).await;
426        assert!(!old_file.exists());
427        assert!(recent_file.exists());
428    }
429
430    #[test]
431    fn test_metric_event_serialization() {
432        let event = MetricEvent {
433            ts: 1_700_000_000_000,
434            tool: "analyze_directory",
435            duration_ms: 42,
436            output_chars: 100,
437            param_path_depth: 3,
438            max_depth: Some(2),
439            result: "ok",
440            error_type: None,
441            session_id: None,
442            seq: None,
443            cache_hit: None,
444        };
445        let json = serde_json::to_string(&event).unwrap();
446        assert!(json.contains("analyze_directory"));
447        assert!(json.contains(r#""result":"ok""#));
448        assert!(json.contains(r#""output_chars":100"#));
449    }
450
451    #[test]
452    fn test_metric_event_serialization_error() {
453        let event = MetricEvent {
454            ts: 1_700_000_000_000,
455            tool: "analyze_directory",
456            duration_ms: 5,
457            output_chars: 0,
458            param_path_depth: 3,
459            max_depth: Some(3),
460            result: "error",
461            error_type: Some("invalid_params".to_string()),
462            session_id: None,
463            seq: None,
464            cache_hit: None,
465        };
466        let json = serde_json::to_string(&event).unwrap();
467        assert!(json.contains(r#""result":"error""#));
468        assert!(json.contains(r#""error_type":"invalid_params""#));
469        assert!(json.contains(r#""output_chars":0"#));
470        assert!(json.contains(r#""max_depth":3"#));
471    }
472
473    #[test]
474    fn test_metric_event_new_fields_round_trip() {
475        let event = MetricEvent {
476            ts: 1_700_000_000_000,
477            tool: "analyze_file",
478            duration_ms: 100,
479            output_chars: 500,
480            param_path_depth: 2,
481            max_depth: Some(3),
482            result: "ok",
483            error_type: None,
484            session_id: Some("1742468880123-42".to_string()),
485            seq: Some(5),
486            cache_hit: None,
487        };
488        let serialized = serde_json::to_string(&event).unwrap();
489        let json_str = r#"{"ts":1700000000000,"tool":"analyze_file","duration_ms":100,"output_chars":500,"param_path_depth":2,"max_depth":3,"result":"ok","error_type":null,"session_id":"1742468880123-42","seq":5}"#;
490        assert_eq!(serialized, json_str);
491    }
492}