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 #[allow(clippy::expect_used)]
365 current_file
366 .as_ref()
367 .expect("current_file is guaranteed Some after check above")
369 .clone()
370 }
371
372 async fn receive_batch(
374 rx: &mut tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
375 tool_counts: &mut std::collections::HashMap<&'static str, ToolMetrics>,
376 export_session_id: &mut Option<String>,
377 ) -> Option<Vec<MetricEvent>> {
378 let mut batch = Vec::new();
379 if let Some(event) = rx.recv().await {
380 Self::accumulate_event(tool_counts, export_session_id, &event);
381 batch.push(event);
382 for _ in 0..99 {
383 match rx.try_recv() {
384 Ok(e) => {
385 Self::accumulate_event(tool_counts, export_session_id, &e);
386 batch.push(e);
387 }
388 Err(
389 mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected,
390 ) => break,
391 }
392 }
393 Some(batch)
394 } else {
395 None
396 }
397 }
398
399 async fn ensure_metrics_dir(path: &std::path::Path, dir_created: &mut bool) {
401 if !*dir_created
402 && let Some(parent) = path.parent()
403 && !parent.as_os_str().is_empty()
404 {
405 match tokio::fs::create_dir_all(parent).await {
406 Ok(()) => {
407 *dir_created = true;
408 }
409 Err(e) => {
410 tracing::warn!(
411 error = %e,
412 path = %parent.display(),
413 "metrics: failed to create directory; will retry next batch"
414 );
415 }
416 }
417 }
418 }
419
420 pub async fn run(mut self) {
421 cleanup_old_files(&self.base_dir).await;
422 let mut current_date = current_date_str();
423 let mut current_file: Option<PathBuf> = None;
424
425 let mut tool_counts: std::collections::HashMap<&'static str, ToolMetrics> =
427 std::collections::HashMap::new();
428 let mut export_session_id: Option<String> = None;
429
430 loop {
431 let Some(batch) =
432 Self::receive_batch(&mut self.rx, &mut tool_counts, &mut export_session_id).await
433 else {
434 break;
435 };
436
437 let path = Self::rotate_metrics_file(
438 &self.base_dir,
439 &mut current_date,
440 &mut current_file,
441 &mut self.dir_created,
442 );
443
444 Self::ensure_metrics_dir(&path, &mut self.dir_created).await;
445
446 let file = tokio::fs::OpenOptions::new()
448 .create(true)
449 .append(true)
450 .open(&path)
451 .await;
452
453 if let Ok(mut file) = file {
454 Self::flush_batch(&mut file, &path, batch).await;
455 }
456 }
457
458 if let Ok(export_path) = std::env::var("APTU_CODER_METRICS_EXPORT_FILE") {
460 if !std::path::Path::new(&export_path).is_absolute() {
461 tracing::warn!(
462 path = %export_path,
463 "metrics: APTU_CODER_METRICS_EXPORT_FILE must be an absolute path; skipping export"
464 );
465 } else {
466 let mut tool_calls = Vec::new();
467 let mut total_duration_ms = 0u64;
468 let mut total_output_chars_sum = 0u64;
469 let mut sorted_tools: Vec<_> = tool_counts.iter().collect();
471 sorted_tools.sort_by_key(|&(name, _)| name);
472 for (tool_name, metrics) in sorted_tools {
473 tool_calls.push(serde_json::json!({
474 "tool": tool_name,
475 "call_count": metrics.count,
476 "total_duration_ms": metrics.duration_ms,
477 "total_output_chars": metrics.output_chars
478 }));
479 total_duration_ms += metrics.duration_ms;
480 total_output_chars_sum += metrics.output_chars;
481 }
482 let summary = serde_json::json!({
483 "session_id": export_session_id.unwrap_or_default(),
484 "tool_calls": tool_calls,
485 "total_duration_ms": total_duration_ms,
486 "total_output_chars": total_output_chars_sum
487 });
488 if let Ok(json_str) = serde_json::to_string(&summary)
489 && let Err(e) = tokio::fs::write(&export_path, json_str).await
490 {
491 tracing::warn!(
492 error = %e,
493 path = %export_path,
494 "metrics: failed to write export file"
495 );
496 }
497 }
498 }
499 }
500}
501
502#[must_use]
504pub(crate) fn unix_ms() -> u64 {
505 SystemTime::now()
506 .duration_since(UNIX_EPOCH)
507 .unwrap_or_default()
508 .as_millis()
509 .try_into()
510 .unwrap_or(u64::MAX)
511}
512
513#[must_use]
515pub(crate) fn path_component_count(path: &str) -> usize {
516 Path::new(path).components().count()
517}
518
519#[must_use]
525pub(crate) fn path_file_ext(path: &str) -> Option<&'static str> {
526 let ext_os = Path::new(path).extension()?;
527 let ext_str = ext_os.to_str()?;
528 if ext_str.is_empty() {
529 return None;
530 }
531 if language_for_extension(ext_str).is_some() {
534 aptu_coder_core::lang::supported_extensions()
535 .into_iter()
536 .find(|e| e.eq_ignore_ascii_case(ext_str))
537 } else {
538 Some("other")
539 }
540}
541
542#[must_use]
547pub(crate) fn path_language(path: &str) -> Option<String> {
548 let ext_os = Path::new(path).extension()?;
549 let ext_str = ext_os.to_str()?;
550 if ext_str.is_empty() {
551 return None;
552 }
553 language_for_extension(ext_str).map(std::borrow::ToOwned::to_owned)
554}
555
556fn xdg_metrics_dir() -> PathBuf {
557 if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
558 && !xdg_data_home.is_empty()
559 {
560 return PathBuf::from(xdg_data_home).join("aptu-coder");
561 }
562
563 if let Ok(home) = std::env::var("HOME") {
564 PathBuf::from(home)
565 .join(".local")
566 .join("share")
567 .join("aptu-coder")
568 } else {
569 PathBuf::from(".")
570 }
571}
572
573async fn cleanup_old_files(base_dir: &Path) {
574 let now_days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
575
576 let Ok(mut entries) = tokio::fs::read_dir(base_dir).await else {
577 return;
578 };
579
580 loop {
581 match entries.next_entry().await {
582 Ok(Some(entry)) => {
583 let path = entry.path();
584 let file_name = match path.file_name() {
585 Some(n) => n.to_string_lossy().into_owned(),
586 None => continue,
587 };
588
589 if !file_name.starts_with("metrics-")
591 || std::path::Path::new(&*file_name)
592 .extension()
593 .is_none_or(|e| !e.eq_ignore_ascii_case("jsonl"))
594 {
595 continue;
596 }
597 let date_part = &file_name[8..file_name.len() - 6];
598 if date_part.len() != 10
599 || date_part.as_bytes().get(4) != Some(&b'-')
600 || date_part.as_bytes().get(7) != Some(&b'-')
601 {
602 continue;
603 }
604 let Ok(year) = date_part[0..4].parse::<u32>() else {
605 continue;
606 };
607 let Ok(month) = date_part[5..7].parse::<u32>() else {
608 continue;
609 };
610 let Ok(day) = date_part[8..10].parse::<u32>() else {
611 continue;
612 };
613 if month == 0 || month > 12 || day == 0 || day > 31 {
614 continue;
615 }
616
617 let file_days = date_to_days_since_epoch(year, month, day);
618 if now_days > file_days && (now_days - file_days) > 30 {
619 let _ = tokio::fs::remove_file(&path).await;
620 let lock_path = format!("{}.lock", path.display());
622 let _ = tokio::fs::remove_file(&lock_path).await;
623 }
624 }
625 Ok(None) => break,
626 Err(e) => {
627 tracing::warn!("error reading metrics directory entry: {e}");
628 }
629 }
630 }
631}
632
633fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
634 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
636 let era = y / 400;
637 let yoe = y - era * 400;
638 let doy = (153 * m + 2) / 5 + d - 1;
639 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
640 (era * 146_097 + doe).saturating_sub(719_468)
644}
645
646#[must_use]
648pub(crate) fn current_date_str() -> String {
649 let days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
650 let z = days + 719_468;
651 let era = z / 146_097;
652 let doe = z - era * 146_097;
653 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
654 let y = yoe + era * 400;
655 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
656 let mp = (5 * doy + 2) / 153;
657 let d = doy - (153 * mp + 2) / 5 + 1;
658 let m = if mp < 10 { mp + 3 } else { mp - 9 };
659 let y = if m <= 2 { y + 1 } else { y };
660 format!("{y:04}-{m:02}-{d:02}")
661}
662
663pub fn migrate_legacy_metrics_dir() -> std::io::Result<()> {
671 let home =
672 std::env::var("HOME").map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
673 migrate_legacy_metrics_dir_impl(&home)
674}
675
676#[allow(dead_code)]
677fn migrate_legacy_metrics_dir_impl(home: &str) -> std::io::Result<()> {
678 let old_dir = PathBuf::from(home).join(".local/share/code-analyze-mcp");
679 let new_dir = PathBuf::from(home).join(".local/share/aptu-coder");
680
681 let old_exists = old_dir.is_dir();
682 let new_exists = new_dir.is_dir();
683
684 if old_exists && !new_exists {
685 std::fs::rename(&old_dir, &new_dir)?;
686 tracing::info!(
687 "Migrated legacy metrics directory from {:?} to {:?}",
688 old_dir,
689 new_dir
690 );
691 } else if old_exists && new_exists {
692 tracing::warn!("Both legacy and new metrics directories exist; not migrating");
693 }
694 Ok(())
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use std::fs;
702 use std::sync::{Mutex, OnceLock};
703 use tempfile::TempDir;
704
705 fn metrics_export_lock() -> std::sync::MutexGuard<'static, ()> {
708 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
709 let m = LOCK.get_or_init(|| Mutex::new(()));
710 m.lock().unwrap_or_else(|e| e.into_inner())
711 }
712
713 #[test]
714 fn test_migrate_legacy_only_old_exists() {
715 let tmp_home = TempDir::new().unwrap();
717 let home_str = tmp_home.path().to_str().unwrap();
718 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
719 let new_path = tmp_home.path().join(".local/share/aptu-coder");
720 fs::create_dir_all(&old_path).unwrap();
721 assert!(!new_path.exists());
722
723 let result = migrate_legacy_metrics_dir_impl(home_str);
725
726 assert!(result.is_ok());
728 assert!(!old_path.exists(), "old dir should be moved");
729 assert!(new_path.is_dir(), "new dir should exist");
730 }
731
732 #[test]
733 fn test_migrate_legacy_both_exist() {
734 let tmp_home = TempDir::new().unwrap();
736 let home_str = tmp_home.path().to_str().unwrap();
737 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
738 let new_path = tmp_home.path().join(".local/share/aptu-coder");
739 fs::create_dir_all(&old_path).unwrap();
740 fs::create_dir_all(&new_path).unwrap();
741
742 let result = migrate_legacy_metrics_dir_impl(home_str);
744
745 assert!(result.is_ok());
747 assert!(old_path.is_dir(), "old dir should remain");
748 assert!(new_path.is_dir(), "new dir should remain");
749 }
750
751 #[test]
752 fn test_migrate_legacy_neither_exists() {
753 let tmp_home = TempDir::new().unwrap();
755 let home_str = tmp_home.path().to_str().unwrap();
756 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
757 let new_path = tmp_home.path().join(".local/share/aptu-coder");
758
759 let result = migrate_legacy_metrics_dir_impl(home_str);
761
762 assert!(result.is_ok());
764 assert!(!old_path.exists(), "old dir should not exist");
765 assert!(!new_path.exists(), "new dir should not exist");
766 }
767
768 #[test]
769 fn test_date_to_days_since_epoch_known_dates() {
770 assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
771 assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
772 assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
773 }
774
775 #[test]
776 fn test_current_date_str_format() {
777 let s = current_date_str();
778 assert_eq!(s.len(), 10);
779 assert_eq!(s.as_bytes()[4], b'-');
780 assert_eq!(s.as_bytes()[7], b'-');
781 let year: u32 = s[0..4].parse().expect("year must be numeric");
782 assert!(year >= 2020 && year <= 2100);
783 }
784
785 #[tokio::test]
786 async fn test_metrics_writer_batching() {
787 let dir = TempDir::new().unwrap();
788 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
789 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
790 let make_event = || MetricEvent {
791 ts: unix_ms(),
792 tool: "analyze_directory",
793 duration_ms: 1,
794 output_chars: 10,
795 param_path_depth: 1,
796 max_depth: None,
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 tx.send(make_event()).unwrap();
814 tx.send(make_event()).unwrap();
815 tx.send(make_event()).unwrap();
816 drop(tx);
817 writer.run().await;
818 let entries: Vec<_> = std::fs::read_dir(dir.path())
819 .unwrap()
820 .filter_map(|e| e.ok())
821 .filter(|e| {
822 e.path()
823 .extension()
824 .and_then(|x| x.to_str())
825 .map(|x| x.eq_ignore_ascii_case("jsonl"))
826 .unwrap_or(false)
827 })
828 .collect();
829 assert_eq!(entries.len(), 1);
830 let content = std::fs::read_to_string(entries[0].path()).unwrap();
831 let lines: Vec<&str> = content.lines().collect();
832 assert_eq!(lines.len(), 3);
833 }
834
835 #[tokio::test]
836 async fn test_cleanup_old_files_deletes_old_keeps_recent() {
837 let dir = TempDir::new().unwrap();
838 let old_file = dir.path().join("metrics-1970-01-01.jsonl");
839 let today = current_date_str();
840 let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
841 std::fs::write(&old_file, "old\n").unwrap();
842 std::fs::write(&recent_file, "recent\n").unwrap();
843 cleanup_old_files(dir.path()).await;
844 assert!(!old_file.exists());
845 assert!(recent_file.exists());
846 }
847
848 #[test]
849 fn test_metric_event_serialization() {
850 let event = MetricEvent {
851 ts: 1_700_000_000_000,
852 tool: "analyze_directory",
853 duration_ms: 42,
854 output_chars: 100,
855 param_path_depth: 3,
856 max_depth: Some(2),
857 result: "ok",
858 error_type: None,
859 error_subtype: None,
860 session_id: None,
861 seq: None,
862 cache_hit: None,
863 cache_write_failure: None,
864 exit_code: None,
865 timed_out: false,
866 cache_tier: None,
867 output_truncated: None,
868 chars_threshold_breach: false,
869 file_ext: None,
870 filter_applied: None,
871 language: None,
872 };
873 let json = serde_json::to_string(&event).unwrap();
874 assert!(json.contains("analyze_directory"));
875 assert!(json.contains(r#""result":"ok""#));
876 assert!(json.contains(r#""output_chars":100"#));
877 assert!(!json.contains("error_subtype"));
879 }
880
881 #[test]
882 fn test_metric_event_serialization_error() {
883 let event = MetricEvent {
884 ts: 1_700_000_000_000,
885 tool: "analyze_directory",
886 duration_ms: 5,
887 output_chars: 0,
888 param_path_depth: 3,
889 max_depth: Some(3),
890 result: "error",
891 error_type: Some("invalid_params".to_string()),
892 error_subtype: None,
893 session_id: None,
894 seq: None,
895 cache_hit: None,
896 cache_write_failure: None,
897 exit_code: None,
898 timed_out: false,
899 cache_tier: None,
900 output_truncated: None,
901 chars_threshold_breach: false,
902 file_ext: None,
903 filter_applied: None,
904 language: None,
905 };
906 let json = serde_json::to_string(&event).unwrap();
907 assert!(json.contains(r#""result":"error""#));
908 assert!(json.contains(r#""error_type":"invalid_params""#));
909 assert!(json.contains(r#""output_chars":0"#));
910 assert!(!json.contains("error_subtype"));
912 }
913
914 #[test]
915 fn test_metric_event_error_subtype_some_serializes() {
916 let event = MetricEvent {
917 ts: 1_700_000_000_000,
918 tool: "edit_replace",
919 duration_ms: 10,
920 output_chars: 0,
921 param_path_depth: 2,
922 max_depth: None,
923 result: "error",
924 error_type: Some("invalid_params".to_string()),
925 error_subtype: Some("not_found".to_string()),
926 session_id: None,
927 seq: None,
928 cache_hit: None,
929 cache_write_failure: None,
930 exit_code: None,
931 timed_out: false,
932 cache_tier: None,
933 output_truncated: None,
934 chars_threshold_breach: false,
935 file_ext: None,
936 filter_applied: None,
937 language: None,
938 };
939 let json = serde_json::to_string(&event).unwrap();
940 assert!(json.contains(r#""error_subtype":"not_found""#));
941 }
942
943 #[test]
944 fn test_metric_event_error_subtype_ambiguous() {
945 let event = MetricEvent {
946 ts: 1_700_000_000_000,
947 tool: "edit_replace",
948 duration_ms: 10,
949 output_chars: 0,
950 param_path_depth: 2,
951 max_depth: None,
952 result: "error",
953 error_type: Some("invalid_params".to_string()),
954 error_subtype: Some("ambiguous".to_string()),
955 session_id: None,
956 seq: None,
957 cache_hit: None,
958 cache_write_failure: None,
959 exit_code: None,
960 timed_out: false,
961 cache_tier: None,
962 output_truncated: None,
963 chars_threshold_breach: false,
964 file_ext: None,
965 filter_applied: None,
966 language: None,
967 };
968 let json = serde_json::to_string(&event).unwrap();
969 assert!(json.contains(r#""error_subtype":"ambiguous""#));
970 }
971
972 #[test]
973 fn test_metric_event_new_fields_round_trip() {
974 let event = MetricEvent {
975 ts: 1_700_000_000_000,
976 tool: "analyze_file",
977 duration_ms: 100,
978 output_chars: 500,
979 param_path_depth: 2,
980 max_depth: Some(3),
981 result: "ok",
982 error_type: None,
983 error_subtype: None,
984 session_id: Some("1742468880123-42".to_string()),
985 seq: Some(5),
986 cache_hit: None,
987 cache_write_failure: None,
988 exit_code: None,
989 timed_out: false,
990 cache_tier: None,
991 output_truncated: None,
992 chars_threshold_breach: false,
993 file_ext: None,
994 filter_applied: None,
995 language: None,
996 };
997 let serialized = serde_json::to_string(&event).unwrap();
998 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}"#;
999 assert_eq!(serialized, json_str);
1000 }
1001
1002 #[test]
1003 fn test_path_file_ext_known() {
1004 assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
1006 }
1007
1008 #[test]
1009 fn test_path_file_ext_unknown() {
1010 assert_eq!(path_file_ext("file.xyz"), Some("other"));
1012 }
1013
1014 #[test]
1015 fn test_path_file_ext_no_ext() {
1016 assert_eq!(path_file_ext("Makefile"), None);
1018 }
1019
1020 #[test]
1021 fn test_path_file_ext_case_insensitive() {
1022 assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
1024 }
1025
1026 #[test]
1027 fn test_path_file_ext_multi_dot() {
1028 assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
1030 }
1031
1032 #[test]
1033 fn test_path_language_known_ext() {
1034 assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
1036 }
1037
1038 #[test]
1039 fn test_path_language_unknown_ext() {
1040 assert_eq!(path_language("file.xyz"), None);
1042 }
1043
1044 #[test]
1045 fn test_path_language_no_ext() {
1046 assert_eq!(path_language("Makefile"), None);
1048 }
1049
1050 #[tokio::test]
1051 async fn test_metrics_export_file_created() {
1052 let _guard = metrics_export_lock();
1053 let dir = TempDir::new().unwrap();
1055 let export_file = dir.path().join("metrics_export.json");
1056 let export_path_str = export_file.to_string_lossy().to_string();
1057
1058 unsafe {
1059 std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", &export_path_str);
1060 }
1061
1062 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1064 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1065
1066 tx.send(MetricEvent {
1068 ts: unix_ms(),
1069 tool: "analyze_directory",
1070 duration_ms: 100,
1071 output_chars: 50,
1072 param_path_depth: 1,
1073 max_depth: None,
1074 result: "ok",
1075 error_type: None,
1076 error_subtype: None,
1077 session_id: Some("test-session-123".to_string()),
1078 seq: None,
1079 cache_hit: None,
1080 cache_write_failure: None,
1081 exit_code: None,
1082 timed_out: false,
1083 cache_tier: None,
1084 output_truncated: None,
1085 chars_threshold_breach: false,
1086 file_ext: None,
1087 filter_applied: None,
1088 language: None,
1089 })
1090 .unwrap();
1091 tx.send(MetricEvent {
1092 ts: unix_ms(),
1093 tool: "analyze_file",
1094 duration_ms: 50,
1095 output_chars: 100,
1096 param_path_depth: 2,
1097 max_depth: Some(3),
1098 result: "ok",
1099 error_type: None,
1100 error_subtype: None,
1101 session_id: Some("test-session-123".to_string()),
1102 seq: None,
1103 cache_hit: None,
1104 cache_write_failure: None,
1105 exit_code: None,
1106 timed_out: false,
1107 cache_tier: None,
1108 output_truncated: None,
1109 chars_threshold_breach: false,
1110 file_ext: None,
1111 filter_applied: None,
1112 language: None,
1113 })
1114 .unwrap();
1115 drop(tx);
1116 writer.run().await;
1117
1118 assert!(
1120 export_file.exists(),
1121 "export file should be created at {:?}",
1122 export_file
1123 );
1124 let content = std::fs::read_to_string(&export_file).unwrap();
1125 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1126
1127 assert_eq!(
1128 json["session_id"], "test-session-123",
1129 "export should contain correct session_id"
1130 );
1131 assert!(
1132 json["tool_calls"].is_array(),
1133 "export should contain tool_calls array"
1134 );
1135 let tool_calls = json["tool_calls"].as_array().unwrap();
1136 assert_eq!(tool_calls.len(), 2, "should have 2 tool calls");
1137 assert!(
1138 json["total_duration_ms"].is_number(),
1139 "export should contain total_duration_ms"
1140 );
1141 assert_eq!(
1142 json["total_duration_ms"], 150,
1143 "total_duration_ms should be sum of all durations"
1144 );
1145 assert_eq!(
1146 json["tool_calls"][0]["total_output_chars"], 50,
1147 "first tool call should have total_output_chars=50"
1148 );
1149 assert_eq!(
1150 json["tool_calls"][1]["total_output_chars"], 100,
1151 "second tool call should have total_output_chars=100"
1152 );
1153 assert_eq!(
1154 json["total_output_chars"], 150,
1155 "total_output_chars should be sum of all output_chars"
1156 );
1157
1158 unsafe {
1160 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1161 }
1162 }
1163
1164 #[tokio::test]
1165 async fn test_metrics_export_env_var_unset() {
1166 let _guard = metrics_export_lock();
1167 unsafe {
1169 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1170 }
1171 let dir = TempDir::new().unwrap();
1172 let marker = "metrics_export_unset_test";
1174
1175 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1177 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1178
1179 tx.send(MetricEvent {
1181 ts: unix_ms(),
1182 tool: "analyze_directory",
1183 duration_ms: 100,
1184 output_chars: 50,
1185 param_path_depth: 1,
1186 max_depth: None,
1187 result: "ok",
1188 error_type: None,
1189 error_subtype: None,
1190 session_id: Some("test-session-456".to_string()),
1191 seq: None,
1192 cache_hit: None,
1193 cache_write_failure: None,
1194 exit_code: None,
1195 timed_out: false,
1196 cache_tier: None,
1197 output_truncated: None,
1198 chars_threshold_breach: false,
1199 file_ext: None,
1200 filter_applied: None,
1201 language: None,
1202 })
1203 .unwrap();
1204 drop(tx);
1205 writer.run().await;
1206
1207 let entries: Vec<_> = std::fs::read_dir(dir.path())
1209 .unwrap()
1210 .filter_map(|e| e.ok())
1211 .filter(|e| {
1212 e.path()
1213 .file_name()
1214 .and_then(|n| n.to_str())
1215 .map(|n| n.contains(marker))
1216 .unwrap_or(false)
1217 })
1218 .collect();
1219 assert_eq!(
1220 entries.len(),
1221 0,
1222 "no export file should be created when env var is unset"
1223 );
1224 }
1225
1226 #[tokio::test]
1227 async fn test_metrics_export_relative_path_rejected() {
1228 let _guard = metrics_export_lock();
1229 let relative_path = "relative/path/metrics.json";
1231 unsafe {
1232 std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", relative_path);
1233 }
1234
1235 let dir = TempDir::new().unwrap();
1236 let marker = "metrics_export_relative_test";
1238
1239 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1241 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1242
1243 tx.send(MetricEvent {
1245 ts: unix_ms(),
1246 tool: "analyze_directory",
1247 duration_ms: 100,
1248 output_chars: 50,
1249 param_path_depth: 1,
1250 max_depth: None,
1251 result: "ok",
1252 error_type: None,
1253 error_subtype: None,
1254 session_id: Some(marker.to_string()),
1255 seq: None,
1256 cache_hit: None,
1257 cache_write_failure: None,
1258 exit_code: None,
1259 timed_out: false,
1260 cache_tier: None,
1261 output_truncated: None,
1262 chars_threshold_breach: false,
1263 file_ext: None,
1264 filter_applied: None,
1265 language: None,
1266 })
1267 .unwrap();
1268 drop(tx);
1269 writer.run().await;
1270
1271 let entries: Vec<_> = std::fs::read_dir(dir.path())
1273 .unwrap()
1274 .filter_map(|e| e.ok())
1275 .filter(|e| {
1276 e.path()
1277 .file_name()
1278 .and_then(|n| n.to_str())
1279 .map(|n| n.contains("metrics.json"))
1280 .unwrap_or(false)
1281 })
1282 .collect();
1283 assert_eq!(
1284 entries.len(),
1285 0,
1286 "no export file should be created for relative path"
1287 );
1288
1289 unsafe {
1291 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
1292 }
1293 }
1294
1295 #[tokio::test]
1296 async fn test_lock_file_created() {
1297 let dir = TempDir::new().unwrap();
1299 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1300 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
1301 let make_event = || MetricEvent {
1302 ts: unix_ms(),
1303 tool: "analyze_directory",
1304 duration_ms: 1,
1305 output_chars: 10,
1306 param_path_depth: 1,
1307 max_depth: None,
1308 result: "ok",
1309 error_type: None,
1310 error_subtype: None,
1311 session_id: None,
1312 seq: None,
1313 cache_hit: None,
1314 cache_write_failure: None,
1315 exit_code: None,
1316 timed_out: false,
1317 cache_tier: None,
1318 output_truncated: None,
1319 chars_threshold_breach: false,
1320 file_ext: None,
1321 filter_applied: None,
1322 language: None,
1323 };
1324 tx.send(make_event()).unwrap();
1325 drop(tx);
1326 writer.run().await;
1327
1328 let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
1330 .unwrap()
1331 .filter_map(|e| e.ok())
1332 .filter(|e| {
1333 e.path()
1334 .extension()
1335 .and_then(|x| x.to_str())
1336 .map(|x| x.eq_ignore_ascii_case("jsonl"))
1337 .unwrap_or(false)
1338 })
1339 .collect();
1340 assert_eq!(jsonl_entries.len(), 1);
1341 let lock_path = format!("{}.lock", jsonl_entries[0].path().display());
1342 assert!(
1343 std::path::Path::new(&lock_path).exists(),
1344 "lock file must exist next to JSONL file"
1345 );
1346 }
1347
1348 #[tokio::test]
1349 async fn test_flush_batch_concurrent_writes() {
1350 let dir = TempDir::new().unwrap();
1353 let base = dir.path().to_path_buf();
1354
1355 let (tx1, rx1) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1357 let writer1 = MetricsWriter::new(rx1, Some(base.clone()));
1358 let make_event = || MetricEvent {
1359 ts: unix_ms(),
1360 tool: "analyze_directory",
1361 duration_ms: 1,
1362 output_chars: 10,
1363 param_path_depth: 1,
1364 max_depth: None,
1365 result: "ok",
1366 error_type: None,
1367 error_subtype: None,
1368 session_id: None,
1369 seq: None,
1370 cache_hit: None,
1371 cache_write_failure: None,
1372 exit_code: None,
1373 timed_out: false,
1374 cache_tier: None,
1375 output_truncated: None,
1376 chars_threshold_breach: false,
1377 file_ext: None,
1378 filter_applied: None,
1379 language: None,
1380 };
1381 tx1.send(make_event()).unwrap();
1382 tx1.send(make_event()).unwrap();
1383 drop(tx1);
1384
1385 let (tx2, rx2) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
1387 let writer2 = MetricsWriter::new(rx2, Some(base));
1388 tx2.send(make_event()).unwrap();
1389 tx2.send(make_event()).unwrap();
1390 drop(tx2);
1391
1392 let h1 = tokio::spawn(writer1.run());
1394 let h2 = tokio::spawn(writer2.run());
1395 let (r1, r2) = tokio::join!(h1, h2);
1396 r1.unwrap();
1397 r2.unwrap();
1398
1399 let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
1401 .unwrap()
1402 .filter_map(|e| e.ok())
1403 .filter(|e| {
1404 e.path()
1405 .extension()
1406 .and_then(|x| x.to_str())
1407 .map(|x| x.eq_ignore_ascii_case("jsonl"))
1408 .unwrap_or(false)
1409 })
1410 .collect();
1411 assert_eq!(jsonl_entries.len(), 1);
1412 let content = std::fs::read_to_string(jsonl_entries[0].path()).unwrap();
1413 let lines: Vec<&str> = content.lines().collect();
1414 assert_eq!(lines.len(), 4, "expected 4 JSONL lines from 2 writers");
1415 }
1416}
1417
1418fn record_otel_metrics(event: &MetricEvent) {
1428 if event.result == "received" {
1430 return;
1431 }
1432 use opentelemetry::metrics::{Counter, Histogram};
1433 use opentelemetry::{KeyValue, global};
1434 use std::sync::OnceLock;
1435
1436 static DURATION_HISTOGRAM: OnceLock<Histogram<f64>> = OnceLock::new();
1437 static CALL_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
1438 static CACHE_HITS_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
1439 static CACHE_WRITE_FAILURES_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
1440
1441 let histogram = DURATION_HISTOGRAM.get_or_init(|| {
1442 global::meter("aptu-coder")
1443 .f64_histogram("mcp.server.operation.duration")
1444 .with_unit("s")
1445 .with_boundaries(vec![
1446 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,
1447 ])
1448 .build()
1449 });
1450
1451 let counter = CALL_COUNTER.get_or_init(|| {
1452 global::meter("aptu-coder")
1453 .u64_counter("mcp.server.tool.calls")
1454 .build()
1455 });
1456
1457 let cache_hits_counter = CACHE_HITS_COUNTER.get_or_init(|| {
1458 global::meter("aptu-coder")
1459 .u64_counter("mcp.server.tool.cache_hits_total")
1460 .with_description("Number of tool responses served from cache (l1_memory or l2_disk)")
1461 .build()
1462 });
1463
1464 let cache_write_failures_counter = CACHE_WRITE_FAILURES_COUNTER.get_or_init(|| {
1465 global::meter("aptu-coder")
1466 .u64_counter("mcp.server.tool.cache_write_failures_total")
1467 .with_description(
1468 "Number of L2 disk cache write failures (dir, tempfile, write, rename)",
1469 )
1470 .build()
1471 });
1472
1473 let error_type = event.error_type.as_deref().unwrap_or("success");
1474 let attributes = [
1475 KeyValue::new("gen_ai.tool.name", event.tool),
1476 KeyValue::new("error.type", error_type.to_string()),
1477 KeyValue::new("mcp.method.name", "tools/call"),
1478 KeyValue::new("mcp.protocol.version", "2025-11-25"),
1479 KeyValue::new("network.transport", "pipe"),
1480 ];
1481
1482 histogram.record(event.duration_ms as f64 / 1000.0, &attributes);
1483 counter.add(1, &attributes);
1484
1485 if event.cache_hit == Some(true) {
1486 let tier = event.cache_tier.unwrap_or("unknown");
1487 cache_hits_counter.add(
1488 1,
1489 &[
1490 KeyValue::new("gen_ai.tool.name", event.tool),
1491 KeyValue::new("cache_tier", tier),
1492 ],
1493 );
1494 }
1495
1496 if event.cache_write_failure == Some(true) {
1497 cache_write_failures_counter.add(1, &[KeyValue::new("gen_ai.tool.name", event.tool)]);
1498 }
1499}