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