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