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 aptu_coder_core::lang::language_for_extension;
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14use tokio::io::AsyncWriteExt;
15use tokio::sync::mpsc;
16
17/// A single metric event emitted by a tool invocation.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(default)]
20pub struct MetricEvent {
21    pub ts: u64,
22    pub tool: &'static str,
23    pub duration_ms: u64,
24    pub output_chars: usize,
25    pub param_path_depth: usize,
26    pub max_depth: Option<u32>,
27    pub result: &'static str,
28    pub error_type: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub error_subtype: Option<String>,
31    #[serde(default)]
32    pub session_id: Option<String>,
33    #[serde(default)]
34    pub seq: Option<u32>,
35    #[serde(default)]
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub cache_hit: Option<bool>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub cache_tier: Option<&'static str>,
40    /// Set to Some(true) when an L2 disk cache write fails (dir, tempfile, write, or rename).
41    /// Drives the cache_write_failures_total OTEL counter.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub cache_write_failure: Option<bool>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub exit_code: Option<i32>,
46    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
47    pub timed_out: bool,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub output_truncated: Option<bool>,
50    /// True when `output_chars > 30_000`; fires for the top ~0.33% of exec_command calls
51    /// (p99.7 of 27,981 observed calls). Early-warning signal for responses approaching
52    /// the per-stream byte-cap threshold.
53    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
54    pub chars_threshold_breach: bool,
55    /// File extension of the analyzed path, lowercased. `Some("rs")` for known extensions,
56    /// `Some("other")` for unrecognized extensions, `None` when the path has no extension.
57    /// Only populated for `analyze_file` and `analyze_module`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub file_ext: Option<&'static str>,
60    /// Name of the filter rule that matched and transformed exec_command output.
61    /// `None` when no filter fired or for non-`exec_command` tools.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub filter_applied: Option<String>,
64    /// Human-readable programming language name derived from the file extension
65    /// (e.g., `Some("Rust")` for `.rs` files). `None` when the path has no extension
66    /// or the extension is not recognized. Only populated for `analyze_file` and
67    /// `analyze_module`.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub language: Option<String>,
70}
71/// Fluent builder for MetricEvent. Reduces repetitive struct literal boilerplate.
72#[derive(Debug, Default)]
73pub(crate) struct MetricEventBuilder {
74    ts: u64,
75    tool: &'static str,
76    duration_ms: u64,
77    output_chars: usize,
78    param_path_depth: usize,
79    max_depth: Option<u32>,
80    result: &'static str,
81    error_type: Option<String>,
82    error_subtype: Option<String>,
83    session_id: Option<String>,
84    seq: Option<u32>,
85    cache_hit: Option<bool>,
86    cache_write_failure: Option<bool>,
87    cache_tier: Option<&'static str>,
88    exit_code: Option<i32>,
89    timed_out: bool,
90    output_truncated: Option<bool>,
91    chars_threshold_breach: bool,
92    file_ext: Option<&'static str>,
93    filter_applied: Option<String>,
94    language: Option<String>,
95}
96
97impl MetricEventBuilder {
98    pub fn new(tool: &'static str, result: &'static str, duration_ms: u64) -> Self {
99        Self {
100            ts: unix_ms(),
101            tool,
102            result,
103            duration_ms,
104            ..Self::default()
105        }
106    }
107    pub fn output_chars(mut self, v: usize) -> Self {
108        self.output_chars = v;
109        self
110    }
111    pub fn param_path_depth(mut self, v: usize) -> Self {
112        self.param_path_depth = v;
113        self
114    }
115    pub fn max_depth(mut self, v: Option<u32>) -> Self {
116        self.max_depth = v;
117        self
118    }
119    pub fn error_type(mut self, v: Option<String>) -> Self {
120        self.error_type = v;
121        self
122    }
123    pub fn error_subtype(mut self, v: Option<String>) -> Self {
124        self.error_subtype = v;
125        self
126    }
127    pub fn session_id(mut self, v: Option<String>) -> Self {
128        self.session_id = v;
129        self
130    }
131    pub fn seq(mut self, v: Option<u32>) -> Self {
132        self.seq = v;
133        self
134    }
135    pub fn cache_hit(mut self, v: Option<bool>) -> Self {
136        self.cache_hit = v;
137        self
138    }
139    pub fn cache_write_failure(mut self, v: Option<bool>) -> Self {
140        self.cache_write_failure = v;
141        self
142    }
143    pub fn cache_tier(mut self, v: Option<&'static str>) -> Self {
144        self.cache_tier = v;
145        self
146    }
147    pub fn exit_code(mut self, v: Option<i32>) -> Self {
148        self.exit_code = v;
149        self
150    }
151    pub fn timed_out(mut self, v: bool) -> Self {
152        self.timed_out = v;
153        self
154    }
155    pub fn output_truncated(mut self, v: Option<bool>) -> Self {
156        self.output_truncated = v;
157        self
158    }
159    pub fn chars_threshold_breach(mut self, v: bool) -> Self {
160        self.chars_threshold_breach = v;
161        self
162    }
163    pub fn file_ext(mut self, v: Option<&'static str>) -> Self {
164        self.file_ext = v;
165        self
166    }
167    pub fn filter_applied(mut self, v: Option<String>) -> Self {
168        self.filter_applied = v;
169        self
170    }
171    pub fn language(mut self, v: Option<String>) -> Self {
172        self.language = v;
173        self
174    }
175    pub fn build(self) -> MetricEvent {
176        MetricEvent {
177            ts: self.ts,
178            tool: self.tool,
179            duration_ms: self.duration_ms,
180            output_chars: self.output_chars,
181            param_path_depth: self.param_path_depth,
182            max_depth: self.max_depth,
183            result: self.result,
184            error_type: self.error_type,
185            error_subtype: self.error_subtype,
186            session_id: self.session_id,
187            seq: self.seq,
188            cache_hit: self.cache_hit,
189            cache_write_failure: self.cache_write_failure,
190            cache_tier: self.cache_tier,
191            exit_code: self.exit_code,
192            timed_out: self.timed_out,
193            output_truncated: self.output_truncated,
194            chars_threshold_breach: self.chars_threshold_breach,
195            file_ext: self.file_ext,
196            filter_applied: self.filter_applied,
197            language: self.language,
198        }
199    }
200}
201
202/// Sender half of the metrics channel; cloned and passed to tools for event emission.
203#[derive(Clone)]
204pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
205
206impl MetricsSender {
207    pub fn send(&self, event: MetricEvent) {
208        let _ = self.0.send(event);
209    }
210}
211
212/// Receiver half of the metrics channel; drains events and writes them to daily-rotated JSONL files.
213pub struct MetricsWriter {
214    rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
215    base_dir: PathBuf,
216    dir_created: bool,
217}
218
219/// Accumulated metrics for a single tool.
220#[derive(Default, Debug)]
221struct ToolMetrics {
222    count: u64,
223    duration_ms: u64,
224    output_chars: u64,
225}
226
227impl MetricsWriter {
228    pub fn new(
229        rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
230        base_dir: Option<PathBuf>,
231    ) -> Self {
232        let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
233        Self {
234            rx,
235            base_dir: dir,
236            dir_created: false,
237        }
238    }
239
240    /// Accumulate a metric event into tool_counts and export_session_id.
241    fn accumulate_event(
242        tool_counts: &mut std::collections::HashMap<&'static str, ToolMetrics>,
243        export_session_id: &mut Option<String>,
244        event: &MetricEvent,
245    ) {
246        let entry = tool_counts.entry(event.tool).or_default();
247        entry.count += 1;
248        entry.duration_ms += event.duration_ms;
249        // output_chars is capped at 50 KB per stream (stdout + stderr each), so usize -> u64 is lossless.
250        entry.output_chars += event.output_chars as u64;
251        if export_session_id.is_none() {
252            *export_session_id = event.session_id.clone();
253        }
254    }
255
256    /// Write accumulated batch to file. Fire-and-forget semantics: errors are logged but not propagated.
257    async fn flush_batch(file: &mut tokio::fs::File, batch: Vec<MetricEvent>) {
258        for event in batch {
259            // Record to OTel metrics if available
260            record_otel_metrics(&event);
261
262            // Always write to JSONL as fallback
263            if let Ok(mut json) = serde_json::to_string(&event) {
264                json.push('\n');
265                let _ = file.write_all(json.as_bytes()).await;
266            }
267        }
268        let _ = file.flush().await;
269    }
270
271    /// Check for date transition and rotate metrics file if needed.
272    /// Returns the current file path and updates state if rotation occurred.
273    fn rotate_metrics_file(
274        base_dir: &std::path::Path,
275        current_date: &mut String,
276        current_file: &mut Option<PathBuf>,
277        dir_created: &mut bool,
278    ) -> PathBuf {
279        let new_date = current_date_str();
280        if new_date != *current_date {
281            *current_date = new_date;
282            *current_file = None;
283            *dir_created = false;
284        }
285
286        if current_file.is_none() {
287            *current_file = Some(base_dir.join(format!("metrics-{}.jsonl", current_date)));
288        }
289
290        current_file
291            .as_ref()
292            .expect("current_file is guaranteed Some after check above")
293            .clone()
294    }
295
296    /// Receive and accumulate a batch of events from the channel.
297    async fn receive_batch(
298        rx: &mut tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
299        tool_counts: &mut std::collections::HashMap<&'static str, ToolMetrics>,
300        export_session_id: &mut Option<String>,
301    ) -> Option<Vec<MetricEvent>> {
302        let mut batch = Vec::new();
303        if let Some(event) = rx.recv().await {
304            Self::accumulate_event(tool_counts, export_session_id, &event);
305            batch.push(event);
306            for _ in 0..99 {
307                match rx.try_recv() {
308                    Ok(e) => {
309                        Self::accumulate_event(tool_counts, export_session_id, &e);
310                        batch.push(e);
311                    }
312                    Err(
313                        mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected,
314                    ) => break,
315                }
316            }
317            Some(batch)
318        } else {
319            None
320        }
321    }
322
323    /// Ensure metrics directory exists for the given path.
324    async fn ensure_metrics_dir(path: &std::path::Path, dir_created: &mut bool) {
325        if !*dir_created
326            && let Some(parent) = path.parent()
327            && !parent.as_os_str().is_empty()
328        {
329            match tokio::fs::create_dir_all(parent).await {
330                Ok(()) => {
331                    *dir_created = true;
332                }
333                Err(e) => {
334                    tracing::warn!(
335                        error = %e,
336                        path = %parent.display(),
337                        "metrics: failed to create directory; will retry next batch"
338                    );
339                }
340            }
341        }
342    }
343
344    pub async fn run(mut self) {
345        cleanup_old_files(&self.base_dir).await;
346        let mut current_date = current_date_str();
347        let mut current_file: Option<PathBuf> = None;
348
349        // Accumulate per-tool metrics for export on shutdown (issue #773)
350        let mut tool_counts: std::collections::HashMap<&'static str, ToolMetrics> =
351            std::collections::HashMap::new();
352        let mut export_session_id: Option<String> = None;
353
354        loop {
355            let Some(batch) =
356                Self::receive_batch(&mut self.rx, &mut tool_counts, &mut export_session_id).await
357            else {
358                break;
359            };
360
361            let path = Self::rotate_metrics_file(
362                &self.base_dir,
363                &mut current_date,
364                &mut current_file,
365                &mut self.dir_created,
366            );
367
368            Self::ensure_metrics_dir(&path, &mut self.dir_created).await;
369
370            // Open file once per batch
371            let file = tokio::fs::OpenOptions::new()
372                .create(true)
373                .append(true)
374                .open(&path)
375                .await;
376
377            if let Ok(mut file) = file {
378                Self::flush_batch(&mut file, batch).await;
379            }
380        }
381
382        // Export metrics summary on shutdown (issue #773)
383        if let Ok(export_path) = std::env::var("APTU_CODER_METRICS_EXPORT_FILE") {
384            if !std::path::Path::new(&export_path).is_absolute() {
385                tracing::warn!(
386                    path = %export_path,
387                    "metrics: APTU_CODER_METRICS_EXPORT_FILE must be an absolute path; skipping export"
388                );
389            } else {
390                let mut tool_calls = Vec::new();
391                let mut total_duration_ms = 0u64;
392                let mut total_output_chars_sum = 0u64;
393                // Sort by tool name for deterministic JSON output
394                let mut sorted_tools: Vec<_> = tool_counts.iter().collect();
395                sorted_tools.sort_by_key(|&(name, _)| name);
396                for (tool_name, metrics) in sorted_tools {
397                    tool_calls.push(serde_json::json!({
398                        "tool": tool_name,
399                        "call_count": metrics.count,
400                        "total_duration_ms": metrics.duration_ms,
401                        "total_output_chars": metrics.output_chars
402                    }));
403                    total_duration_ms += metrics.duration_ms;
404                    total_output_chars_sum += metrics.output_chars;
405                }
406                let summary = serde_json::json!({
407                    "session_id": export_session_id.unwrap_or_default(),
408                    "tool_calls": tool_calls,
409                    "total_duration_ms": total_duration_ms,
410                    "total_output_chars": total_output_chars_sum
411                });
412                if let Ok(json_str) = serde_json::to_string(&summary)
413                    && let Err(e) = tokio::fs::write(&export_path, json_str).await
414                {
415                    tracing::warn!(
416                        error = %e,
417                        path = %export_path,
418                        "metrics: failed to write export file"
419                    );
420                }
421            }
422        }
423    }
424}
425
426/// Returns the current UNIX timestamp in milliseconds.
427#[must_use]
428pub fn unix_ms() -> u64 {
429    SystemTime::now()
430        .duration_since(UNIX_EPOCH)
431        .unwrap_or_default()
432        .as_millis()
433        .try_into()
434        .unwrap_or(u64::MAX)
435}
436
437/// Counts the number of path segments in a file path.
438#[must_use]
439pub fn path_component_count(path: &str) -> usize {
440    Path::new(path).components().count()
441}
442
443/// Returns the lowercased file extension of `path` as a `&'static str`.
444///
445/// - Returns `Some(ext)` for extensions recognized by [`language_for_extension`] (e.g. `"rs"`).
446/// - Returns `Some("other")` for paths that have an extension but it is not in the known set.
447/// - Returns `None` for paths with no extension or an empty extension.
448#[must_use]
449pub fn path_file_ext(path: &str) -> Option<&'static str> {
450    let ext_os = Path::new(path).extension()?;
451    let ext_str = ext_os.to_str()?;
452    if ext_str.is_empty() {
453        return None;
454    }
455    // language_for_extension does case-insensitive lookup; if found, return the
456    // canonical (lowercased) extension key from EXTENSION_MAP via supported_extensions().
457    if language_for_extension(ext_str).is_some() {
458        aptu_coder_core::lang::supported_extensions()
459            .into_iter()
460            .find(|e| e.eq_ignore_ascii_case(ext_str))
461    } else {
462        Some("other")
463    }
464}
465
466/// Derive a human-readable language name from a file path.
467///
468/// - Returns `Some("Rust")` for paths with a recognized extension.
469/// - Returns `None` for paths with no extension or an unrecognized extension.
470#[must_use]
471pub fn path_language(path: &str) -> Option<String> {
472    let ext_os = Path::new(path).extension()?;
473    let ext_str = ext_os.to_str()?;
474    if ext_str.is_empty() {
475        return None;
476    }
477    language_for_extension(ext_str).map(std::borrow::ToOwned::to_owned)
478}
479
480fn xdg_metrics_dir() -> PathBuf {
481    if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
482        && !xdg_data_home.is_empty()
483    {
484        return PathBuf::from(xdg_data_home).join("aptu-coder");
485    }
486
487    if let Ok(home) = std::env::var("HOME") {
488        PathBuf::from(home)
489            .join(".local")
490            .join("share")
491            .join("aptu-coder")
492    } else {
493        PathBuf::from(".")
494    }
495}
496
497async fn cleanup_old_files(base_dir: &Path) {
498    let now_days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
499
500    let Ok(mut entries) = tokio::fs::read_dir(base_dir).await else {
501        return;
502    };
503
504    loop {
505        match entries.next_entry().await {
506            Ok(Some(entry)) => {
507                let path = entry.path();
508                let file_name = match path.file_name() {
509                    Some(n) => n.to_string_lossy().into_owned(),
510                    None => continue,
511                };
512
513                // Expected format: metrics-YYYY-MM-DD.jsonl
514                if !file_name.starts_with("metrics-")
515                    || std::path::Path::new(&*file_name)
516                        .extension()
517                        .is_none_or(|e| !e.eq_ignore_ascii_case("jsonl"))
518                {
519                    continue;
520                }
521                let date_part = &file_name[8..file_name.len() - 6];
522                if date_part.len() != 10
523                    || date_part.as_bytes().get(4) != Some(&b'-')
524                    || date_part.as_bytes().get(7) != Some(&b'-')
525                {
526                    continue;
527                }
528                let Ok(year) = date_part[0..4].parse::<u32>() else {
529                    continue;
530                };
531                let Ok(month) = date_part[5..7].parse::<u32>() else {
532                    continue;
533                };
534                let Ok(day) = date_part[8..10].parse::<u32>() else {
535                    continue;
536                };
537                if month == 0 || month > 12 || day == 0 || day > 31 {
538                    continue;
539                }
540
541                let file_days = date_to_days_since_epoch(year, month, day);
542                if now_days > file_days && (now_days - file_days) > 30 {
543                    let _ = tokio::fs::remove_file(&path).await;
544                }
545            }
546            Ok(None) => break,
547            Err(e) => {
548                tracing::warn!("error reading metrics directory entry: {e}");
549            }
550        }
551    }
552}
553
554fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
555    // Shift year so March is month 0
556    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
557    let era = y / 400;
558    let yoe = y - era * 400;
559    let doy = (153 * m + 2) / 5 + d - 1;
560    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
561    // Compute the proleptic Gregorian day number, then subtract the Unix epoch offset.
562    // The subtraction must wrap the full expression; applying .saturating_sub to `doe`
563    // alone would underflow for recent dates where doe < 719_468.
564    (era * 146_097 + doe).saturating_sub(719_468)
565}
566
567/// Returns the current UTC date as a string in YYYY-MM-DD format.
568#[must_use]
569pub fn current_date_str() -> String {
570    let days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
571    let z = days + 719_468;
572    let era = z / 146_097;
573    let doe = z - era * 146_097;
574    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
575    let y = yoe + era * 400;
576    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
577    let mp = (5 * doy + 2) / 153;
578    let d = doy - (153 * mp + 2) / 5 + 1;
579    let m = if mp < 10 { mp + 3 } else { mp - 9 };
580    let y = if m <= 2 { y + 1 } else { y };
581    format!("{y:04}-{m:02}-{d:02}")
582}
583
584/// Migrate legacy metrics directory from `code-analyze-mcp` to `aptu-coder`.
585///
586/// - If the old directory exists and the new one does not, rename it and log info.
587/// - If both exist, log a warning and do nothing.
588/// - If neither exists, do nothing.
589///
590/// Returns `Ok(())` on success, propagating any I/O errors.
591pub fn migrate_legacy_metrics_dir() -> std::io::Result<()> {
592    let home =
593        std::env::var("HOME").map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
594    migrate_legacy_metrics_dir_impl(&home)
595}
596
597#[allow(dead_code)]
598fn migrate_legacy_metrics_dir_impl(home: &str) -> std::io::Result<()> {
599    let old_dir = PathBuf::from(home).join(".local/share/code-analyze-mcp");
600    let new_dir = PathBuf::from(home).join(".local/share/aptu-coder");
601
602    let old_exists = old_dir.is_dir();
603    let new_exists = new_dir.is_dir();
604
605    if old_exists && !new_exists {
606        std::fs::rename(&old_dir, &new_dir)?;
607        tracing::info!(
608            "Migrated legacy metrics directory from {:?} to {:?}",
609            old_dir,
610            new_dir
611        );
612    } else if old_exists && new_exists {
613        tracing::warn!("Both legacy and new metrics directories exist; not migrating");
614    }
615    // If old does not exist, nothing to do.
616    Ok(())
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use std::fs;
623    use std::sync::{Mutex, OnceLock};
624    use tempfile::TempDir;
625
626    /// Serializes tests that mutate `APTU_CODER_METRICS_EXPORT_FILE` to prevent parallel
627    /// pollution. Recovers from poison caused by panicking tests.
628    fn metrics_export_lock() -> std::sync::MutexGuard<'static, ()> {
629        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
630        let m = LOCK.get_or_init(|| Mutex::new(()));
631        m.lock().unwrap_or_else(|e| e.into_inner())
632    }
633
634    #[test]
635    fn test_migrate_legacy_only_old_exists() {
636        // Arrange
637        let tmp_home = TempDir::new().unwrap();
638        let home_str = tmp_home.path().to_str().unwrap();
639        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
640        let new_path = tmp_home.path().join(".local/share/aptu-coder");
641        fs::create_dir_all(&old_path).unwrap();
642        assert!(!new_path.exists());
643
644        // Act
645        let result = migrate_legacy_metrics_dir_impl(home_str);
646
647        // Assert
648        assert!(result.is_ok());
649        assert!(!old_path.exists(), "old dir should be moved");
650        assert!(new_path.is_dir(), "new dir should exist");
651    }
652
653    #[test]
654    fn test_migrate_legacy_both_exist() {
655        // Arrange
656        let tmp_home = TempDir::new().unwrap();
657        let home_str = tmp_home.path().to_str().unwrap();
658        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
659        let new_path = tmp_home.path().join(".local/share/aptu-coder");
660        fs::create_dir_all(&old_path).unwrap();
661        fs::create_dir_all(&new_path).unwrap();
662
663        // Act
664        let result = migrate_legacy_metrics_dir_impl(home_str);
665
666        // Assert
667        assert!(result.is_ok());
668        assert!(old_path.is_dir(), "old dir should remain");
669        assert!(new_path.is_dir(), "new dir should remain");
670    }
671
672    #[test]
673    fn test_migrate_legacy_neither_exists() {
674        // Arrange
675        let tmp_home = TempDir::new().unwrap();
676        let home_str = tmp_home.path().to_str().unwrap();
677        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
678        let new_path = tmp_home.path().join(".local/share/aptu-coder");
679
680        // Act
681        let result = migrate_legacy_metrics_dir_impl(home_str);
682
683        // Assert
684        assert!(result.is_ok());
685        assert!(!old_path.exists(), "old dir should not exist");
686        assert!(!new_path.exists(), "new dir should not exist");
687    }
688
689    #[test]
690    fn test_date_to_days_since_epoch_known_dates() {
691        assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
692        assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
693        assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
694    }
695
696    #[test]
697    fn test_current_date_str_format() {
698        let s = current_date_str();
699        assert_eq!(s.len(), 10);
700        assert_eq!(s.as_bytes()[4], b'-');
701        assert_eq!(s.as_bytes()[7], b'-');
702        let year: u32 = s[0..4].parse().expect("year must be numeric");
703        assert!(year >= 2020 && year <= 2100);
704    }
705
706    #[tokio::test]
707    async fn test_metrics_writer_batching() {
708        let dir = TempDir::new().unwrap();
709        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
710        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
711        let make_event = || MetricEvent {
712            ts: unix_ms(),
713            tool: "analyze_directory",
714            duration_ms: 1,
715            output_chars: 10,
716            param_path_depth: 1,
717            max_depth: None,
718            result: "ok",
719            error_type: None,
720            error_subtype: None,
721            session_id: None,
722            seq: None,
723            cache_hit: None,
724            cache_write_failure: None,
725            exit_code: None,
726            timed_out: false,
727            cache_tier: None,
728            output_truncated: None,
729            chars_threshold_breach: false,
730            file_ext: None,
731            filter_applied: None,
732            language: None,
733        };
734        tx.send(make_event()).unwrap();
735        tx.send(make_event()).unwrap();
736        tx.send(make_event()).unwrap();
737        drop(tx);
738        writer.run().await;
739        let entries: Vec<_> = std::fs::read_dir(dir.path())
740            .unwrap()
741            .filter_map(|e| e.ok())
742            .filter(|e| {
743                e.path()
744                    .extension()
745                    .and_then(|x| x.to_str())
746                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
747                    .unwrap_or(false)
748            })
749            .collect();
750        assert_eq!(entries.len(), 1);
751        let content = std::fs::read_to_string(entries[0].path()).unwrap();
752        let lines: Vec<&str> = content.lines().collect();
753        assert_eq!(lines.len(), 3);
754    }
755
756    #[tokio::test]
757    async fn test_cleanup_old_files_deletes_old_keeps_recent() {
758        let dir = TempDir::new().unwrap();
759        let old_file = dir.path().join("metrics-1970-01-01.jsonl");
760        let today = current_date_str();
761        let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
762        std::fs::write(&old_file, "old\n").unwrap();
763        std::fs::write(&recent_file, "recent\n").unwrap();
764        cleanup_old_files(dir.path()).await;
765        assert!(!old_file.exists());
766        assert!(recent_file.exists());
767    }
768
769    #[test]
770    fn test_metric_event_serialization() {
771        let event = MetricEvent {
772            ts: 1_700_000_000_000,
773            tool: "analyze_directory",
774            duration_ms: 42,
775            output_chars: 100,
776            param_path_depth: 3,
777            max_depth: Some(2),
778            result: "ok",
779            error_type: None,
780            error_subtype: None,
781            session_id: None,
782            seq: None,
783            cache_hit: None,
784            cache_write_failure: None,
785            exit_code: None,
786            timed_out: false,
787            cache_tier: None,
788            output_truncated: None,
789            chars_threshold_breach: false,
790            file_ext: None,
791            filter_applied: None,
792            language: None,
793        };
794        let json = serde_json::to_string(&event).unwrap();
795        assert!(json.contains("analyze_directory"));
796        assert!(json.contains(r#""result":"ok""#));
797        assert!(json.contains(r#""output_chars":100"#));
798        // Verify error_subtype is omitted when None (backward compat)
799        assert!(!json.contains("error_subtype"));
800    }
801
802    #[test]
803    fn test_metric_event_serialization_error() {
804        let event = MetricEvent {
805            ts: 1_700_000_000_000,
806            tool: "analyze_directory",
807            duration_ms: 5,
808            output_chars: 0,
809            param_path_depth: 3,
810            max_depth: Some(3),
811            result: "error",
812            error_type: Some("invalid_params".to_string()),
813            error_subtype: None,
814            session_id: None,
815            seq: None,
816            cache_hit: None,
817            cache_write_failure: None,
818            exit_code: None,
819            timed_out: false,
820            cache_tier: None,
821            output_truncated: None,
822            chars_threshold_breach: false,
823            file_ext: None,
824            filter_applied: None,
825            language: None,
826        };
827        let json = serde_json::to_string(&event).unwrap();
828        assert!(json.contains(r#""result":"error""#));
829        assert!(json.contains(r#""error_type":"invalid_params""#));
830        assert!(json.contains(r#""output_chars":0"#));
831        // Verify error_subtype is omitted when None (backward compat)
832        assert!(!json.contains("error_subtype"));
833    }
834
835    #[test]
836    fn test_metric_event_error_subtype_some_serializes() {
837        let event = MetricEvent {
838            ts: 1_700_000_000_000,
839            tool: "edit_replace",
840            duration_ms: 10,
841            output_chars: 0,
842            param_path_depth: 2,
843            max_depth: None,
844            result: "error",
845            error_type: Some("invalid_params".to_string()),
846            error_subtype: Some("not_found".to_string()),
847            session_id: None,
848            seq: None,
849            cache_hit: None,
850            cache_write_failure: None,
851            exit_code: None,
852            timed_out: false,
853            cache_tier: None,
854            output_truncated: None,
855            chars_threshold_breach: false,
856            file_ext: None,
857            filter_applied: None,
858            language: None,
859        };
860        let json = serde_json::to_string(&event).unwrap();
861        assert!(json.contains(r#""error_subtype":"not_found""#));
862    }
863
864    #[test]
865    fn test_metric_event_error_subtype_ambiguous() {
866        let event = MetricEvent {
867            ts: 1_700_000_000_000,
868            tool: "edit_replace",
869            duration_ms: 10,
870            output_chars: 0,
871            param_path_depth: 2,
872            max_depth: None,
873            result: "error",
874            error_type: Some("invalid_params".to_string()),
875            error_subtype: Some("ambiguous".to_string()),
876            session_id: None,
877            seq: None,
878            cache_hit: None,
879            cache_write_failure: None,
880            exit_code: None,
881            timed_out: false,
882            cache_tier: None,
883            output_truncated: None,
884            chars_threshold_breach: false,
885            file_ext: None,
886            filter_applied: None,
887            language: None,
888        };
889        let json = serde_json::to_string(&event).unwrap();
890        assert!(json.contains(r#""error_subtype":"ambiguous""#));
891    }
892
893    #[test]
894    fn test_metric_event_new_fields_round_trip() {
895        let event = MetricEvent {
896            ts: 1_700_000_000_000,
897            tool: "analyze_file",
898            duration_ms: 100,
899            output_chars: 500,
900            param_path_depth: 2,
901            max_depth: Some(3),
902            result: "ok",
903            error_type: None,
904            error_subtype: None,
905            session_id: Some("1742468880123-42".to_string()),
906            seq: Some(5),
907            cache_hit: None,
908            cache_write_failure: None,
909            exit_code: None,
910            timed_out: false,
911            cache_tier: None,
912            output_truncated: None,
913            chars_threshold_breach: false,
914            file_ext: None,
915            filter_applied: None,
916            language: None,
917        };
918        let serialized = serde_json::to_string(&event).unwrap();
919        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}"#;
920        assert_eq!(serialized, json_str);
921    }
922
923    #[test]
924    fn test_path_file_ext_known() {
925        // Arrange / Act / Assert: known extension returns the lowercased extension key
926        assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
927    }
928
929    #[test]
930    fn test_path_file_ext_unknown() {
931        // Arrange / Act / Assert: unrecognized extension returns Some("other")
932        assert_eq!(path_file_ext("file.xyz"), Some("other"));
933    }
934
935    #[test]
936    fn test_path_file_ext_no_ext() {
937        // Arrange / Act / Assert: path with no extension returns None
938        assert_eq!(path_file_ext("Makefile"), None);
939    }
940
941    #[test]
942    fn test_path_file_ext_case_insensitive() {
943        // Arrange / Act / Assert: uppercase extension is normalized to lowercase key
944        assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
945    }
946
947    #[test]
948    fn test_path_file_ext_multi_dot() {
949        // Arrange / Act / Assert: multi-dot filename uses the last extension
950        assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
951    }
952
953    #[test]
954    fn test_path_language_known_ext() {
955        // Arrange / Act / Assert: known extension returns Some(language name)
956        assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
957    }
958
959    #[test]
960    fn test_path_language_unknown_ext() {
961        // Arrange / Act / Assert: unknown extension returns None
962        assert_eq!(path_language("file.xyz"), None);
963    }
964
965    #[test]
966    fn test_path_language_no_ext() {
967        // Arrange / Act / Assert: path without extension returns None
968        assert_eq!(path_language("Makefile"), None);
969    }
970
971    #[tokio::test]
972    async fn test_metrics_export_file_created() {
973        let _guard = metrics_export_lock();
974        // Arrange: create temp dir and set export env var
975        let dir = TempDir::new().unwrap();
976        let export_file = dir.path().join("metrics_export.json");
977        let export_path_str = export_file.to_string_lossy().to_string();
978
979        unsafe {
980            std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", &export_path_str);
981        }
982
983        // Create metrics writer and send events
984        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
985        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
986
987        // Act: send a few events with session_id
988        tx.send(MetricEvent {
989            ts: unix_ms(),
990            tool: "analyze_directory",
991            duration_ms: 100,
992            output_chars: 50,
993            param_path_depth: 1,
994            max_depth: None,
995            result: "ok",
996            error_type: None,
997            error_subtype: None,
998            session_id: Some("test-session-123".to_string()),
999            seq: None,
1000            cache_hit: None,
1001            cache_write_failure: None,
1002            exit_code: None,
1003            timed_out: false,
1004            cache_tier: None,
1005            output_truncated: None,
1006            chars_threshold_breach: false,
1007            file_ext: None,
1008            filter_applied: None,
1009            language: None,
1010        })
1011        .unwrap();
1012        tx.send(MetricEvent {
1013            ts: unix_ms(),
1014            tool: "analyze_file",
1015            duration_ms: 50,
1016            output_chars: 100,
1017            param_path_depth: 2,
1018            max_depth: Some(3),
1019            result: "ok",
1020            error_type: None,
1021            error_subtype: None,
1022            session_id: Some("test-session-123".to_string()),
1023            seq: None,
1024            cache_hit: None,
1025            cache_write_failure: None,
1026            exit_code: None,
1027            timed_out: false,
1028            cache_tier: None,
1029            output_truncated: None,
1030            chars_threshold_breach: false,
1031            file_ext: None,
1032            filter_applied: None,
1033            language: None,
1034        })
1035        .unwrap();
1036        drop(tx);
1037        writer.run().await;
1038
1039        // Assert: export file should exist with correct JSON structure
1040        assert!(
1041            export_file.exists(),
1042            "export file should be created at {:?}",
1043            export_file
1044        );
1045        let content = std::fs::read_to_string(&export_file).unwrap();
1046        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1047
1048        assert_eq!(
1049            json["session_id"], "test-session-123",
1050            "export should contain correct session_id"
1051        );
1052        assert!(
1053            json["tool_calls"].is_array(),
1054            "export should contain tool_calls array"
1055        );
1056        let tool_calls = json["tool_calls"].as_array().unwrap();
1057        assert_eq!(tool_calls.len(), 2, "should have 2 tool calls");
1058        assert!(
1059            json["total_duration_ms"].is_number(),
1060            "export should contain total_duration_ms"
1061        );
1062        assert_eq!(
1063            json["total_duration_ms"], 150,
1064            "total_duration_ms should be sum of all durations"
1065        );
1066        assert_eq!(
1067            json["tool_calls"][0]["total_output_chars"], 50,
1068            "first tool call should have total_output_chars=50"
1069        );
1070        assert_eq!(
1071            json["tool_calls"][1]["total_output_chars"], 100,
1072            "second tool call should have total_output_chars=100"
1073        );
1074        assert_eq!(
1075            json["total_output_chars"], 150,
1076            "total_output_chars should be sum of all output_chars"
1077        );
1078
1079        // Cleanup
1080        unsafe {
1081            std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1082        }
1083    }
1084
1085    #[tokio::test]
1086    async fn test_metrics_export_env_var_unset() {
1087        let _guard = metrics_export_lock();
1088        // Arrange: ensure env var is not set
1089        unsafe {
1090            std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1091        }
1092        let dir = TempDir::new().unwrap();
1093        // Use a unique marker to ensure we don't pick up files from other tests
1094        let marker = "metrics_export_unset_test";
1095
1096        // Create metrics writer and send events
1097        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1098        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1099
1100        // Act: send events and run writer
1101        tx.send(MetricEvent {
1102            ts: unix_ms(),
1103            tool: "analyze_directory",
1104            duration_ms: 100,
1105            output_chars: 50,
1106            param_path_depth: 1,
1107            max_depth: None,
1108            result: "ok",
1109            error_type: None,
1110            error_subtype: None,
1111            session_id: Some("test-session-456".to_string()),
1112            seq: None,
1113            cache_hit: None,
1114            cache_write_failure: None,
1115            exit_code: None,
1116            timed_out: false,
1117            cache_tier: None,
1118            output_truncated: None,
1119            chars_threshold_breach: false,
1120            file_ext: None,
1121            filter_applied: None,
1122            language: None,
1123        })
1124        .unwrap();
1125        drop(tx);
1126        writer.run().await;
1127
1128        // Assert: no export file should be created
1129        let entries: Vec<_> = std::fs::read_dir(dir.path())
1130            .unwrap()
1131            .filter_map(|e| e.ok())
1132            .filter(|e| {
1133                e.path()
1134                    .file_name()
1135                    .and_then(|n| n.to_str())
1136                    .map(|n| n.contains(marker))
1137                    .unwrap_or(false)
1138            })
1139            .collect();
1140        assert_eq!(
1141            entries.len(),
1142            0,
1143            "no export file should be created when env var is unset"
1144        );
1145    }
1146
1147    #[tokio::test]
1148    async fn test_metrics_export_relative_path_rejected() {
1149        let _guard = metrics_export_lock();
1150        // Arrange: set export env var to a relative path
1151        let relative_path = "relative/path/metrics.json";
1152        unsafe {
1153            std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", relative_path);
1154        }
1155
1156        let dir = TempDir::new().unwrap();
1157        // Use a unique marker to ensure we don't pick up files from other tests
1158        let marker = "metrics_export_relative_test";
1159
1160        // Create metrics writer and send events
1161        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1162        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1163
1164        // Act: send events and run writer
1165        tx.send(MetricEvent {
1166            ts: unix_ms(),
1167            tool: "analyze_directory",
1168            duration_ms: 100,
1169            output_chars: 50,
1170            param_path_depth: 1,
1171            max_depth: None,
1172            result: "ok",
1173            error_type: None,
1174            error_subtype: None,
1175            session_id: Some(marker.to_string()),
1176            seq: None,
1177            cache_hit: None,
1178            cache_write_failure: None,
1179            exit_code: None,
1180            timed_out: false,
1181            cache_tier: None,
1182            output_truncated: None,
1183            chars_threshold_breach: false,
1184            file_ext: None,
1185            filter_applied: None,
1186            language: None,
1187        })
1188        .unwrap();
1189        drop(tx);
1190        writer.run().await;
1191
1192        // Assert: no export file should be created for relative path
1193        let entries: Vec<_> = std::fs::read_dir(dir.path())
1194            .unwrap()
1195            .filter_map(|e| e.ok())
1196            .filter(|e| {
1197                e.path()
1198                    .file_name()
1199                    .and_then(|n| n.to_str())
1200                    .map(|n| n.contains("metrics.json"))
1201                    .unwrap_or(false)
1202            })
1203            .collect();
1204        assert_eq!(
1205            entries.len(),
1206            0,
1207            "no export file should be created for relative path"
1208        );
1209
1210        // Cleanup
1211        unsafe {
1212            std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1213        }
1214    }
1215}
1216
1217/// Record a metric event to OTel metrics if the global meter provider is available.
1218///
1219/// Records:
1220/// - Histogram: mcp.server.operation.duration (in milliseconds)
1221/// - Counter: mcp.server.tool.calls (incremented by 1)
1222///
1223/// Labels: gen_ai.tool.name, error.type (or "none" if no error)
1224///
1225/// Instruments are initialized once via OnceLock to avoid rebuilding them on every call.
1226fn record_otel_metrics(event: &MetricEvent) {
1227    // Skip OTEL recording for "received" events (duration_ms=0 would pollute latency histograms)
1228    if event.result == "received" {
1229        return;
1230    }
1231    use opentelemetry::metrics::{Counter, Histogram};
1232    use opentelemetry::{KeyValue, global};
1233    use std::sync::OnceLock;
1234
1235    static DURATION_HISTOGRAM: OnceLock<Histogram<f64>> = OnceLock::new();
1236    static CALL_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
1237    static CACHE_HITS_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
1238    static CACHE_WRITE_FAILURES_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
1239
1240    let histogram = DURATION_HISTOGRAM.get_or_init(|| {
1241        global::meter("aptu-coder")
1242            .f64_histogram("mcp.server.operation.duration")
1243            .with_unit("s")
1244            .with_boundaries(vec![
1245                0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
1246            ])
1247            .build()
1248    });
1249
1250    let counter = CALL_COUNTER.get_or_init(|| {
1251        global::meter("aptu-coder")
1252            .u64_counter("mcp.server.tool.calls")
1253            .build()
1254    });
1255
1256    let cache_hits_counter = CACHE_HITS_COUNTER.get_or_init(|| {
1257        global::meter("aptu-coder")
1258            .u64_counter("mcp.server.tool.cache_hits_total")
1259            .with_description("Number of tool responses served from cache (l1_memory or l2_disk)")
1260            .build()
1261    });
1262
1263    let cache_write_failures_counter = CACHE_WRITE_FAILURES_COUNTER.get_or_init(|| {
1264        global::meter("aptu-coder")
1265            .u64_counter("mcp.server.tool.cache_write_failures_total")
1266            .with_description(
1267                "Number of L2 disk cache write failures (dir, tempfile, write, rename)",
1268            )
1269            .build()
1270    });
1271
1272    let error_type = event.error_type.as_deref().unwrap_or("success");
1273    let attributes = [
1274        KeyValue::new("gen_ai.tool.name", event.tool.to_string()),
1275        KeyValue::new("error.type", error_type.to_string()),
1276        KeyValue::new("mcp.method.name", "tools/call"),
1277        KeyValue::new("mcp.protocol.version", "2025-11-25"),
1278        KeyValue::new("network.transport", "pipe"),
1279    ];
1280
1281    histogram.record(event.duration_ms as f64 / 1000.0, &attributes);
1282    counter.add(1, &attributes);
1283
1284    if event.cache_hit == Some(true) {
1285        let tier = event.cache_tier.unwrap_or("unknown");
1286        cache_hits_counter.add(
1287            1,
1288            &[
1289                KeyValue::new("gen_ai.tool.name", event.tool.to_string()),
1290                KeyValue::new("cache_tier", tier.to_string()),
1291            ],
1292        );
1293    }
1294
1295    if event.cache_write_failure == Some(true) {
1296        cache_write_failures_counter.add(
1297            1,
1298            &[KeyValue::new("gen_ai.tool.name", event.tool.to_string())],
1299        );
1300    }
1301}