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 tempfile::TempDir;
468
469 async fn metrics_export_lock() -> tokio::sync::MutexGuard<'static, ()> {
472 static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
473 LOCK.lock().await
474 }
475
476 #[test]
477 fn test_migrate_legacy_only_old_exists() {
478 let tmp_home = TempDir::new().unwrap();
480 let home_str = tmp_home.path().to_str().unwrap();
481 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
482 let new_path = tmp_home.path().join(".local/share/aptu-coder");
483 fs::create_dir_all(&old_path).unwrap();
484 assert!(!new_path.exists());
485
486 let result = migrate_legacy_metrics_dir_impl(home_str);
488
489 assert!(result.is_ok());
491 assert!(!old_path.exists(), "old dir should be moved");
492 assert!(new_path.is_dir(), "new dir should exist");
493 }
494
495 #[test]
496 fn test_migrate_legacy_both_exist() {
497 let tmp_home = TempDir::new().unwrap();
499 let home_str = tmp_home.path().to_str().unwrap();
500 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
501 let new_path = tmp_home.path().join(".local/share/aptu-coder");
502 fs::create_dir_all(&old_path).unwrap();
503 fs::create_dir_all(&new_path).unwrap();
504
505 let result = migrate_legacy_metrics_dir_impl(home_str);
507
508 assert!(result.is_ok());
510 assert!(old_path.is_dir(), "old dir should remain");
511 assert!(new_path.is_dir(), "new dir should remain");
512 }
513
514 #[test]
515 fn test_migrate_legacy_neither_exists() {
516 let tmp_home = TempDir::new().unwrap();
518 let home_str = tmp_home.path().to_str().unwrap();
519 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
520 let new_path = tmp_home.path().join(".local/share/aptu-coder");
521
522 let result = migrate_legacy_metrics_dir_impl(home_str);
524
525 assert!(result.is_ok());
527 assert!(!old_path.exists(), "old dir should not exist");
528 assert!(!new_path.exists(), "new dir should not exist");
529 }
530
531 #[test]
532 fn test_date_to_days_since_epoch_known_dates() {
533 assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
534 assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
535 assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
536 }
537
538 #[test]
539 fn test_date_to_days_since_epoch_edge_cases() {
540 assert_eq!(date_to_days_since_epoch(1970, 12, 31), 364);
542 assert_eq!(date_to_days_since_epoch(1971, 1, 1), 365);
543 assert_eq!(date_to_days_since_epoch(2023, 12, 31), 19_722);
544 assert_eq!(date_to_days_since_epoch(2024, 1, 1), 19_723);
545
546 assert_eq!(date_to_days_since_epoch(2023, 1, 31), 19_388);
548 assert_eq!(date_to_days_since_epoch(2023, 2, 1), 19_389);
549
550 assert_eq!(date_to_days_since_epoch(2024, 2, 28), 19_781);
552 assert_eq!(date_to_days_since_epoch(2024, 2, 29), 19_782);
553 assert_eq!(date_to_days_since_epoch(2024, 3, 1), 19_783);
554
555 assert_eq!(date_to_days_since_epoch(2000, 2, 28), 11_015);
557 assert_eq!(date_to_days_since_epoch(2000, 3, 1), 11_017);
558
559 assert_eq!(date_to_days_since_epoch(2100, 2, 28), 47_540);
561 assert_eq!(date_to_days_since_epoch(2100, 3, 1), 47_541);
562 }
563
564 #[test]
565 fn test_current_date_str_roundtrip() {
566 let s = current_date_str();
568 let year: u32 = s[0..4].parse().expect("year numeric");
569 let month: u32 = s[5..7].parse().expect("month numeric");
570 let day: u32 = s[8..10].parse().expect("day numeric");
571 let from_str = date_to_days_since_epoch(year, month, day);
572 let from_unix = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
573 assert_eq!(from_str, from_unix);
574 }
575
576 #[test]
577 fn test_current_date_str_format() {
578 let s = current_date_str();
579 assert_eq!(s.len(), 10);
580 assert_eq!(s.as_bytes()[4], b'-');
581 assert_eq!(s.as_bytes()[7], b'-');
582 let year: u32 = s[0..4].parse().expect("year must be numeric");
583 assert!((2020..=2100).contains(&year));
584 }
585
586 #[tokio::test]
587 async fn test_metrics_writer_batching() {
588 let _guard = metrics_export_lock().await;
589 let dir = TempDir::new().unwrap();
590 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
591 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
592 let make_event = || MetricEvent {
593 ts: unix_ms(),
594 tool: "analyze_directory",
595 duration_ms: 1,
596 output_chars: 10,
597 param_path_depth: 1,
598 max_depth: None,
599 result: "ok",
600 error_type: None,
601 error_subtype: None,
602 session_id: None,
603 seq: None,
604 cache_hit: None,
605 cache_write_failure: None,
606 exit_code: None,
607 timed_out: false,
608 cache_tier: None,
609 output_truncated: None,
610 chars_threshold_breach: false,
611 file_ext: None,
612 ..Default::default()
613 };
614 tx.send(make_event()).unwrap();
615 tx.send(make_event()).unwrap();
616 tx.send(make_event()).unwrap();
617 drop(tx);
618 writer.run().await;
619 let entries: Vec<_> = std::fs::read_dir(dir.path())
620 .unwrap()
621 .filter_map(|e| e.ok())
622 .filter(|e| {
623 e.path()
624 .extension()
625 .and_then(|x| x.to_str())
626 .map(|x| x.eq_ignore_ascii_case("jsonl"))
627 .unwrap_or(false)
628 })
629 .collect();
630 assert_eq!(entries.len(), 1);
631 let content = std::fs::read_to_string(entries[0].path()).unwrap();
632 let lines: Vec<&str> = content.lines().collect();
633 assert_eq!(lines.len(), 3);
634 }
635
636 #[tokio::test]
637 async fn test_cleanup_old_files_deletes_old_keeps_recent() {
638 let _guard = metrics_export_lock().await;
639 let dir = TempDir::new().unwrap();
640 let old_file = dir.path().join("metrics-1970-01-01.jsonl");
641 let today = current_date_str();
642 let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
643 std::fs::write(&old_file, "old\n").unwrap();
644 std::fs::write(&recent_file, "recent\n").unwrap();
645 cleanup_old_files(dir.path()).await;
646 assert!(!old_file.exists());
647 assert!(recent_file.exists());
648 }
649
650 #[test]
651 fn test_path_file_ext_known() {
652 assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
654 }
655
656 #[test]
657 fn test_path_file_ext_unknown() {
658 assert_eq!(path_file_ext("file.xyz"), Some("other"));
660 }
661
662 #[test]
663 fn test_path_file_ext_no_ext() {
664 assert_eq!(path_file_ext("Makefile"), None);
666 }
667
668 #[test]
669 fn test_path_file_ext_case_insensitive() {
670 assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
672 }
673
674 #[test]
675 fn test_path_file_ext_multi_dot() {
676 assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
678 }
679
680 #[test]
681 fn test_path_language_known_ext() {
682 assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
684 }
685
686 #[test]
687 fn test_path_language_unknown_ext() {
688 assert_eq!(path_language("file.xyz"), None);
690 }
691
692 #[test]
693 fn test_path_language_no_ext() {
694 assert_eq!(path_language("Makefile"), None);
696 }
697
698 #[tokio::test]
699 async fn test_metrics_export_file_created() {
700 let _guard = metrics_export_lock().await;
701 let dir = TempDir::new().unwrap();
703 let export_file = dir.path().join("metrics_export.json");
704 let export_path = export_file.to_str().unwrap().to_string();
705 unsafe {
706 std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", &export_path);
707 }
708
709 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
711 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
712
713 let make_event = || MetricEvent {
714 ts: unix_ms(),
715 tool: "analyze_directory",
716 duration_ms: 1,
717 output_chars: 10,
718 param_path_depth: 1,
719 max_depth: None,
720 result: "ok",
721 error_type: None,
722 error_subtype: None,
723 session_id: Some("test-session-1".to_string()),
724 seq: None,
725 cache_hit: None,
726 cache_write_failure: None,
727 exit_code: None,
728 timed_out: false,
729 cache_tier: None,
730 output_truncated: None,
731 chars_threshold_breach: false,
732 file_ext: None,
733 ..Default::default()
734 };
735
736 tx.send(make_event()).unwrap();
737 tx.send(make_event()).unwrap();
738 drop(tx);
739 writer.run().await;
740
741 assert!(
743 export_file.exists(),
744 "export file should exist at {}",
745 export_path
746 );
747 let content = std::fs::read_to_string(&export_file).unwrap();
748 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
749 assert_eq!(parsed["session_id"], "test-session-1");
750 assert!(parsed["total_duration_ms"].as_u64().unwrap() >= 2);
751 assert_eq!(parsed["tool_calls"][0]["tool"], "analyze_directory");
752 assert_eq!(parsed["tool_calls"][0]["call_count"], 2);
753
754 unsafe {
756 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
757 }
758 }
759
760 #[tokio::test]
761 async fn test_metrics_export_env_var_unset() {
762 let _guard = metrics_export_lock().await;
763 let dir = TempDir::new().unwrap();
765 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
766 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
767
768 let make_event = || MetricEvent {
769 ts: unix_ms(),
770 tool: "analyze_directory",
771 duration_ms: 1,
772 output_chars: 10,
773 param_path_depth: 1,
774 max_depth: None,
775 result: "ok",
776 error_type: None,
777 error_subtype: None,
778 session_id: None,
779 seq: None,
780 cache_hit: None,
781 cache_write_failure: None,
782 exit_code: None,
783 timed_out: false,
784 cache_tier: None,
785 output_truncated: None,
786 chars_threshold_breach: false,
787 file_ext: None,
788 ..Default::default()
789 };
790
791 tx.send(make_event()).unwrap();
792 drop(tx);
793 writer.run().await;
794
795 let entries: Vec<_> = std::fs::read_dir(dir.path())
797 .unwrap()
798 .filter_map(|e| e.ok())
799 .filter(|e| {
800 e.path()
801 .file_name()
802 .and_then(|n| n.to_str())
803 .map(|n| n.contains("metrics.json"))
804 .unwrap_or(false)
805 })
806 .collect();
807 assert_eq!(entries.len(), 0, "no export file should be created");
808 }
809
810 #[tokio::test]
811 async fn test_metrics_export_relative_path_rejected() {
812 let _guard = metrics_export_lock().await;
813 let dir = TempDir::new().unwrap();
815 unsafe {
816 std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", "relative/export.json");
817 }
818
819 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
820 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
821
822 let make_event = || MetricEvent {
823 ts: unix_ms(),
824 tool: "analyze_file",
825 duration_ms: 1,
826 output_chars: 10,
827 param_path_depth: 1,
828 max_depth: None,
829 result: "ok",
830 error_type: None,
831 error_subtype: None,
832 session_id: None,
833 seq: None,
834 cache_hit: None,
835 cache_write_failure: None,
836 exit_code: None,
837 timed_out: false,
838 cache_tier: None,
839 output_truncated: None,
840 chars_threshold_breach: false,
841 file_ext: None,
842 ..Default::default()
843 };
844
845 tx.send(make_event()).unwrap();
846 drop(tx);
847 writer.run().await;
848
849 let entries: Vec<_> = std::fs::read_dir(dir.path())
851 .unwrap()
852 .filter_map(|e| e.ok())
853 .filter(|e| {
854 e.path()
855 .file_name()
856 .and_then(|n| n.to_str())
857 .map(|n| n.contains("metrics.json"))
858 .unwrap_or(false)
859 })
860 .collect();
861 assert_eq!(
862 entries.len(),
863 0,
864 "no export file should be created for relative path"
865 );
866
867 unsafe {
869 std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
870 }
871 }
872
873 #[tokio::test]
874 async fn test_lock_file_created() {
875 let _guard = metrics_export_lock().await;
876 let dir = TempDir::new().unwrap();
878 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
879 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
880 let make_event = || MetricEvent {
881 ts: unix_ms(),
882 tool: "analyze_directory",
883 duration_ms: 1,
884 output_chars: 10,
885 param_path_depth: 1,
886 max_depth: None,
887 result: "ok",
888 error_type: None,
889 error_subtype: None,
890 session_id: None,
891 seq: None,
892 cache_hit: None,
893 cache_write_failure: None,
894 exit_code: None,
895 timed_out: false,
896 cache_tier: None,
897 output_truncated: None,
898 chars_threshold_breach: false,
899 file_ext: None,
900 ..Default::default()
901 };
902 tx.send(make_event()).unwrap();
903 drop(tx);
904 writer.run().await;
905
906 let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
908 .unwrap()
909 .filter_map(|e| e.ok())
910 .filter(|e| {
911 e.path()
912 .extension()
913 .and_then(|x| x.to_str())
914 .map(|x| x.eq_ignore_ascii_case("jsonl"))
915 .unwrap_or(false)
916 })
917 .collect();
918 assert_eq!(jsonl_entries.len(), 1);
919 let lock_path = format!("{}.lock", jsonl_entries[0].path().display());
920 assert!(
921 std::path::Path::new(&lock_path).exists(),
922 "lock file must exist next to JSONL file"
923 );
924 }
925
926 #[tokio::test]
927 async fn test_flush_batch_concurrent_writes() {
928 let dir = TempDir::new().unwrap();
931 let base = dir.path().to_path_buf();
932
933 let (tx1, rx1) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
935 let writer1 = MetricsWriter::new(rx1, Some(base.clone()));
936 let make_event = || MetricEvent {
937 ts: unix_ms(),
938 tool: "analyze_directory",
939 duration_ms: 1,
940 output_chars: 10,
941 param_path_depth: 1,
942 max_depth: None,
943 result: "ok",
944 error_type: None,
945 error_subtype: None,
946 session_id: None,
947 seq: None,
948 cache_hit: None,
949 cache_write_failure: None,
950 exit_code: None,
951 timed_out: false,
952 cache_tier: None,
953 output_truncated: None,
954 chars_threshold_breach: false,
955 file_ext: None,
956 ..Default::default()
957 };
958 tx1.send(make_event()).unwrap();
959 tx1.send(make_event()).unwrap();
960 drop(tx1);
961
962 let (tx2, rx2) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
964 let writer2 = MetricsWriter::new(rx2, Some(base));
965 tx2.send(make_event()).unwrap();
966 tx2.send(make_event()).unwrap();
967 drop(tx2);
968
969 let h1 = tokio::spawn(writer1.run());
971 let h2 = tokio::spawn(writer2.run());
972 let (r1, r2) = tokio::join!(h1, h2);
973 r1.unwrap();
974 r2.unwrap();
975
976 let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
978 .unwrap()
979 .filter_map(|e| e.ok())
980 .filter(|e| {
981 e.path()
982 .extension()
983 .and_then(|x| x.to_str())
984 .map(|x| x.eq_ignore_ascii_case("jsonl"))
985 .unwrap_or(false)
986 })
987 .collect();
988 assert_eq!(jsonl_entries.len(), 1);
989 let content = std::fs::read_to_string(jsonl_entries[0].path()).unwrap();
990 let lines: Vec<&str> = content.lines().collect();
991 assert_eq!(lines.len(), 4, "expected 4 JSONL lines from 2 writers");
992 }
993}