1use crate::metrics::{MetricEvent, MetricsLockGuard, ToolMetrics, record_otel_metrics};
11use aptu_coder_core::lang::language_for_extension;
12use fs2::FileExt;
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15use tokio::io::AsyncWriteExt;
16use tokio::sync::mpsc;
17
18pub struct MetricsWriter {
20 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
21 base_dir: PathBuf,
22 dir_created: bool,
23}
24
25impl MetricsWriter {
26 pub fn new(
27 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
28 base_dir: Option<PathBuf>,
29 ) -> Self {
30 let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
31 Self {
32 rx,
33 base_dir: dir,
34 dir_created: false,
35 }
36 }
37
38 fn accumulate_event(
40 tool_counts: &mut std::collections::HashMap<&'static str, ToolMetrics>,
41 export_session_id: &mut Option<String>,
42 event: &MetricEvent,
43 ) {
44 let entry = tool_counts.entry(event.tool).or_default();
45 entry.count += 1;
46 entry.duration_ms += event.duration_ms;
47 entry.output_chars += event.output_chars as u64;
49 if export_session_id.is_none() {
50 *export_session_id = event.session_id.clone();
51 }
52 }
53
54 async fn flush_batch(file: &mut tokio::fs::File, path: &Path, batch: Vec<MetricEvent>) {
60 let _lock_guard = Self::acquire_metrics_lock(path).await;
62
63 for event in batch {
64 record_otel_metrics(&event);
66
67 if let Ok(mut json) = serde_json::to_string(&event) {
69 json.push('\n');
70 let _ = file.write_all(json.as_bytes()).await;
71 }
72 }
73 let _ = file.flush().await;
74 }
75
76 async fn acquire_metrics_lock(path: &Path) -> Option<MetricsLockGuard> {
80 let lock_path = format!("{}.lock", path.display());
81 let file = match std::fs::OpenOptions::new()
82 .create(true)
83 .write(true)
84 .truncate(false)
85 .open(&lock_path)
86 {
87 Ok(f) => f,
88 Err(e) => {
89 tracing::warn!(
90 error = %e,
91 lock_path = %lock_path,
92 "metrics: failed to open lock file; proceeding without lock"
93 );
94 return None;
95 }
96 };
97 let result = tokio::task::spawn_blocking(move || file.lock_exclusive().map(|_| file)).await;
98 match result {
99 Ok(Ok(locked)) => Some(MetricsLockGuard(locked)),
100 Ok(Err(e)) => {
101 tracing::warn!(
102 error = %e,
103 "metrics: failed to acquire exclusive lock; proceeding without lock"
104 );
105 None
106 }
107 Err(e) => {
108 tracing::warn!(
109 error = %e,
110 "metrics: spawn_blocking panicked acquiring lock; proceeding without lock"
111 );
112 None
113 }
114 }
115 }
116
117 fn rotate_metrics_file(
120 base_dir: &std::path::Path,
121 current_date: &mut String,
122 current_file: &mut Option<PathBuf>,
123 dir_created: &mut bool,
124 ) -> PathBuf {
125 let new_date = current_date_str();
126 if new_date != *current_date {
127 *current_date = new_date;
128 *current_file = None;
129 *dir_created = false;
130 }
131
132 current_file
133 .get_or_insert_with(|| base_dir.join(format!("metrics-{}.jsonl", current_date)))
134 .clone()
135 }
136
137 async fn receive_batch(
139 rx: &mut tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
140 tool_counts: &mut std::collections::HashMap<&'static str, ToolMetrics>,
141 export_session_id: &mut Option<String>,
142 ) -> Option<Vec<MetricEvent>> {
143 let mut batch = Vec::new();
144 if let Some(event) = rx.recv().await {
145 Self::accumulate_event(tool_counts, export_session_id, &event);
146 batch.push(event);
147 for _ in 0..99 {
148 match rx.try_recv() {
149 Ok(e) => {
150 Self::accumulate_event(tool_counts, export_session_id, &e);
151 batch.push(e);
152 }
153 Err(
154 mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected,
155 ) => break,
156 }
157 }
158 Some(batch)
159 } else {
160 None
161 }
162 }
163
164 async fn ensure_metrics_dir(path: &std::path::Path, dir_created: &mut bool) {
166 if !*dir_created
167 && let Some(parent) = path.parent()
168 && !parent.as_os_str().is_empty()
169 {
170 match tokio::fs::create_dir_all(parent).await {
171 Ok(()) => {
172 *dir_created = true;
173 }
174 Err(e) => {
175 tracing::warn!(
176 error = %e,
177 path = %parent.display(),
178 "metrics: failed to create directory; will retry next batch"
179 );
180 }
181 }
182 }
183 }
184
185 pub async fn run(mut self) {
186 cleanup_old_files(&self.base_dir).await;
187 let mut current_date = current_date_str();
188 let mut current_file: Option<PathBuf> = None;
189
190 let mut tool_counts: std::collections::HashMap<&'static str, ToolMetrics> =
192 std::collections::HashMap::new();
193 let mut export_session_id: Option<String> = None;
194
195 loop {
196 let Some(batch) =
197 Self::receive_batch(&mut self.rx, &mut tool_counts, &mut export_session_id).await
198 else {
199 break;
200 };
201
202 let path = Self::rotate_metrics_file(
203 &self.base_dir,
204 &mut current_date,
205 &mut current_file,
206 &mut self.dir_created,
207 );
208
209 Self::ensure_metrics_dir(&path, &mut self.dir_created).await;
210
211 let file = tokio::fs::OpenOptions::new()
213 .create(true)
214 .append(true)
215 .open(&path)
216 .await;
217
218 if let Ok(mut file) = file {
219 Self::flush_batch(&mut file, &path, batch).await;
220 }
221 }
222
223 if let Ok(export_path) = std::env::var("APTU_CODER_METRICS_EXPORT_FILE") {
225 if !std::path::Path::new(&export_path).is_absolute() {
226 tracing::warn!(
227 path = %export_path,
228 "metrics: APTU_CODER_METRICS_EXPORT_FILE must be an absolute path; skipping export"
229 );
230 } else {
231 let mut tool_calls = Vec::new();
232 let mut total_duration_ms = 0u64;
233 let mut total_output_chars_sum = 0u64;
234 let mut sorted_tools: Vec<_> = tool_counts.iter().collect();
236 sorted_tools.sort_by_key(|&(name, _)| name);
237 for (tool_name, metrics) in sorted_tools {
238 tool_calls.push(serde_json::json!({
239 "tool": tool_name,
240 "call_count": metrics.count,
241 "total_duration_ms": metrics.duration_ms,
242 "total_output_chars": metrics.output_chars
243 }));
244 total_duration_ms += metrics.duration_ms;
245 total_output_chars_sum += metrics.output_chars;
246 }
247 let summary = serde_json::json!({
248 "session_id": export_session_id.unwrap_or_default(),
249 "tool_calls": tool_calls,
250 "total_duration_ms": total_duration_ms,
251 "total_output_chars": total_output_chars_sum
252 });
253 if let Ok(json_str) = serde_json::to_string(&summary)
254 && let Err(e) = tokio::fs::write(&export_path, json_str).await
255 {
256 tracing::warn!(
257 error = %e,
258 path = %export_path,
259 "metrics: failed to write export file"
260 );
261 }
262 }
263 }
264 }
265}
266
267#[must_use]
269pub(crate) fn unix_ms() -> u64 {
270 SystemTime::now()
271 .duration_since(UNIX_EPOCH)
272 .unwrap_or_default()
273 .as_millis()
274 .try_into()
275 .unwrap_or(u64::MAX)
276}
277
278#[must_use]
280pub(crate) fn path_component_count(path: &str) -> usize {
281 Path::new(path).components().count()
282}
283
284#[must_use]
290pub(crate) fn path_file_ext(file_path: &str) -> Option<&'static str> {
291 let ext_os = Path::new(file_path).extension()?;
292 let ext_str = ext_os.to_str()?;
293 if ext_str.is_empty() {
294 return None;
295 }
296 if language_for_extension(ext_str).is_some() {
299 aptu_coder_core::lang::supported_extensions()
300 .into_iter()
301 .find(|e| e.eq_ignore_ascii_case(ext_str))
302 } else {
303 Some("other")
304 }
305}
306
307#[must_use]
312pub(crate) fn path_language(path: &str) -> Option<String> {
313 let ext_os = Path::new(path).extension()?;
314 let ext_str = ext_os.to_str()?;
315 if ext_str.is_empty() {
316 return None;
317 }
318 language_for_extension(ext_str).map(std::borrow::ToOwned::to_owned)
319}
320
321fn xdg_metrics_dir() -> PathBuf {
322 if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
323 && !xdg_data_home.is_empty()
324 {
325 return PathBuf::from(xdg_data_home).join("aptu-coder");
326 }
327
328 if let Ok(home) = std::env::var("HOME") {
329 PathBuf::from(home)
330 .join(".local")
331 .join("share")
332 .join("aptu-coder")
333 } else {
334 PathBuf::from(".")
335 }
336}
337
338async fn cleanup_old_files(base_dir: &Path) {
339 let now_days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
340
341 let Ok(mut entries) = tokio::fs::read_dir(base_dir).await else {
342 return;
343 };
344
345 loop {
346 match entries.next_entry().await {
347 Ok(Some(entry)) => {
348 let path = entry.path();
349 let file_name = match path.file_name() {
350 Some(n) => n.to_string_lossy().into_owned(),
351 None => continue,
352 };
353
354 if !file_name.starts_with("metrics-")
356 || std::path::Path::new(&*file_name)
357 .extension()
358 .is_none_or(|e| !e.eq_ignore_ascii_case("jsonl"))
359 {
360 continue;
361 }
362 let date_part = &file_name[8..file_name.len() - 6];
363 if date_part.len() != 10
364 || date_part.as_bytes().get(4) != Some(&b'-')
365 || date_part.as_bytes().get(7) != Some(&b'-')
366 {
367 continue;
368 }
369 let Ok(year) = date_part[0..4].parse::<u32>() else {
370 continue;
371 };
372 let Ok(month) = date_part[5..7].parse::<u32>() else {
373 continue;
374 };
375 let Ok(day) = date_part[8..10].parse::<u32>() else {
376 continue;
377 };
378 if month == 0 || month > 12 || day == 0 || day > 31 {
379 continue;
380 }
381
382 let file_days = date_to_days_since_epoch(year, month, day);
383 if now_days > file_days && (now_days - file_days) > 30 {
384 let _ = tokio::fs::remove_file(&path).await;
385 let lock_path = format!("{}.lock", path.display());
387 let _ = tokio::fs::remove_file(&lock_path).await;
388 }
389 }
390 Ok(None) => break,
391 Err(e) => {
392 tracing::warn!("error reading metrics directory entry: {e}");
393 }
394 }
395 }
396}
397
398fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
399 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
401 let era = y / 400;
402 let yoe = y - era * 400;
403 let doy = (153 * m + 2) / 5 + d - 1;
404 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
405 (era * 146_097 + doe).saturating_sub(719_468)
409}
410
411#[must_use]
413pub(crate) fn current_date_str() -> String {
414 let days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
415 let z = days + 719_468;
416 let era = z / 146_097;
417 let doe = z - era * 146_097;
418 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
419 let y = yoe + era * 400;
420 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
421 let mp = (5 * doy + 2) / 153;
422 let d = doy - (153 * mp + 2) / 5 + 1;
423 let m = if mp < 10 { mp + 3 } else { mp - 9 };
424 let y = if m <= 2 { y + 1 } else { y };
425 format!("{y:04}-{m:02}-{d:02}")
426}
427
428pub fn migrate_legacy_metrics_dir() -> std::io::Result<()> {
436 let home =
437 std::env::var("HOME").map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
438 migrate_legacy_metrics_dir_impl(&home)
439}
440
441#[allow(dead_code)]
442fn migrate_legacy_metrics_dir_impl(home: &str) -> std::io::Result<()> {
443 let old_dir = PathBuf::from(home).join(".local/share/code-analyze-mcp");
444 let new_dir = PathBuf::from(home).join(".local/share/aptu-coder");
445
446 let old_exists = old_dir.is_dir();
447 let new_exists = new_dir.is_dir();
448
449 if old_exists && !new_exists {
450 std::fs::rename(&old_dir, &new_dir)?;
451 tracing::info!(
452 "Migrated legacy metrics directory from {:?} to {:?}",
453 old_dir,
454 new_dir
455 );
456 } else if old_exists && new_exists {
457 tracing::warn!("Both legacy and new metrics directories exist; not migrating");
458 }
459 Ok(())
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use std::fs;
467 use std::sync::{Mutex, OnceLock};
468 use tempfile::TempDir;
469
470 fn metrics_export_lock() -> std::sync::MutexGuard<'static, ()> {
473 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
474 let m = LOCK.get_or_init(|| Mutex::new(()));
475 m.lock().unwrap_or_else(|e| e.into_inner())
476 }
477
478 #[test]
479 fn test_migrate_legacy_only_old_exists() {
480 let tmp_home = TempDir::new().unwrap();
482 let home_str = tmp_home.path().to_str().unwrap();
483 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
484 let new_path = tmp_home.path().join(".local/share/aptu-coder");
485 fs::create_dir_all(&old_path).unwrap();
486 assert!(!new_path.exists());
487
488 let result = migrate_legacy_metrics_dir_impl(home_str);
490
491 assert!(result.is_ok());
493 assert!(!old_path.exists(), "old dir should be moved");
494 assert!(new_path.is_dir(), "new dir should exist");
495 }
496
497 #[test]
498 fn test_migrate_legacy_both_exist() {
499 let tmp_home = TempDir::new().unwrap();
501 let home_str = tmp_home.path().to_str().unwrap();
502 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
503 let new_path = tmp_home.path().join(".local/share/aptu-coder");
504 fs::create_dir_all(&old_path).unwrap();
505 fs::create_dir_all(&new_path).unwrap();
506
507 let result = migrate_legacy_metrics_dir_impl(home_str);
509
510 assert!(result.is_ok());
512 assert!(old_path.is_dir(), "old dir should remain");
513 assert!(new_path.is_dir(), "new dir should remain");
514 }
515
516 #[test]
517 fn test_migrate_legacy_neither_exists() {
518 let tmp_home = TempDir::new().unwrap();
520 let home_str = tmp_home.path().to_str().unwrap();
521 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
522 let new_path = tmp_home.path().join(".local/share/aptu-coder");
523
524 let result = migrate_legacy_metrics_dir_impl(home_str);
526
527 assert!(result.is_ok());
529 assert!(!old_path.exists(), "old dir should not exist");
530 assert!(!new_path.exists(), "new dir should not exist");
531 }
532
533 #[test]
534 fn test_date_to_days_since_epoch_known_dates() {
535 assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
536 assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
537 assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
538 }
539
540 #[test]
541 fn test_date_to_days_since_epoch_edge_cases() {
542 assert_eq!(date_to_days_since_epoch(1970, 12, 31), 364);
544 assert_eq!(date_to_days_since_epoch(1971, 1, 1), 365);
545 assert_eq!(date_to_days_since_epoch(2023, 12, 31), 19_722);
546 assert_eq!(date_to_days_since_epoch(2024, 1, 1), 19_723);
547
548 assert_eq!(date_to_days_since_epoch(2023, 1, 31), 19_388);
550 assert_eq!(date_to_days_since_epoch(2023, 2, 1), 19_389);
551
552 assert_eq!(date_to_days_since_epoch(2024, 2, 28), 19_781);
554 assert_eq!(date_to_days_since_epoch(2024, 2, 29), 19_782);
555 assert_eq!(date_to_days_since_epoch(2024, 3, 1), 19_783);
556
557 assert_eq!(date_to_days_since_epoch(2000, 2, 28), 11_015);
559 assert_eq!(date_to_days_since_epoch(2000, 3, 1), 11_017);
560
561 assert_eq!(date_to_days_since_epoch(2100, 2, 28), 47_540);
563 assert_eq!(date_to_days_since_epoch(2100, 3, 1), 47_541);
564 }
565
566 #[test]
567 fn test_current_date_str_roundtrip() {
568 let s = current_date_str();
570 let year: u32 = s[0..4].parse().expect("year numeric");
571 let month: u32 = s[5..7].parse().expect("month numeric");
572 let day: u32 = s[8..10].parse().expect("day numeric");
573 let from_str = date_to_days_since_epoch(year, month, day);
574 let from_unix = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
575 assert_eq!(from_str, from_unix);
576 }
577
578 #[test]
579 fn test_current_date_str_format() {
580 let s = current_date_str();
581 assert_eq!(s.len(), 10);
582 assert_eq!(s.as_bytes()[4], b'-');
583 assert_eq!(s.as_bytes()[7], b'-');
584 let year: u32 = s[0..4].parse().expect("year must be numeric");
585 assert!(year >= 2020 && year <= 2100);
586 }
587
588 #[tokio::test]
589 async fn test_metrics_writer_batching() {
590 let _guard = metrics_export_lock();
591 let dir = TempDir::new().unwrap();
592 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
593 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
594 let make_event = || MetricEvent {
595 ts: unix_ms(),
596 tool: "analyze_directory",
597 duration_ms: 1,
598 output_chars: 10,
599 param_path_depth: 1,
600 max_depth: None,
601 result: "ok",
602 error_type: None,
603 error_subtype: None,
604 session_id: None,
605 seq: None,
606 cache_hit: None,
607 cache_write_failure: None,
608 exit_code: None,
609 timed_out: false,
610 cache_tier: None,
611 output_truncated: None,
612 chars_threshold_breach: false,
613 file_ext: None,
614 ..Default::default()
615 };
616 tx.send(make_event()).unwrap();
617 tx.send(make_event()).unwrap();
618 tx.send(make_event()).unwrap();
619 drop(tx);
620 writer.run().await;
621 let entries: Vec<_> = std::fs::read_dir(dir.path())
622 .unwrap()
623 .filter_map(|e| e.ok())
624 .filter(|e| {
625 e.path()
626 .extension()
627 .and_then(|x| x.to_str())
628 .map(|x| x.eq_ignore_ascii_case("jsonl"))
629 .unwrap_or(false)
630 })
631 .collect();
632 assert_eq!(entries.len(), 1);
633 let content = std::fs::read_to_string(entries[0].path()).unwrap();
634 let lines: Vec<&str> = content.lines().collect();
635 assert_eq!(lines.len(), 3);
636 }
637
638 #[tokio::test]
639 async fn test_cleanup_old_files_deletes_old_keeps_recent() {
640 let _guard = metrics_export_lock();
641 let dir = TempDir::new().unwrap();
642 let old_file = dir.path().join("metrics-1970-01-01.jsonl");
643 let today = current_date_str();
644 let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
645 std::fs::write(&old_file, "old\n").unwrap();
646 std::fs::write(&recent_file, "recent\n").unwrap();
647 cleanup_old_files(dir.path()).await;
648 assert!(!old_file.exists());
649 assert!(recent_file.exists());
650 }
651
652 #[test]
653 fn test_path_file_ext_known() {
654 assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
656 }
657
658 #[test]
659 fn test_path_file_ext_unknown() {
660 assert_eq!(path_file_ext("file.xyz"), Some("other"));
662 }
663
664 #[test]
665 fn test_path_file_ext_no_ext() {
666 assert_eq!(path_file_ext("Makefile"), None);
668 }
669
670 #[test]
671 fn test_path_file_ext_case_insensitive() {
672 assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
674 }
675
676 #[test]
677 fn test_path_file_ext_multi_dot() {
678 assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
680 }
681
682 #[test]
683 fn test_path_language_known_ext() {
684 assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
686 }
687
688 #[test]
689 fn test_path_language_unknown_ext() {
690 assert_eq!(path_language("file.xyz"), None);
692 }
693
694 #[test]
695 fn test_path_language_no_ext() {
696 assert_eq!(path_language("Makefile"), None);
698 }
699
700 #[tokio::test]
701 async fn test_metrics_export_file_created() {
702 let _guard = metrics_export_lock();
703 let dir = TempDir::new().unwrap();
705 let export_file = dir.path().join("metrics_export.json");
706 let export_path = export_file.to_str().unwrap().to_string();
707 unsafe {
708 std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", &export_path);
709 }
710
711 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
713 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
714
715 let make_event = || MetricEvent {
716 ts: unix_ms(),
717 tool: "analyze_directory",
718 duration_ms: 1,
719 output_chars: 10,
720 param_path_depth: 1,
721 max_depth: None,
722 result: "ok",
723 error_type: None,
724 error_subtype: None,
725 session_id: Some("test-session-1".to_string()),
726 seq: None,
727 cache_hit: None,
728 cache_write_failure: None,
729 exit_code: None,
730 timed_out: false,
731 cache_tier: None,
732 output_truncated: None,
733 chars_threshold_breach: false,
734 file_ext: None,
735 ..Default::default()
736 };
737
738 tx.send(make_event()).unwrap();
739 tx.send(make_event()).unwrap();
740 drop(tx);
741 writer.run().await;
742
743 assert!(
745 export_file.exists(),
746 "export file should exist at {}",
747 export_path
748 );
749 let content = std::fs::read_to_string(&export_file).unwrap();
750 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
751 assert_eq!(parsed["session_id"], "test-session-1");
752 assert!(parsed["total_duration_ms"].as_u64().unwrap() >= 2);
753 assert_eq!(parsed["tool_calls"][0]["tool"], "analyze_directory");
754 assert_eq!(parsed["tool_calls"][0]["call_count"], 2);
755
756 unsafe {
758 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
759 }
760 }
761
762 #[tokio::test]
763 async fn test_metrics_export_env_var_unset() {
764 let _guard = metrics_export_lock();
765 let dir = TempDir::new().unwrap();
767 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
768 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
769
770 let make_event = || MetricEvent {
771 ts: unix_ms(),
772 tool: "analyze_directory",
773 duration_ms: 1,
774 output_chars: 10,
775 param_path_depth: 1,
776 max_depth: None,
777 result: "ok",
778 error_type: None,
779 error_subtype: None,
780 session_id: None,
781 seq: None,
782 cache_hit: None,
783 cache_write_failure: None,
784 exit_code: None,
785 timed_out: false,
786 cache_tier: None,
787 output_truncated: None,
788 chars_threshold_breach: false,
789 file_ext: None,
790 ..Default::default()
791 };
792
793 tx.send(make_event()).unwrap();
794 drop(tx);
795 writer.run().await;
796
797 let entries: Vec<_> = std::fs::read_dir(dir.path())
799 .unwrap()
800 .filter_map(|e| e.ok())
801 .filter(|e| {
802 e.path()
803 .file_name()
804 .and_then(|n| n.to_str())
805 .map(|n| n.contains("metrics.json"))
806 .unwrap_or(false)
807 })
808 .collect();
809 assert_eq!(entries.len(), 0, "no export file should be created");
810 }
811
812 #[tokio::test]
813 async fn test_metrics_export_relative_path_rejected() {
814 let _guard = metrics_export_lock();
815 let dir = TempDir::new().unwrap();
817 unsafe {
818 std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", "relative/export.json");
819 }
820
821 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
822 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
823
824 let make_event = || MetricEvent {
825 ts: unix_ms(),
826 tool: "analyze_file",
827 duration_ms: 1,
828 output_chars: 10,
829 param_path_depth: 1,
830 max_depth: None,
831 result: "ok",
832 error_type: None,
833 error_subtype: None,
834 session_id: None,
835 seq: None,
836 cache_hit: None,
837 cache_write_failure: None,
838 exit_code: None,
839 timed_out: false,
840 cache_tier: None,
841 output_truncated: None,
842 chars_threshold_breach: false,
843 file_ext: None,
844 ..Default::default()
845 };
846
847 tx.send(make_event()).unwrap();
848 drop(tx);
849 writer.run().await;
850
851 let entries: Vec<_> = std::fs::read_dir(dir.path())
853 .unwrap()
854 .filter_map(|e| e.ok())
855 .filter(|e| {
856 e.path()
857 .file_name()
858 .and_then(|n| n.to_str())
859 .map(|n| n.contains("metrics.json"))
860 .unwrap_or(false)
861 })
862 .collect();
863 assert_eq!(
864 entries.len(),
865 0,
866 "no export file should be created for relative path"
867 );
868
869 unsafe {
871 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
872 }
873 }
874
875 #[tokio::test]
876 async fn test_lock_file_created() {
877 let _guard = metrics_export_lock();
878 let dir = TempDir::new().unwrap();
880 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
881 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
882 let make_event = || MetricEvent {
883 ts: unix_ms(),
884 tool: "analyze_directory",
885 duration_ms: 1,
886 output_chars: 10,
887 param_path_depth: 1,
888 max_depth: None,
889 result: "ok",
890 error_type: None,
891 error_subtype: None,
892 session_id: None,
893 seq: None,
894 cache_hit: None,
895 cache_write_failure: None,
896 exit_code: None,
897 timed_out: false,
898 cache_tier: None,
899 output_truncated: None,
900 chars_threshold_breach: false,
901 file_ext: None,
902 ..Default::default()
903 };
904 tx.send(make_event()).unwrap();
905 drop(tx);
906 writer.run().await;
907
908 let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
910 .unwrap()
911 .filter_map(|e| e.ok())
912 .filter(|e| {
913 e.path()
914 .extension()
915 .and_then(|x| x.to_str())
916 .map(|x| x.eq_ignore_ascii_case("jsonl"))
917 .unwrap_or(false)
918 })
919 .collect();
920 assert_eq!(jsonl_entries.len(), 1);
921 let lock_path = format!("{}.lock", jsonl_entries[0].path().display());
922 assert!(
923 std::path::Path::new(&lock_path).exists(),
924 "lock file must exist next to JSONL file"
925 );
926 }
927
928 #[tokio::test]
929 async fn test_flush_batch_concurrent_writes() {
930 let dir = TempDir::new().unwrap();
933 let base = dir.path().to_path_buf();
934
935 let (tx1, rx1) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
937 let writer1 = MetricsWriter::new(rx1, Some(base.clone()));
938 let make_event = || MetricEvent {
939 ts: unix_ms(),
940 tool: "analyze_directory",
941 duration_ms: 1,
942 output_chars: 10,
943 param_path_depth: 1,
944 max_depth: None,
945 result: "ok",
946 error_type: None,
947 error_subtype: None,
948 session_id: None,
949 seq: None,
950 cache_hit: None,
951 cache_write_failure: None,
952 exit_code: None,
953 timed_out: false,
954 cache_tier: None,
955 output_truncated: None,
956 chars_threshold_breach: false,
957 file_ext: None,
958 ..Default::default()
959 };
960 tx1.send(make_event()).unwrap();
961 tx1.send(make_event()).unwrap();
962 drop(tx1);
963
964 let (tx2, rx2) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
966 let writer2 = MetricsWriter::new(rx2, Some(base));
967 tx2.send(make_event()).unwrap();
968 tx2.send(make_event()).unwrap();
969 drop(tx2);
970
971 let h1 = tokio::spawn(writer1.run());
973 let h2 = tokio::spawn(writer2.run());
974 let (r1, r2) = tokio::join!(h1, h2);
975 r1.unwrap();
976 r2.unwrap();
977
978 let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
980 .unwrap()
981 .filter_map(|e| e.ok())
982 .filter(|e| {
983 e.path()
984 .extension()
985 .and_then(|x| x.to_str())
986 .map(|x| x.eq_ignore_ascii_case("jsonl"))
987 .unwrap_or(false)
988 })
989 .collect();
990 assert_eq!(jsonl_entries.len(), 1);
991 let content = std::fs::read_to_string(jsonl_entries[0].path()).unwrap();
992 let lines: Vec<&str> = content.lines().collect();
993 assert_eq!(lines.len(), 4, "expected 4 JSONL lines from 2 writers");
994 }
995}