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