1use 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#[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 #[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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
54 pub chars_threshold_breach: bool,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub file_ext: Option<&'static str>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub filter_applied: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub language: Option<String>,
70}
71#[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#[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
231pub struct MetricsWriter {
233 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
234 base_dir: PathBuf,
235 dir_created: bool,
236}
237
238#[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 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 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 async fn flush_batch(file: &mut tokio::fs::File, batch: Vec<MetricEvent>) {
277 for event in batch {
278 record_otel_metrics(&event);
280
281 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 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 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 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 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 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 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 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#[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#[must_use]
458pub(crate) fn path_component_count(path: &str) -> usize {
459 Path::new(path).components().count()
460}
461
462#[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 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#[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 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 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 (era * 146_097 + doe).saturating_sub(719_468)
584}
585
586#[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
603pub 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 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 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 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 let result = migrate_legacy_metrics_dir_impl(home_str);
665
666 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 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 let result = migrate_legacy_metrics_dir_impl(home_str);
684
685 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 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 let result = migrate_legacy_metrics_dir_impl(home_str);
701
702 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 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 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 assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
946 }
947
948 #[test]
949 fn test_path_file_ext_unknown() {
950 assert_eq!(path_file_ext("file.xyz"), Some("other"));
952 }
953
954 #[test]
955 fn test_path_file_ext_no_ext() {
956 assert_eq!(path_file_ext("Makefile"), None);
958 }
959
960 #[test]
961 fn test_path_file_ext_case_insensitive() {
962 assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
964 }
965
966 #[test]
967 fn test_path_file_ext_multi_dot() {
968 assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
970 }
971
972 #[test]
973 fn test_path_language_known_ext() {
974 assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
976 }
977
978 #[test]
979 fn test_path_language_unknown_ext() {
980 assert_eq!(path_language("file.xyz"), None);
982 }
983
984 #[test]
985 fn test_path_language_no_ext() {
986 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 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 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1004 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1005
1006 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!(
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 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 unsafe {
1109 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1110 }
1111 let dir = TempDir::new().unwrap();
1112 let marker = "metrics_export_unset_test";
1114
1115 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1117 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1118
1119 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 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 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 let marker = "metrics_export_relative_test";
1178
1179 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1181 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1182
1183 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 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 unsafe {
1231 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1232 }
1233 }
1234}
1235
1236fn record_otel_metrics(event: &MetricEvent) {
1246 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}