1use std::fmt;
46use std::io::{self, Write};
47use std::path::Path;
48use std::sync::atomic::{AtomicU64, Ordering};
49use std::sync::{mpsc, Arc};
50use std::time::{SystemTime, UNIX_EPOCH};
51
52use ant_protocol::transport::PeerId;
53use serde::Serialize;
54use tracing::{error, warn};
55
56pub const DIAGNOSTICS_SCHEMA_VERSION: u8 = 4;
58
59#[derive(Debug, Clone, PartialEq, Eq)]
62pub(crate) struct DownloadRequestCorrelation {
63 pub(crate) request_id: u64,
64 pub(crate) local_peer_id: String,
65}
66
67impl DownloadRequestCorrelation {
68 pub(crate) fn new(request_id: u64, local_peer_id: &PeerId) -> Self {
69 Self {
70 request_id,
71 local_peer_id: local_peer_id.to_string(),
72 }
73 }
74}
75
76const DIAGNOSTICS_CHANNEL_CAPACITY: usize = 1024;
80
81const DIAGNOSTICS_ERROR_MAX_CHARS: usize = 240;
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum DownloadDiagnosticsOutcome {
93 Found,
95 NotFound,
97 Timeout,
99 NetworkError,
101 ProtocolError,
105 CacheHit,
107 LookupError,
109 Exhausted,
111}
112
113impl DownloadDiagnosticsOutcome {
114 #[must_use]
116 pub const fn as_str(self) -> &'static str {
117 match self {
118 Self::Found => "found",
119 Self::NotFound => "not_found",
120 Self::Timeout => "timeout",
121 Self::NetworkError => "network_error",
122 Self::ProtocolError => "protocol_error",
123 Self::CacheHit => "cache_hit",
124 Self::LookupError => "lookup_error",
125 Self::Exhausted => "exhausted",
126 }
127 }
128}
129
130impl fmt::Display for DownloadDiagnosticsOutcome {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.write_str(self.as_str())
133 }
134}
135
136impl Serialize for DownloadDiagnosticsOutcome {
137 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
138 where
139 S: serde::Serializer,
140 {
141 serializer.serialize_str(self.as_str())
142 }
143}
144
145#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
152pub struct DownloadDiagnosticsRecord {
153 pub schema_version: u8,
155 pub timestamp: String,
157 pub request_started_unix_ms: Option<u64>,
161 pub request_completed_unix_ms: Option<u64>,
164 pub request_id: Option<u64>,
168 pub local_peer_id: Option<String>,
171 pub file_attempt: usize,
173 pub chunk_index: usize,
175 pub chunk_address: String,
177 pub sweep: String,
181 pub peer_attempt: Option<usize>,
184 pub lookup_duration_ms: Option<u64>,
187 pub lookup_correlation_id: Option<String>,
189 pub selected_peer_ordinal: Option<usize>,
191 pub expected_peer: Option<String>,
194 pub selected_peer_addresses: Option<Vec<String>>,
196 pub selected_peer_address_types: Option<Vec<String>>,
198 pub local_last_seen_age_ms: Option<u64>,
201 pub publisher_address_set_age_ms: Option<u64>,
204 pub publisher_address_set_unix_ns: Option<u64>,
206 pub source_peer: Option<String>,
210 pub transport_source: Option<String>,
214 pub route: String,
219 pub route_note: Option<String>,
222 pub peer_connected_before_request: Option<bool>,
225 pub active_requests_at_start: Option<usize>,
228 pub fetch_cap: Option<usize>,
231 pub response_elapsed_ms: Option<u64>,
234 pub ttfb_ms: Option<u64>,
236 pub ttfb_available: bool,
239 pub ttfb_unavailable_reason: String,
241 pub bytes: u64,
243 pub outcome: DownloadDiagnosticsOutcome,
245 pub error: Option<String>,
248}
249
250impl DownloadDiagnosticsRecord {
251 pub const TTFB_UNAVAILABLE_REASON: &'static str =
253 "protocol exposes only a complete-response event; first-byte/first-frame \
254 timing is not available";
255
256 pub const ROUTE_UNKNOWN_NOTE: &'static str =
260 "transport_source absent or did not match any known typed peer dial address; \
261 route could not be classified from the observed response";
262
263 #[allow(clippy::too_many_arguments)]
267 pub(crate) fn peer_attempt(
268 file_attempt: usize,
269 chunk_index: usize,
270 chunk_address: &[u8; 32],
271 sweep: &'static str,
272 peer_attempt: usize,
273 lookup_duration_ms: Option<u64>,
274 lookup_correlation_id: &str,
275 expected_peer: &str,
276 selected_peer_addresses: Vec<String>,
277 selected_peer_address_types: Vec<String>,
278 local_last_seen_age_ms: Option<u64>,
279 publisher_address_set_age_ms: Option<u64>,
280 publisher_address_set_unix_ns: Option<u64>,
281 source_peer: Option<&str>,
282 transport_source: Option<&str>,
283 route: &str,
284 route_note: Option<&str>,
285 peer_connected_before_request: Option<bool>,
286 active_requests_at_start: Option<usize>,
287 fetch_cap: Option<usize>,
288 request_started_unix_ms: u64,
289 request_completed_unix_ms: u64,
290 correlation: &DownloadRequestCorrelation,
291 response_elapsed_ms: u64,
292 bytes: u64,
293 outcome: DownloadDiagnosticsOutcome,
294 error: Option<String>,
295 ) -> Self {
296 let lookup = if peer_attempt == 1 {
297 lookup_duration_ms
298 } else {
299 None
300 };
301 Self {
302 schema_version: DIAGNOSTICS_SCHEMA_VERSION,
303 timestamp: utc_now_rfc3339(),
304 request_started_unix_ms: Some(request_started_unix_ms),
305 request_completed_unix_ms: Some(request_completed_unix_ms),
306 request_id: Some(correlation.request_id),
307 local_peer_id: Some(correlation.local_peer_id.clone()),
308 file_attempt,
309 chunk_index,
310 chunk_address: hex::encode(chunk_address),
311 sweep: sweep.to_string(),
312 peer_attempt: Some(peer_attempt),
313 lookup_duration_ms: lookup,
314 lookup_correlation_id: Some(lookup_correlation_id.to_string()),
315 selected_peer_ordinal: Some(peer_attempt),
316 expected_peer: Some(expected_peer.to_string()),
317 selected_peer_addresses: Some(selected_peer_addresses),
318 selected_peer_address_types: Some(selected_peer_address_types),
319 local_last_seen_age_ms,
320 publisher_address_set_age_ms,
321 publisher_address_set_unix_ns,
322 source_peer: source_peer.map(str::to_string),
323 transport_source: transport_source.map(str::to_string),
324 route: route.to_string(),
325 route_note: route_note.map(str::to_string),
326 peer_connected_before_request,
327 active_requests_at_start,
328 fetch_cap,
329 response_elapsed_ms: Some(response_elapsed_ms),
330 ttfb_ms: None,
331 ttfb_available: false,
332 ttfb_unavailable_reason: Self::TTFB_UNAVAILABLE_REASON.to_string(),
333 bytes,
334 outcome,
335 error,
336 }
337 }
338
339 #[allow(clippy::too_many_arguments)]
342 pub fn chunk_level(
343 file_attempt: usize,
344 chunk_index: usize,
345 chunk_address: &[u8; 32],
346 sweep: &'static str,
347 fetch_cap: Option<usize>,
348 bytes: u64,
349 outcome: DownloadDiagnosticsOutcome,
350 error: Option<String>,
351 ) -> Self {
352 Self {
353 schema_version: DIAGNOSTICS_SCHEMA_VERSION,
354 timestamp: utc_now_rfc3339(),
355 request_started_unix_ms: None,
356 request_completed_unix_ms: None,
357 request_id: None,
358 local_peer_id: None,
359 file_attempt,
360 chunk_index,
361 chunk_address: hex::encode(chunk_address),
362 sweep: sweep.to_string(),
363 peer_attempt: None,
364 lookup_duration_ms: None,
365 lookup_correlation_id: None,
366 selected_peer_ordinal: None,
367 expected_peer: None,
368 selected_peer_addresses: None,
369 selected_peer_address_types: None,
370 local_last_seen_age_ms: None,
371 publisher_address_set_age_ms: None,
372 publisher_address_set_unix_ns: None,
373 source_peer: None,
374 transport_source: None,
375 route: "unknown".to_string(),
376 route_note: None,
377 peer_connected_before_request: None,
378 active_requests_at_start: None,
379 fetch_cap,
380 response_elapsed_ms: None,
381 ttfb_ms: None,
382 ttfb_available: false,
383 ttfb_unavailable_reason: Self::TTFB_UNAVAILABLE_REASON.to_string(),
384 bytes,
385 outcome,
386 error,
387 }
388 }
389}
390
391#[derive(Clone)]
398pub struct DownloadDiagnosticsSender {
399 tx: mpsc::SyncSender<DownloadDiagnosticsRecord>,
400 dropped: Arc<AtomicU64>,
401}
402
403impl DownloadDiagnosticsSender {
404 pub fn try_emit(&self, record: DownloadDiagnosticsRecord) {
407 if self.tx.try_send(record).is_err() {
408 self.dropped.fetch_add(1, Ordering::Relaxed);
409 }
410 }
411}
412
413pub fn spawn_download_diagnostics_writer(
428 path: &Path,
429) -> io::Result<(DownloadDiagnosticsSender, std::thread::JoinHandle<()>)> {
430 let file = std::fs::OpenOptions::new()
431 .create(true)
432 .write(true)
433 .truncate(true)
434 .open(path)?;
435 let (tx, rx) = mpsc::sync_channel::<DownloadDiagnosticsRecord>(DIAGNOSTICS_CHANNEL_CAPACITY);
436 let dropped = Arc::new(AtomicU64::new(0));
437 let writer_dropped = Arc::clone(&dropped);
438 let builder = std::thread::Builder::new().name("ant-download-diagnostics-writer".to_string());
439 let handle = builder
440 .spawn(move || {
441 let mut writer = io::BufWriter::new(file);
442 for record in rx.iter() {
443 match serde_json::to_string(&record) {
444 Ok(line) => {
445 if let Err(err) = writeln!(writer, "{line}") {
446 error!(%err, "download diagnostics sidecar write failed");
447 break;
448 }
449 }
450 Err(err) => {
451 error!(%err, "download diagnostics record serialization failed");
455 continue;
456 }
457 }
458 }
459 if let Err(err) = writer.flush() {
460 error!(%err, "download diagnostics sidecar flush failed");
461 }
462 let dropped = writer_dropped.load(Ordering::Relaxed);
463 if dropped > 0 {
464 warn!(dropped, "download diagnostics records were dropped");
465 }
466 })
467 .map_err(|e| io::Error::other(format!("failed to spawn diagnostics writer thread: {e}")))?;
468 Ok((DownloadDiagnosticsSender { tx, dropped }, handle))
469}
470
471pub fn bounded_error(category: &str, detail: &str) -> String {
478 let prefix = if category.is_empty() {
479 String::new()
480 } else {
481 format!("{category}: ")
482 };
483 if prefix.len() + detail.len() <= DIAGNOSTICS_ERROR_MAX_CHARS {
484 return format!("{prefix}{detail}");
485 }
486 let remaining = DIAGNOSTICS_ERROR_MAX_CHARS.saturating_sub(prefix.len());
487 let mut truncated: String = detail.chars().take(remaining.saturating_sub(1)).collect();
488 truncated.push('…');
489 format!("{prefix}{truncated}")
490}
491
492fn utc_now_rfc3339() -> String {
495 let now = SystemTime::now()
496 .duration_since(UNIX_EPOCH)
497 .unwrap_or_default();
498 rfc3339_from_unix_secs(now.as_secs())
499}
500
501pub(crate) fn unix_now_ms() -> u64 {
505 let millis = SystemTime::now()
506 .duration_since(UNIX_EPOCH)
507 .unwrap_or_default()
508 .as_millis();
509 u64::try_from(millis).unwrap_or(u64::MAX)
510}
511
512fn rfc3339_from_unix_secs(secs: u64) -> String {
517 let days = (secs / 86_400) as i64;
518 let secs_of_day = secs % 86_400;
519 let hour = secs_of_day / 3600;
520 let minute = (secs_of_day % 3600) / 60;
521 let second = secs_of_day % 60;
522
523 let z = days + 719_468;
525 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
526 let doe = z - era * 146_097;
527 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
528 let y = yoe + era * 400;
529 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
530 let mp = (5 * doy + 2) / 153;
531 let d = doy - (153 * mp + 2) / 5 + 1;
532 let m = if mp < 10 { mp + 3 } else { mp - 9 };
533 let year = if m <= 2 { y + 1 } else { y };
534
535 format!("{year:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 fn test_correlation(request_id: u64) -> DownloadRequestCorrelation {
543 DownloadRequestCorrelation::new(request_id, &PeerId::from_bytes([42; 32]))
544 }
545
546 #[test]
547 fn outcome_as_str_is_stable_lowercase() {
548 assert_eq!(DownloadDiagnosticsOutcome::Found.as_str(), "found");
549 assert_eq!(DownloadDiagnosticsOutcome::NotFound.as_str(), "not_found");
550 assert_eq!(DownloadDiagnosticsOutcome::Timeout.as_str(), "timeout");
551 assert_eq!(
552 DownloadDiagnosticsOutcome::NetworkError.as_str(),
553 "network_error"
554 );
555 assert_eq!(
556 DownloadDiagnosticsOutcome::ProtocolError.as_str(),
557 "protocol_error"
558 );
559 assert_eq!(DownloadDiagnosticsOutcome::CacheHit.as_str(), "cache_hit");
560 assert_eq!(
561 DownloadDiagnosticsOutcome::LookupError.as_str(),
562 "lookup_error"
563 );
564 assert_eq!(DownloadDiagnosticsOutcome::Exhausted.as_str(), "exhausted");
565 }
566
567 #[test]
568 fn outcome_serializes_as_lowercase_string() {
569 let v = serde_json::to_string(&DownloadDiagnosticsOutcome::NetworkError).unwrap();
570 assert_eq!(v, "\"network_error\"");
571 }
572
573 #[test]
574 fn peer_attempt_record_pins_field_names_and_null_ttfb() {
575 let addr = [7u8; 32];
576 let correlation = test_correlation(9_001);
577 let record = DownloadDiagnosticsRecord::peer_attempt(
578 1,
579 3,
580 &addr,
581 "initial",
582 1,
583 Some(42),
584 "lookup-1",
585 "peer-abc",
586 vec!["/ip4/1.2.3.4/udp/9000/quic".to_string()],
587 vec!["direct".to_string()],
588 Some(1_500),
589 Some(2_000),
590 Some(1_234_000_000),
591 Some("peer-abc"),
592 Some("/ip4/1.2.3.4/udp/9000/quic"),
593 "direct",
594 None,
595 Some(true),
596 Some(8),
597 Some(8),
598 120,
599 1024,
600 &correlation,
601 904,
602 1024,
603 DownloadDiagnosticsOutcome::Found,
604 None,
605 );
606 let json = serde_json::to_value(&record).unwrap();
607 let obj = json.as_object().unwrap();
608 for field in [
610 "schema_version",
611 "timestamp",
612 "request_started_unix_ms",
613 "request_completed_unix_ms",
614 "request_id",
615 "local_peer_id",
616 "file_attempt",
617 "chunk_index",
618 "chunk_address",
619 "sweep",
620 "peer_attempt",
621 "lookup_duration_ms",
622 "lookup_correlation_id",
623 "selected_peer_ordinal",
624 "expected_peer",
625 "selected_peer_addresses",
626 "selected_peer_address_types",
627 "local_last_seen_age_ms",
628 "publisher_address_set_age_ms",
629 "publisher_address_set_unix_ns",
630 "source_peer",
631 "transport_source",
632 "route",
633 "route_note",
634 "peer_connected_before_request",
635 "active_requests_at_start",
636 "fetch_cap",
637 "response_elapsed_ms",
638 "ttfb_ms",
639 "ttfb_available",
640 "ttfb_unavailable_reason",
641 "bytes",
642 "outcome",
643 "error",
644 ] {
645 assert!(obj.contains_key(field), "missing field {field}");
646 }
647 assert_eq!(obj["ttfb_ms"], serde_json::Value::Null);
649 assert_eq!(obj["ttfb_available"], serde_json::Value::Bool(false));
650 assert!(
651 obj["ttfb_unavailable_reason"]
652 .as_str()
653 .unwrap()
654 .contains("first-byte"),
655 "ttfb reason must mention first-byte"
656 );
657 assert_eq!(obj["route"], serde_json::Value::String("direct".into()));
659 assert_eq!(obj["route_note"], serde_json::Value::Null);
660 assert_eq!(obj["source_peer"], serde_json::json!("peer-abc"));
662 assert_eq!(
663 obj["transport_source"],
664 serde_json::json!("/ip4/1.2.3.4/udp/9000/quic")
665 );
666 assert_eq!(obj["request_started_unix_ms"], serde_json::json!(120u64));
668 assert_eq!(obj["request_completed_unix_ms"], serde_json::json!(1024u64));
669 assert_eq!(obj["active_requests_at_start"], serde_json::json!(8usize));
670 assert_eq!(
671 obj["peer_connected_before_request"],
672 serde_json::json!(true)
673 );
674 assert_eq!(obj["fetch_cap"], serde_json::json!(8usize));
675 assert_eq!(obj["lookup_duration_ms"], serde_json::json!(42u64));
677 assert_eq!(obj["bytes"], serde_json::json!(1024u64));
678 assert_eq!(obj["outcome"], serde_json::json!("found"));
679 assert_eq!(obj["request_id"], serde_json::json!(9_001u64));
680 assert_eq!(
681 obj["local_peer_id"],
682 serde_json::json!(correlation.local_peer_id)
683 );
684 assert_eq!(obj["schema_version"], serde_json::json!(4u8));
685 assert_eq!(obj["lookup_correlation_id"], serde_json::json!("lookup-1"));
686 assert_eq!(obj["selected_peer_ordinal"], serde_json::json!(1usize));
687 assert_eq!(obj["local_last_seen_age_ms"], serde_json::json!(1_500u64));
688 assert_eq!(
689 obj["publisher_address_set_age_ms"],
690 serde_json::json!(2_000u64)
691 );
692 assert_eq!(
693 obj["chunk_address"],
694 serde_json::Value::String(hex::encode(addr))
695 );
696 }
697
698 #[test]
699 fn later_peer_attempt_omits_lookup_duration_and_carries_route_note_when_unknown() {
700 let addr = [9u8; 32];
701 let record = DownloadDiagnosticsRecord::peer_attempt(
702 1,
703 1,
704 &addr,
705 "retry",
706 3,
707 Some(10),
708 "lookup-2",
709 "peer-x",
710 vec!["/ip6/2001:db8::1/udp/9000/quic".to_string()],
711 vec!["unverified".to_string()],
712 None,
713 None,
714 None,
715 None,
717 None,
718 "unknown",
719 Some(DownloadDiagnosticsRecord::ROUTE_UNKNOWN_NOTE),
720 Some(false),
721 Some(4),
722 Some(4),
723 500,
724 1_000,
725 &test_correlation(9_002),
726 500,
727 0,
728 DownloadDiagnosticsOutcome::Timeout,
729 Some(bounded_error("timeout", "no response")),
730 );
731 let json = serde_json::to_value(&record).unwrap();
732 let obj = json.as_object().unwrap();
733 assert_eq!(obj["lookup_duration_ms"], serde_json::Value::Null);
734 assert_eq!(obj["lookup_correlation_id"], serde_json::json!("lookup-2"));
735 assert_eq!(obj["selected_peer_ordinal"], serde_json::json!(3usize));
736 assert_eq!(
737 obj["route_note"],
738 serde_json::json!(DownloadDiagnosticsRecord::ROUTE_UNKNOWN_NOTE)
739 );
740 assert_eq!(obj["source_peer"], serde_json::Value::Null);
741 assert_eq!(obj["transport_source"], serde_json::Value::Null);
742 assert_eq!(obj["route"], serde_json::json!("unknown"));
743 assert_eq!(
744 obj["peer_connected_before_request"],
745 serde_json::json!(false)
746 );
747 assert_eq!(obj["active_requests_at_start"], serde_json::json!(4usize));
748 assert_eq!(obj["fetch_cap"], serde_json::json!(4usize));
749 assert_eq!(obj["outcome"], serde_json::json!("timeout"));
750 assert_eq!(obj["bytes"], serde_json::json!(0u64));
751 assert!(obj["error"].as_str().unwrap().starts_with("timeout: "));
752 }
753
754 #[test]
755 fn cache_hit_record_has_no_peer_fields() {
756 let addr = [1u8; 32];
757 let record = DownloadDiagnosticsRecord::chunk_level(
758 1,
759 2,
760 &addr,
761 "initial",
762 Some(8),
763 4096,
764 DownloadDiagnosticsOutcome::CacheHit,
765 None,
766 );
767 let json = serde_json::to_value(&record).unwrap();
768 let obj = json.as_object().unwrap();
769 assert_eq!(obj["peer_attempt"], serde_json::Value::Null);
770 assert_eq!(obj["expected_peer"], serde_json::Value::Null);
771 assert_eq!(obj["source_peer"], serde_json::Value::Null);
772 assert_eq!(obj["transport_source"], serde_json::Value::Null);
773 assert_eq!(obj["lookup_duration_ms"], serde_json::Value::Null);
774 assert_eq!(obj["lookup_correlation_id"], serde_json::Value::Null);
775 assert_eq!(obj["selected_peer_addresses"], serde_json::Value::Null);
776 assert_eq!(obj["response_elapsed_ms"], serde_json::Value::Null);
777 assert_eq!(
778 obj["peer_connected_before_request"],
779 serde_json::Value::Null
780 );
781 assert_eq!(obj["request_started_unix_ms"], serde_json::Value::Null);
782 assert_eq!(obj["request_completed_unix_ms"], serde_json::Value::Null);
783 assert_eq!(obj["request_id"], serde_json::Value::Null);
784 assert_eq!(obj["local_peer_id"], serde_json::Value::Null);
785 assert_eq!(obj["active_requests_at_start"], serde_json::Value::Null);
786 assert_eq!(obj["fetch_cap"], serde_json::json!(8usize));
787 assert_eq!(obj["route"], serde_json::json!("unknown"));
788 assert_eq!(obj["route_note"], serde_json::Value::Null);
789 assert_eq!(obj["outcome"], serde_json::json!("cache_hit"));
790 assert_eq!(obj["bytes"], serde_json::json!(4096u64));
791 }
792
793 #[test]
794 fn exhausted_and_lookup_error_records_classify_correctly() {
795 let addr = [2u8; 32];
796 let exhausted = DownloadDiagnosticsRecord::chunk_level(
797 2,
798 5,
799 &addr,
800 "retry",
801 Some(2),
802 0,
803 DownloadDiagnosticsOutcome::Exhausted,
804 None,
805 );
806 assert_eq!(
807 serde_json::to_value(&exhausted).unwrap()["outcome"],
808 serde_json::json!("exhausted")
809 );
810
811 let lookup_err = DownloadDiagnosticsRecord::chunk_level(
812 2,
813 5,
814 &addr,
815 "initial",
816 Some(2),
817 0,
818 DownloadDiagnosticsOutcome::LookupError,
819 Some(bounded_error("lookup", "DHT returned no peers")),
820 );
821 let v = serde_json::to_value(&lookup_err).unwrap();
822 assert_eq!(v["outcome"], serde_json::json!("lookup_error"));
823 assert!(v["error"].as_str().unwrap().starts_with("lookup: "));
824 }
825
826 #[test]
827 fn bounded_error_truncates_long_detail() {
828 let long = "x".repeat(10_000);
829 let s = bounded_error("network", &long);
830 assert!(s.starts_with("network: "));
831 assert!(s.chars().count() <= DIAGNOSTICS_ERROR_MAX_CHARS);
833 assert!(s.ends_with('…'));
834 }
835
836 #[test]
837 fn bounded_error_preserves_short_detail_intact() {
838 let s = bounded_error("protocol", "mismatched address");
839 assert_eq!(s, "protocol: mismatched address");
840 }
841
842 #[test]
843 fn rfc3339_formatter_is_valid_for_known_epoch() {
844 let s = rfc3339_from_unix_secs(1_609_459_200);
846 assert_eq!(s, "2021-01-01T00:00:00Z");
847 assert_eq!(rfc3339_from_unix_secs(0), "1970-01-01T00:00:00Z");
849 assert_eq!(
851 rfc3339_from_unix_secs(1_709_164_800),
852 "2024-02-29T00:00:00Z"
853 );
854 }
855
856 #[test]
857 fn disabled_diagnostics_does_not_create_sidecar() {
858 let dir = tempfile::tempdir().unwrap();
859 let path = dir.path().join("disabled.jsonl");
860 let diagnostics: Option<DownloadDiagnosticsSender> = None;
861
862 assert!(diagnostics.is_none());
863 assert!(!path.exists());
864 }
865
866 #[test]
867 fn writer_emits_one_json_line_per_record_and_flushes_on_drop() {
868 let dir = std::env::temp_dir();
869 let path = dir.join(format!(
870 "ant-dl-diag-{}-{}.jsonl",
871 std::process::id(),
872 std::time::SystemTime::now()
873 .duration_since(UNIX_EPOCH)
874 .unwrap()
875 .as_nanos()
876 ));
877 let (sender, writer) = spawn_download_diagnostics_writer(&path).unwrap();
878 let addr = [3u8; 32];
879 sender.try_emit(DownloadDiagnosticsRecord::chunk_level(
880 1,
881 1,
882 &addr,
883 "initial",
884 Some(8),
885 128,
886 DownloadDiagnosticsOutcome::CacheHit,
887 None,
888 ));
889 sender.try_emit(DownloadDiagnosticsRecord::peer_attempt(
890 1,
891 1,
892 &addr,
893 "initial",
894 1,
895 Some(5),
896 "lookup-writer",
897 "peer-z",
898 vec!["/ip4/1.2.3.4/udp/9000/quic".to_string()],
899 vec!["direct".to_string()],
900 Some(50),
901 Some(100),
902 Some(1_234_000_000),
903 Some("peer-z"),
904 Some("/ip4/1.2.3.4/udp/9000/quic"),
905 "direct",
906 None,
907 Some(true),
908 Some(8),
909 Some(8),
910 30,
911 60,
912 &test_correlation(9_003),
913 30,
914 128,
915 DownloadDiagnosticsOutcome::Found,
916 None,
917 ));
918 drop(sender);
920 writer.join().unwrap();
921 let contents = std::fs::read_to_string(&path).unwrap();
922 let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
923 assert_eq!(
924 lines.len(),
925 2,
926 "expected 2 JSONL records, got: {contents:?}"
927 );
928 let first = serde_json::from_str::<serde_json::Value>(lines[0]).unwrap();
929 assert_eq!(first["outcome"], serde_json::json!("cache_hit"));
930 assert_eq!(first["schema_version"], serde_json::json!(4u8));
931 let second = serde_json::from_str::<serde_json::Value>(lines[1]).unwrap();
932 assert_eq!(second["outcome"], serde_json::json!("found"));
933 assert_eq!(second["route"], serde_json::json!("direct"));
934 assert_eq!(second["route_note"], serde_json::Value::Null);
935 assert_eq!(
936 second["transport_source"],
937 serde_json::json!("/ip4/1.2.3.4/udp/9000/quic")
938 );
939 let _ = std::fs::remove_file(&path);
940 }
941}