1use 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#[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 #[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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
55 pub chars_threshold_breach: bool,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub file_ext: Option<&'static str>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub filter_applied: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub language: Option<String>,
71}
72#[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#[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
232pub struct MetricsWriter {
234 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
235 base_dir: PathBuf,
236 dir_created: bool,
237}
238
239#[derive(Default, Debug)]
241struct ToolMetrics {
242 count: u64,
243 duration_ms: u64,
244 output_chars: u64,
245}
246
247#[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 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 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 async fn flush_batch(file: &mut tokio::fs::File, path: &Path, batch: Vec<MetricEvent>) {
288 let _lock_guard = Self::acquire_metrics_lock(path).await;
290
291 for event in batch {
292 record_otel_metrics(&event);
294
295 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 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 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 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 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 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 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 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 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#[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#[must_use]
513pub(crate) fn path_component_count(path: &str) -> usize {
514 Path::new(path).components().count()
515}
516
517#[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 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#[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 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 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 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 (era * 146_097 + doe).saturating_sub(719_468)
642}
643
644#[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
661pub 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 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 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 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 let result = migrate_legacy_metrics_dir_impl(home_str);
723
724 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 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 let result = migrate_legacy_metrics_dir_impl(home_str);
742
743 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 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 let result = migrate_legacy_metrics_dir_impl(home_str);
759
760 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 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 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 assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
1004 }
1005
1006 #[test]
1007 fn test_path_file_ext_unknown() {
1008 assert_eq!(path_file_ext("file.xyz"), Some("other"));
1010 }
1011
1012 #[test]
1013 fn test_path_file_ext_no_ext() {
1014 assert_eq!(path_file_ext("Makefile"), None);
1016 }
1017
1018 #[test]
1019 fn test_path_file_ext_case_insensitive() {
1020 assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
1022 }
1023
1024 #[test]
1025 fn test_path_file_ext_multi_dot() {
1026 assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
1028 }
1029
1030 #[test]
1031 fn test_path_language_known_ext() {
1032 assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
1034 }
1035
1036 #[test]
1037 fn test_path_language_unknown_ext() {
1038 assert_eq!(path_language("file.xyz"), None);
1040 }
1041
1042 #[test]
1043 fn test_path_language_no_ext() {
1044 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 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 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1062 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1063
1064 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!(
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 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 unsafe {
1167 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1168 }
1169 let dir = TempDir::new().unwrap();
1170 let marker = "metrics_export_unset_test";
1172
1173 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1175 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1176
1177 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 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 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 let marker = "metrics_export_relative_test";
1236
1237 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1239 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1240
1241 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 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 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 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 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 let dir = TempDir::new().unwrap();
1351 let base = dir.path().to_path_buf();
1352
1353 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 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 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 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
1416fn record_otel_metrics(event: &MetricEvent) {
1426 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}