Skip to main content

ant_core/data/client/
diagnostics.rs

1//! Normal-path download diagnostics instrumentation.
2//!
3//! Runtime-gated sidecar JSONL writer for `ant file download
4//! --download-diagnostics <PATH>`. One record is emitted per normal-path
5//! chunk fetch attempt (cache hit, per-peer attempt, lookup failure, or
6//! exhausted peer set) while the existing early-return / retry /
7//! adaptive-concurrency / stdout behaviour is preserved.
8//!
9//! When the `--download-diagnostics` flag is absent, no channel, file, or
10//! writer is created and the download path is unchanged. The optional sender
11//! threaded through the file/chunk download path is `None`, so record
12//! construction is skipped entirely (no allocation, no I/O).
13//!
14//! # Schema v4: exact node/client request correlation
15//!
16//! Peer-attempt records carry the request ID allocated by this client and the
17//! client's local peer ID. The same request ID is encoded on the chunk GET,
18//! while the peer ID matches the serving node's `source_peer`, permitting an
19//! exact join to node-side GET telemetry. Chunk-level records leave both
20//! fields `null` because no individual peer request was sent.
21//!
22//! The stacked `ant-protocol` PR WithAutonomi/ant-protocol#32 exposes
23//! `send_and_await_chunk_response_with_metadata`, which returns a
24//! `ChunkProtocolResponse { result, source_peer, transport_source }`. The
25//! stacked `saorsa-core` PR WithAutonomi/saorsa-core#162 exposes
26//! `P2PNode::classify_peer_transport_route(expected_peer,
27//! transport_source)` returning a `PeerRouteKind`
28//! (`direct`/`relay`/`lan`/`unverified`/`unknown`). Schema v4 records the
29//! *actual* `source_peer` and `transport_source` from the observed response,
30//! classifies the route from the actual transport source against the peer's
31//! typed DHT addresses, and attaches a `route_note` only when the route is
32//! `unknown`. A `peer_connected_before_request` sample
33//! (`node.is_peer_connected(peer)` called before the send) and an adaptive
34//! `fetch_cap` snapshot are included on every record.
35//!
36//! # TTFB limitation
37//!
38//! The protocol event is emitted only after complete message reassembly, so
39//! this branch measures complete-response latency (`response_elapsed_ms`),
40//! not true network time-to-first-byte. `ttfb_ms` is always `null`,
41//! `ttfb_available` is `false`, and `ttfb_unavailable_reason` carries the
42//! explanation. This prevents complete-response latency being presented as
43//! TTFB.
44
45use 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
56/// Current diagnostic record schema discriminator.
57pub const DIAGNOSTICS_SCHEMA_VERSION: u8 = 4;
58
59/// Correlation values captured once for a peer request and shared by both
60/// the encoded protocol message and its diagnostic record.
61#[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
76/// Bounded capacity of the diagnostics channel. A slow writer must not create
77/// unbounded memory growth: when full, further records are dropped (counted
78/// via `try_send`), which is acceptable for a best-effort diagnostic sidecar.
79const DIAGNOSTICS_CHANNEL_CAPACITY: usize = 1024;
80
81/// Upper bound on the length of the `error` string we serialize, so a verbose
82/// remote error message cannot balloon the sidecar file. The category prefix
83/// is always preserved; only the trailing detail is truncated.
84const DIAGNOSTICS_ERROR_MAX_CHARS: usize = 240;
85
86/// The outcome of a single normal-path chunk fetch attempt.
87///
88/// Each variant maps to a stable lowercase string used as the `outcome` JSON
89/// field. Variants are intentionally exhaustive over the record categories
90/// listed in the design doc.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum DownloadDiagnosticsOutcome {
93    /// A chunk was successfully fetched from a peer.
94    Found,
95    /// A queried peer responded `NotFound`.
96    NotFound,
97    /// A peer attempt timed out waiting for a response.
98    Timeout,
99    /// A peer attempt failed at the transport / dial / send layer.
100    NetworkError,
101    /// A peer responded with a structured protocol-level error (e.g. a
102    /// corrupted-chunk `ChunkGetResponse::Error` or a content/address
103    /// mismatch).
104    ProtocolError,
105    /// The chunk was served from the in-memory cache; no peer was contacted.
106    CacheHit,
107    /// The DHT closest-peer lookup itself failed before any peer was queried.
108    LookupError,
109    /// A sweep queried every selected peer without success.
110    Exhausted,
111}
112
113impl DownloadDiagnosticsOutcome {
114    /// Canonical lowercase label for the `outcome` JSON field.
115    #[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/// One JSONL record for a normal-path chunk fetch attempt.
146///
147/// Field names are stable and pinned by the serialization tests. `null` JSON
148/// values are used for fields that do not apply to a given record kind (e.g.
149/// `peer_attempt` / `expected_peer` / `source_peer` / `lookup_duration_ms` are
150/// `null` for a cache hit, which has no peer).
151#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
152pub struct DownloadDiagnosticsRecord {
153    /// Stable schema discriminator, currently `4`.
154    pub schema_version: u8,
155    /// UTC time the attempt completed, RFC 3339 (`YYYY-MM-DDTHH:MM:SSZ`).
156    pub timestamp: String,
157    /// Wall-clock Unix time immediately before the peer request, in
158    /// milliseconds. Together with `request_completed_unix_ms`, this permits
159    /// request overlap to be reconstructed against fleet telemetry.
160    pub request_started_unix_ms: Option<u64>,
161    /// Wall-clock Unix time immediately after the peer request completed, in
162    /// milliseconds. `None` for chunk-level records.
163    pub request_completed_unix_ms: Option<u64>,
164    /// Protocol request identifier allocated by this client and sent on the
165    /// wire; the serving node's record for this GET carries the same value.
166    /// `None` for chunk-level records where no peer request was sent.
167    pub request_id: Option<u64>,
168    /// This diagnostic client's local peer ID, matching the serving node's
169    /// `source_peer` field. `None` for chunk-level records.
170    pub local_peer_id: Option<String>,
171    /// Outer file / deferred-retry attempt number (1 = first pass).
172    pub file_attempt: usize,
173    /// Chunk index within the file (1-based, matching the progress reports).
174    pub chunk_index: usize,
175    /// Hex-encoded chunk address.
176    pub chunk_address: String,
177    /// `initial` or `retry` (internal close-group retry sweep).
178    pub sweep: String,
179    /// Peer attempt number within the sweep; `None` for chunk-level records
180    /// (`cache_hit`, `lookup_error`, `exhausted`).
181    pub peer_attempt: Option<usize>,
182    /// Closest-peer DHT lookup duration, emitted on the first peer attempt
183    /// associated with that lookup; `None` otherwise.
184    pub lookup_duration_ms: Option<u64>,
185    /// Process-local identifier shared by attempts from one closest-peer lookup.
186    pub lookup_correlation_id: Option<String>,
187    /// One-based ordinal in the DHT-selected peer order.
188    pub selected_peer_ordinal: Option<usize>,
189    /// Peer selected by the DHT lookup for this attempt; `None` for chunk-level
190    /// records.
191    pub expected_peer: Option<String>,
192    /// Dial addresses selected from the DHT record, in priority order.
193    pub selected_peer_addresses: Option<Vec<String>>,
194    /// Address-type labels parallel to `selected_peer_addresses`.
195    pub selected_peer_address_types: Option<Vec<String>>,
196    /// This client's monotonic last-successful-DHT-interaction age. This is
197    /// local knowledge, not proof of remote uptime.
198    pub local_last_seen_age_ms: Option<u64>,
199    /// Publisher-clock-derived address-set age. The timestamp is untrusted and
200    /// is not proof of remote uptime.
201    pub publisher_address_set_age_ms: Option<u64>,
202    /// Raw publisher wall-clock address-set sequence, when present.
203    pub publisher_address_set_unix_ns: Option<u64>,
204    /// Authenticated peer that supplied the matching response, from the
205    /// `ChunkProtocolResponse` metadata; `None` when no response was received
206    /// (timeout / send failure) or for chunk-level records.
207    pub source_peer: Option<String>,
208    /// Transport address that delivered the response, from the
209    /// `ChunkProtocolResponse` metadata; `None` when no response was received
210    /// or for chunk-level records.
211    pub transport_source: Option<String>,
212    /// `direct`, `relay`, `lan`, `unverified`, or `unknown`, classified from
213    /// the actual transport source via
214    /// `P2PNode::classify_peer_transport_route`. `unknown` for chunk-level
215    /// records with no peer.
216    pub route: String,
217    /// Why `route` is `unknown` when that is the case; `None` once a real
218    /// transport source is classified, and for chunk-level records.
219    pub route_note: Option<String>,
220    /// Whether `node.is_peer_connected(peer)` returned `true` when sampled
221    /// before the send; `None` for chunk-level records.
222    pub peer_connected_before_request: Option<bool>,
223    /// Number of diagnostics-enabled peer requests active in this process
224    /// immediately after this request entered the active set.
225    pub active_requests_at_start: Option<usize>,
226    /// Adaptive fetch concurrency cap snapshot at the time of this record;
227    /// `None` when diagnostics are disabled (never emitted in that case).
228    pub fetch_cap: Option<usize>,
229    /// Elapsed time until the complete response was reassembled and
230    /// delivered; `None` for chunk-level records with no peer attempt.
231    pub response_elapsed_ms: Option<u64>,
232    /// Time to first byte. Always `null` — see `ttfb_unavailable_reason`.
233    pub ttfb_ms: Option<u64>,
234    /// Explicitly `false` so complete-response latency is never presented as
235    /// TTFB.
236    pub ttfb_available: bool,
237    /// Why TTFB is unavailable.
238    pub ttfb_unavailable_reason: String,
239    /// Valid returned chunk bytes; `0` for non-`found` outcomes.
240    pub bytes: u64,
241    /// Attempt outcome. See [`DownloadDiagnosticsOutcome`].
242    pub outcome: DownloadDiagnosticsOutcome,
243    /// Bounded diagnostic error category/detail; no secrets. `None` for
244    /// successful records.
245    pub error: Option<String>,
246}
247
248impl DownloadDiagnosticsRecord {
249    /// The shared TTFB-unavailable reason string used by every record.
250    pub const TTFB_UNAVAILABLE_REASON: &'static str =
251        "protocol exposes only a complete-response event; first-byte/first-frame \
252         timing is not available";
253
254    /// The shared route-unknown note used when `classify_peer_transport_route`
255    /// returns `Unknown`: the transport source was absent (no response) or did
256    /// not match any known typed peer dial address.
257    pub const ROUTE_UNKNOWN_NOTE: &'static str =
258        "transport_source absent or did not match any known typed peer dial address; \
259         route could not be classified from the observed response";
260
261    /// Build a peer-attempt record. `lookup_duration_ms` is attached only when
262    /// this is the first peer attempt of the sweep (`peer_attempt == 1`).
263    /// `route_note` should be `Some` only when `route` is `"unknown"`.
264    #[allow(clippy::too_many_arguments)]
265    pub(crate) fn peer_attempt(
266        file_attempt: usize,
267        chunk_index: usize,
268        chunk_address: &[u8; 32],
269        sweep: &'static str,
270        peer_attempt: usize,
271        lookup_duration_ms: Option<u64>,
272        lookup_correlation_id: &str,
273        expected_peer: &str,
274        selected_peer_addresses: Vec<String>,
275        selected_peer_address_types: Vec<String>,
276        local_last_seen_age_ms: Option<u64>,
277        publisher_address_set_age_ms: Option<u64>,
278        publisher_address_set_unix_ns: Option<u64>,
279        source_peer: Option<&str>,
280        transport_source: Option<&str>,
281        route: &str,
282        route_note: Option<&str>,
283        peer_connected_before_request: Option<bool>,
284        active_requests_at_start: Option<usize>,
285        fetch_cap: Option<usize>,
286        request_started_unix_ms: u64,
287        request_completed_unix_ms: u64,
288        correlation: &DownloadRequestCorrelation,
289        response_elapsed_ms: u64,
290        bytes: u64,
291        outcome: DownloadDiagnosticsOutcome,
292        error: Option<String>,
293    ) -> Self {
294        let lookup = if peer_attempt == 1 {
295            lookup_duration_ms
296        } else {
297            None
298        };
299        Self {
300            schema_version: DIAGNOSTICS_SCHEMA_VERSION,
301            timestamp: utc_now_rfc3339(),
302            request_started_unix_ms: Some(request_started_unix_ms),
303            request_completed_unix_ms: Some(request_completed_unix_ms),
304            request_id: Some(correlation.request_id),
305            local_peer_id: Some(correlation.local_peer_id.clone()),
306            file_attempt,
307            chunk_index,
308            chunk_address: hex::encode(chunk_address),
309            sweep: sweep.to_string(),
310            peer_attempt: Some(peer_attempt),
311            lookup_duration_ms: lookup,
312            lookup_correlation_id: Some(lookup_correlation_id.to_string()),
313            selected_peer_ordinal: Some(peer_attempt),
314            expected_peer: Some(expected_peer.to_string()),
315            selected_peer_addresses: Some(selected_peer_addresses),
316            selected_peer_address_types: Some(selected_peer_address_types),
317            local_last_seen_age_ms,
318            publisher_address_set_age_ms,
319            publisher_address_set_unix_ns,
320            source_peer: source_peer.map(str::to_string),
321            transport_source: transport_source.map(str::to_string),
322            route: route.to_string(),
323            route_note: route_note.map(str::to_string),
324            peer_connected_before_request,
325            active_requests_at_start,
326            fetch_cap,
327            response_elapsed_ms: Some(response_elapsed_ms),
328            ttfb_ms: None,
329            ttfb_available: false,
330            ttfb_unavailable_reason: Self::TTFB_UNAVAILABLE_REASON.to_string(),
331            bytes,
332            outcome,
333            error,
334        }
335    }
336
337    /// Build a chunk-level record (no peer attempt): cache hit, lookup error,
338    /// or exhausted peer set.
339    #[allow(clippy::too_many_arguments)]
340    pub fn chunk_level(
341        file_attempt: usize,
342        chunk_index: usize,
343        chunk_address: &[u8; 32],
344        sweep: &'static str,
345        fetch_cap: Option<usize>,
346        bytes: u64,
347        outcome: DownloadDiagnosticsOutcome,
348        error: Option<String>,
349    ) -> Self {
350        Self {
351            schema_version: DIAGNOSTICS_SCHEMA_VERSION,
352            timestamp: utc_now_rfc3339(),
353            request_started_unix_ms: None,
354            request_completed_unix_ms: None,
355            request_id: None,
356            local_peer_id: None,
357            file_attempt,
358            chunk_index,
359            chunk_address: hex::encode(chunk_address),
360            sweep: sweep.to_string(),
361            peer_attempt: None,
362            lookup_duration_ms: None,
363            lookup_correlation_id: None,
364            selected_peer_ordinal: None,
365            expected_peer: None,
366            selected_peer_addresses: None,
367            selected_peer_address_types: None,
368            local_last_seen_age_ms: None,
369            publisher_address_set_age_ms: None,
370            publisher_address_set_unix_ns: None,
371            source_peer: None,
372            transport_source: None,
373            route: "unknown".to_string(),
374            route_note: None,
375            peer_connected_before_request: None,
376            active_requests_at_start: None,
377            fetch_cap,
378            response_elapsed_ms: None,
379            ttfb_ms: None,
380            ttfb_available: false,
381            ttfb_unavailable_reason: Self::TTFB_UNAVAILABLE_REASON.to_string(),
382            bytes,
383            outcome,
384            error,
385        }
386    }
387}
388
389/// A cloneable, bounded sender for diagnostic records.
390///
391/// Cloning is cheap (a single `mpsc::Sender` handle). `try_emit` never
392/// blocks: when the bounded channel is full the record is dropped, so a slow
393/// writer cannot stall the download path. Dropped records are counted and the
394/// writer reports the total when it exits.
395#[derive(Clone)]
396pub struct DownloadDiagnosticsSender {
397    tx: mpsc::SyncSender<DownloadDiagnosticsRecord>,
398    dropped: Arc<AtomicU64>,
399}
400
401impl DownloadDiagnosticsSender {
402    /// Enqueue a record without blocking. Drops the record if the bounded
403    /// channel is full.
404    pub fn try_emit(&self, record: DownloadDiagnosticsRecord) {
405        if self.tx.try_send(record).is_err() {
406            self.dropped.fetch_add(1, Ordering::Relaxed);
407        }
408    }
409}
410
411/// Open `<path>` for writing (truncated), spawn a dedicated OS thread that
412/// drains records from a bounded channel and writes one JSON line per record
413/// to a buffered writer, flushing on close.
414///
415/// The writer runs on a plain OS thread (not a tokio task) so synchronous file
416/// writes never block the async runtime. The returned sender is cloneable and
417/// can be threaded through the download path; dropping the last clone closes
418/// the channel. Joining the returned thread handle waits for the final flush
419/// and writer exit.
420///
421/// # Errors
422///
423/// Returns an error if the file cannot be opened or the writer thread cannot
424/// be spawned.
425pub fn spawn_download_diagnostics_writer(
426    path: &Path,
427) -> io::Result<(DownloadDiagnosticsSender, std::thread::JoinHandle<()>)> {
428    let file = std::fs::OpenOptions::new()
429        .create(true)
430        .write(true)
431        .truncate(true)
432        .open(path)?;
433    let (tx, rx) = mpsc::sync_channel::<DownloadDiagnosticsRecord>(DIAGNOSTICS_CHANNEL_CAPACITY);
434    let dropped = Arc::new(AtomicU64::new(0));
435    let writer_dropped = Arc::clone(&dropped);
436    let builder = std::thread::Builder::new().name("ant-download-diagnostics-writer".to_string());
437    let handle = builder
438        .spawn(move || {
439            let mut writer = io::BufWriter::new(file);
440            for record in rx.iter() {
441                match serde_json::to_string(&record) {
442                    Ok(line) => {
443                        if let Err(err) = writeln!(writer, "{line}") {
444                            error!(%err, "download diagnostics sidecar write failed");
445                            break;
446                        }
447                    }
448                    Err(err) => {
449                        // A record that cannot be serialized is skipped rather
450                        // than dropping the whole sidecar; the writer keeps
451                        // draining so later valid records survive.
452                        error!(%err, "download diagnostics record serialization failed");
453                        continue;
454                    }
455                }
456            }
457            if let Err(err) = writer.flush() {
458                error!(%err, "download diagnostics sidecar flush failed");
459            }
460            let dropped = writer_dropped.load(Ordering::Relaxed);
461            if dropped > 0 {
462                warn!(dropped, "download diagnostics records were dropped");
463            }
464        })
465        .map_err(|e| io::Error::other(format!("failed to spawn diagnostics writer thread: {e}")))?;
466    Ok((DownloadDiagnosticsSender { tx, dropped }, handle))
467}
468
469/// Bound an error message to `DIAGNOSTICS_ERROR_MAX_CHARS` chars, preserving
470/// a leading category if one is supplied.
471///
472/// `category` is a short stable label (e.g. `"timeout"`); `detail` is the
473/// free-form error text that may be truncated. No credentials are added — the
474/// caller passes only a bounded diagnostic string.
475pub fn bounded_error(category: &str, detail: &str) -> String {
476    let prefix = if category.is_empty() {
477        String::new()
478    } else {
479        format!("{category}: ")
480    };
481    if prefix.len() + detail.len() <= DIAGNOSTICS_ERROR_MAX_CHARS {
482        return format!("{prefix}{detail}");
483    }
484    let remaining = DIAGNOSTICS_ERROR_MAX_CHARS.saturating_sub(prefix.len());
485    let mut truncated: String = detail.chars().take(remaining.saturating_sub(1)).collect();
486    truncated.push('…');
487    format!("{prefix}{truncated}")
488}
489
490/// Format the current UTC time as an RFC 3339 string (`YYYY-MM-DDTHH:MM:SSZ`)
491/// without a `chrono`/`time` dependency.
492fn utc_now_rfc3339() -> String {
493    let now = SystemTime::now()
494        .duration_since(UNIX_EPOCH)
495        .unwrap_or_default();
496    rfc3339_from_unix_secs(now.as_secs())
497}
498
499/// Current wall-clock Unix time in milliseconds for joining request windows
500/// to external fleet telemetry. Saturates if the platform clock representation
501/// exceeds `u64`.
502pub(crate) fn unix_now_ms() -> u64 {
503    let millis = SystemTime::now()
504        .duration_since(UNIX_EPOCH)
505        .unwrap_or_default()
506        .as_millis();
507    u64::try_from(millis).unwrap_or(u64::MAX)
508}
509
510/// Convert Unix epoch seconds to an RFC 3339 UTC string.
511///
512/// Uses the well-known civil-from-days algorithm (Howard Hinnant). No leap
513/// seconds; sufficient precision for a diagnostic timestamp.
514fn rfc3339_from_unix_secs(secs: u64) -> String {
515    let days = (secs / 86_400) as i64;
516    let secs_of_day = secs % 86_400;
517    let hour = secs_of_day / 3600;
518    let minute = (secs_of_day % 3600) / 60;
519    let second = secs_of_day % 60;
520
521    // Civil date from days since 1970-01-01.
522    let z = days + 719_468;
523    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
524    let doe = z - era * 146_097;
525    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
526    let y = yoe + era * 400;
527    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
528    let mp = (5 * doy + 2) / 153;
529    let d = doy - (153 * mp + 2) / 5 + 1;
530    let m = if mp < 10 { mp + 3 } else { mp - 9 };
531    let year = if m <= 2 { y + 1 } else { y };
532
533    format!("{year:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn test_correlation(request_id: u64) -> DownloadRequestCorrelation {
541        DownloadRequestCorrelation::new(request_id, &PeerId::from_bytes([42; 32]))
542    }
543
544    #[test]
545    fn outcome_as_str_is_stable_lowercase() {
546        assert_eq!(DownloadDiagnosticsOutcome::Found.as_str(), "found");
547        assert_eq!(DownloadDiagnosticsOutcome::NotFound.as_str(), "not_found");
548        assert_eq!(DownloadDiagnosticsOutcome::Timeout.as_str(), "timeout");
549        assert_eq!(
550            DownloadDiagnosticsOutcome::NetworkError.as_str(),
551            "network_error"
552        );
553        assert_eq!(
554            DownloadDiagnosticsOutcome::ProtocolError.as_str(),
555            "protocol_error"
556        );
557        assert_eq!(DownloadDiagnosticsOutcome::CacheHit.as_str(), "cache_hit");
558        assert_eq!(
559            DownloadDiagnosticsOutcome::LookupError.as_str(),
560            "lookup_error"
561        );
562        assert_eq!(DownloadDiagnosticsOutcome::Exhausted.as_str(), "exhausted");
563    }
564
565    #[test]
566    fn outcome_serializes_as_lowercase_string() {
567        let v = serde_json::to_string(&DownloadDiagnosticsOutcome::NetworkError).unwrap();
568        assert_eq!(v, "\"network_error\"");
569    }
570
571    #[test]
572    fn peer_attempt_record_pins_field_names_and_null_ttfb() {
573        let addr = [7u8; 32];
574        let correlation = test_correlation(9_001);
575        let record = DownloadDiagnosticsRecord::peer_attempt(
576            1,
577            3,
578            &addr,
579            "initial",
580            1,
581            Some(42),
582            "lookup-1",
583            "peer-abc",
584            vec!["/ip4/1.2.3.4/udp/9000/quic".to_string()],
585            vec!["direct".to_string()],
586            Some(1_500),
587            Some(2_000),
588            Some(1_234_000_000),
589            Some("peer-abc"),
590            Some("/ip4/1.2.3.4/udp/9000/quic"),
591            "direct",
592            None,
593            Some(true),
594            Some(8),
595            Some(8),
596            120,
597            1024,
598            &correlation,
599            904,
600            1024,
601            DownloadDiagnosticsOutcome::Found,
602            None,
603        );
604        let json = serde_json::to_value(&record).unwrap();
605        let obj = json.as_object().unwrap();
606        // Pin field names — schema v4.
607        for field in [
608            "schema_version",
609            "timestamp",
610            "request_started_unix_ms",
611            "request_completed_unix_ms",
612            "request_id",
613            "local_peer_id",
614            "file_attempt",
615            "chunk_index",
616            "chunk_address",
617            "sweep",
618            "peer_attempt",
619            "lookup_duration_ms",
620            "lookup_correlation_id",
621            "selected_peer_ordinal",
622            "expected_peer",
623            "selected_peer_addresses",
624            "selected_peer_address_types",
625            "local_last_seen_age_ms",
626            "publisher_address_set_age_ms",
627            "publisher_address_set_unix_ns",
628            "source_peer",
629            "transport_source",
630            "route",
631            "route_note",
632            "peer_connected_before_request",
633            "active_requests_at_start",
634            "fetch_cap",
635            "response_elapsed_ms",
636            "ttfb_ms",
637            "ttfb_available",
638            "ttfb_unavailable_reason",
639            "bytes",
640            "outcome",
641            "error",
642        ] {
643            assert!(obj.contains_key(field), "missing field {field}");
644        }
645        // Explicit unavailable-TTFB representation.
646        assert_eq!(obj["ttfb_ms"], serde_json::Value::Null);
647        assert_eq!(obj["ttfb_available"], serde_json::Value::Bool(false));
648        assert!(
649            obj["ttfb_unavailable_reason"]
650                .as_str()
651                .unwrap()
652                .contains("first-byte"),
653            "ttfb reason must mention first-byte"
654        );
655        // Route classified from actual transport source.
656        assert_eq!(obj["route"], serde_json::Value::String("direct".into()));
657        assert_eq!(obj["route_note"], serde_json::Value::Null);
658        // Actual source_peer and transport_source from response metadata.
659        assert_eq!(obj["source_peer"], serde_json::json!("peer-abc"));
660        assert_eq!(
661            obj["transport_source"],
662            serde_json::json!("/ip4/1.2.3.4/udp/9000/quic")
663        );
664        // Request bounds, active count, peer state, and fetch cap sampled.
665        assert_eq!(obj["request_started_unix_ms"], serde_json::json!(120u64));
666        assert_eq!(obj["request_completed_unix_ms"], serde_json::json!(1024u64));
667        assert_eq!(obj["active_requests_at_start"], serde_json::json!(8usize));
668        assert_eq!(
669            obj["peer_connected_before_request"],
670            serde_json::json!(true)
671        );
672        assert_eq!(obj["fetch_cap"], serde_json::json!(8usize));
673        // First peer attempt carries the lookup duration.
674        assert_eq!(obj["lookup_duration_ms"], serde_json::json!(42u64));
675        assert_eq!(obj["bytes"], serde_json::json!(1024u64));
676        assert_eq!(obj["outcome"], serde_json::json!("found"));
677        assert_eq!(obj["request_id"], serde_json::json!(9_001u64));
678        assert_eq!(
679            obj["local_peer_id"],
680            serde_json::json!(correlation.local_peer_id)
681        );
682        assert_eq!(obj["schema_version"], serde_json::json!(4u8));
683        assert_eq!(obj["lookup_correlation_id"], serde_json::json!("lookup-1"));
684        assert_eq!(obj["selected_peer_ordinal"], serde_json::json!(1usize));
685        assert_eq!(obj["local_last_seen_age_ms"], serde_json::json!(1_500u64));
686        assert_eq!(
687            obj["publisher_address_set_age_ms"],
688            serde_json::json!(2_000u64)
689        );
690        assert_eq!(
691            obj["chunk_address"],
692            serde_json::Value::String(hex::encode(addr))
693        );
694    }
695
696    #[test]
697    fn later_peer_attempt_omits_lookup_duration_and_carries_route_note_when_unknown() {
698        let addr = [9u8; 32];
699        let record = DownloadDiagnosticsRecord::peer_attempt(
700            1,
701            1,
702            &addr,
703            "retry",
704            3,
705            Some(10),
706            "lookup-2",
707            "peer-x",
708            vec!["/ip6/2001:db8::1/udp/9000/quic".to_string()],
709            vec!["unverified".to_string()],
710            None,
711            None,
712            None,
713            // No response → no source_peer, no transport_source.
714            None,
715            None,
716            "unknown",
717            Some(DownloadDiagnosticsRecord::ROUTE_UNKNOWN_NOTE),
718            Some(false),
719            Some(4),
720            Some(4),
721            500,
722            1_000,
723            &test_correlation(9_002),
724            500,
725            0,
726            DownloadDiagnosticsOutcome::Timeout,
727            Some(bounded_error("timeout", "no response")),
728        );
729        let json = serde_json::to_value(&record).unwrap();
730        let obj = json.as_object().unwrap();
731        assert_eq!(obj["lookup_duration_ms"], serde_json::Value::Null);
732        assert_eq!(obj["lookup_correlation_id"], serde_json::json!("lookup-2"));
733        assert_eq!(obj["selected_peer_ordinal"], serde_json::json!(3usize));
734        assert_eq!(
735            obj["route_note"],
736            serde_json::json!(DownloadDiagnosticsRecord::ROUTE_UNKNOWN_NOTE)
737        );
738        assert_eq!(obj["source_peer"], serde_json::Value::Null);
739        assert_eq!(obj["transport_source"], serde_json::Value::Null);
740        assert_eq!(obj["route"], serde_json::json!("unknown"));
741        assert_eq!(
742            obj["peer_connected_before_request"],
743            serde_json::json!(false)
744        );
745        assert_eq!(obj["active_requests_at_start"], serde_json::json!(4usize));
746        assert_eq!(obj["fetch_cap"], serde_json::json!(4usize));
747        assert_eq!(obj["outcome"], serde_json::json!("timeout"));
748        assert_eq!(obj["bytes"], serde_json::json!(0u64));
749        assert!(obj["error"].as_str().unwrap().starts_with("timeout: "));
750    }
751
752    #[test]
753    fn cache_hit_record_has_no_peer_fields() {
754        let addr = [1u8; 32];
755        let record = DownloadDiagnosticsRecord::chunk_level(
756            1,
757            2,
758            &addr,
759            "initial",
760            Some(8),
761            4096,
762            DownloadDiagnosticsOutcome::CacheHit,
763            None,
764        );
765        let json = serde_json::to_value(&record).unwrap();
766        let obj = json.as_object().unwrap();
767        assert_eq!(obj["peer_attempt"], serde_json::Value::Null);
768        assert_eq!(obj["expected_peer"], serde_json::Value::Null);
769        assert_eq!(obj["source_peer"], serde_json::Value::Null);
770        assert_eq!(obj["transport_source"], serde_json::Value::Null);
771        assert_eq!(obj["lookup_duration_ms"], serde_json::Value::Null);
772        assert_eq!(obj["lookup_correlation_id"], serde_json::Value::Null);
773        assert_eq!(obj["selected_peer_addresses"], serde_json::Value::Null);
774        assert_eq!(obj["response_elapsed_ms"], serde_json::Value::Null);
775        assert_eq!(
776            obj["peer_connected_before_request"],
777            serde_json::Value::Null
778        );
779        assert_eq!(obj["request_started_unix_ms"], serde_json::Value::Null);
780        assert_eq!(obj["request_completed_unix_ms"], serde_json::Value::Null);
781        assert_eq!(obj["request_id"], serde_json::Value::Null);
782        assert_eq!(obj["local_peer_id"], serde_json::Value::Null);
783        assert_eq!(obj["active_requests_at_start"], serde_json::Value::Null);
784        assert_eq!(obj["fetch_cap"], serde_json::json!(8usize));
785        assert_eq!(obj["route"], serde_json::json!("unknown"));
786        assert_eq!(obj["route_note"], serde_json::Value::Null);
787        assert_eq!(obj["outcome"], serde_json::json!("cache_hit"));
788        assert_eq!(obj["bytes"], serde_json::json!(4096u64));
789    }
790
791    #[test]
792    fn exhausted_and_lookup_error_records_classify_correctly() {
793        let addr = [2u8; 32];
794        let exhausted = DownloadDiagnosticsRecord::chunk_level(
795            2,
796            5,
797            &addr,
798            "retry",
799            Some(2),
800            0,
801            DownloadDiagnosticsOutcome::Exhausted,
802            None,
803        );
804        assert_eq!(
805            serde_json::to_value(&exhausted).unwrap()["outcome"],
806            serde_json::json!("exhausted")
807        );
808
809        let lookup_err = DownloadDiagnosticsRecord::chunk_level(
810            2,
811            5,
812            &addr,
813            "initial",
814            Some(2),
815            0,
816            DownloadDiagnosticsOutcome::LookupError,
817            Some(bounded_error("lookup", "DHT returned no peers")),
818        );
819        let v = serde_json::to_value(&lookup_err).unwrap();
820        assert_eq!(v["outcome"], serde_json::json!("lookup_error"));
821        assert!(v["error"].as_str().unwrap().starts_with("lookup: "));
822    }
823
824    #[test]
825    fn bounded_error_truncates_long_detail() {
826        let long = "x".repeat(10_000);
827        let s = bounded_error("network", &long);
828        assert!(s.starts_with("network: "));
829        // +1 for the ellipsis added on truncation.
830        assert!(s.chars().count() <= DIAGNOSTICS_ERROR_MAX_CHARS);
831        assert!(s.ends_with('…'));
832    }
833
834    #[test]
835    fn bounded_error_preserves_short_detail_intact() {
836        let s = bounded_error("protocol", "mismatched address");
837        assert_eq!(s, "protocol: mismatched address");
838    }
839
840    #[test]
841    fn rfc3339_formatter_is_valid_for_known_epoch() {
842        // 2021-01-01T00:00:00Z = 1609459200.
843        let s = rfc3339_from_unix_secs(1_609_459_200);
844        assert_eq!(s, "2021-01-01T00:00:00Z");
845        // 1970-01-01T00:00:00Z = 0.
846        assert_eq!(rfc3339_from_unix_secs(0), "1970-01-01T00:00:00Z");
847        // Leap-year day: 2024-02-29T00:00:00Z = 1709164800.
848        assert_eq!(
849            rfc3339_from_unix_secs(1_709_164_800),
850            "2024-02-29T00:00:00Z"
851        );
852    }
853
854    #[test]
855    fn disabled_diagnostics_does_not_create_sidecar() {
856        let dir = tempfile::tempdir().unwrap();
857        let path = dir.path().join("disabled.jsonl");
858        let diagnostics: Option<DownloadDiagnosticsSender> = None;
859
860        assert!(diagnostics.is_none());
861        assert!(!path.exists());
862    }
863
864    #[test]
865    fn writer_emits_one_json_line_per_record_and_flushes_on_drop() {
866        let dir = std::env::temp_dir();
867        let path = dir.join(format!(
868            "ant-dl-diag-{}-{}.jsonl",
869            std::process::id(),
870            std::time::SystemTime::now()
871                .duration_since(UNIX_EPOCH)
872                .unwrap()
873                .as_nanos()
874        ));
875        let (sender, writer) = spawn_download_diagnostics_writer(&path).unwrap();
876        let addr = [3u8; 32];
877        sender.try_emit(DownloadDiagnosticsRecord::chunk_level(
878            1,
879            1,
880            &addr,
881            "initial",
882            Some(8),
883            128,
884            DownloadDiagnosticsOutcome::CacheHit,
885            None,
886        ));
887        sender.try_emit(DownloadDiagnosticsRecord::peer_attempt(
888            1,
889            1,
890            &addr,
891            "initial",
892            1,
893            Some(5),
894            "lookup-writer",
895            "peer-z",
896            vec!["/ip4/1.2.3.4/udp/9000/quic".to_string()],
897            vec!["direct".to_string()],
898            Some(50),
899            Some(100),
900            Some(1_234_000_000),
901            Some("peer-z"),
902            Some("/ip4/1.2.3.4/udp/9000/quic"),
903            "direct",
904            None,
905            Some(true),
906            Some(8),
907            Some(8),
908            30,
909            60,
910            &test_correlation(9_003),
911            30,
912            128,
913            DownloadDiagnosticsOutcome::Found,
914            None,
915        ));
916        // Drop the last sender: the channel closes and the writer flushes.
917        drop(sender);
918        writer.join().unwrap();
919        let contents = std::fs::read_to_string(&path).unwrap();
920        let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
921        assert_eq!(
922            lines.len(),
923            2,
924            "expected 2 JSONL records, got: {contents:?}"
925        );
926        let first = serde_json::from_str::<serde_json::Value>(lines[0]).unwrap();
927        assert_eq!(first["outcome"], serde_json::json!("cache_hit"));
928        assert_eq!(first["schema_version"], serde_json::json!(4u8));
929        let second = serde_json::from_str::<serde_json::Value>(lines[1]).unwrap();
930        assert_eq!(second["outcome"], serde_json::json!("found"));
931        assert_eq!(second["route"], serde_json::json!("direct"));
932        assert_eq!(second["route_note"], serde_json::Value::Null);
933        assert_eq!(
934            second["transport_source"],
935            serde_json::json!("/ip4/1.2.3.4/udp/9000/quic")
936        );
937        let _ = std::fs::remove_file(&path);
938    }
939}