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    /// `early` (a candidate while discovery is pending), `initial`, or `retry`
178    /// (internal close-group retry sweep). Early probes still fetch and verify
179    /// the content; they are distinct from in-memory `cache_hit` records.
180    pub sweep: String,
181    /// Peer attempt number within the sweep; `None` for chunk-level records
182    /// (`cache_hit`, `lookup_error`, `exhausted`).
183    pub peer_attempt: Option<usize>,
184    /// Closest-peer DHT lookup duration, emitted on the first peer attempt
185    /// associated with that lookup; `None` otherwise.
186    pub lookup_duration_ms: Option<u64>,
187    /// Process-local identifier shared by attempts from one closest-peer lookup.
188    pub lookup_correlation_id: Option<String>,
189    /// One-based ordinal in the selected peer order (one for an early probe).
190    pub selected_peer_ordinal: Option<usize>,
191    /// Peer selected by discovery or the early discovery hints for this attempt;
192    /// `None` for chunk-level records.
193    pub expected_peer: Option<String>,
194    /// Dial addresses selected from the DHT record, in priority order.
195    pub selected_peer_addresses: Option<Vec<String>>,
196    /// Address-type labels parallel to `selected_peer_addresses`.
197    pub selected_peer_address_types: Option<Vec<String>>,
198    /// This client's monotonic last-successful-DHT-interaction age. This is
199    /// local knowledge, not proof of remote uptime.
200    pub local_last_seen_age_ms: Option<u64>,
201    /// Publisher-clock-derived address-set age. The timestamp is untrusted and
202    /// is not proof of remote uptime.
203    pub publisher_address_set_age_ms: Option<u64>,
204    /// Raw publisher wall-clock address-set sequence, when present.
205    pub publisher_address_set_unix_ns: Option<u64>,
206    /// Authenticated peer that supplied the matching response, from the
207    /// `ChunkProtocolResponse` metadata; `None` when no response was received
208    /// (timeout / send failure) or for chunk-level records.
209    pub source_peer: Option<String>,
210    /// Transport address that delivered the response, from the
211    /// `ChunkProtocolResponse` metadata; `None` when no response was received
212    /// or for chunk-level records.
213    pub transport_source: Option<String>,
214    /// `direct`, `relay`, `lan`, `unverified`, or `unknown`, classified from
215    /// the actual transport source via
216    /// `P2PNode::classify_peer_transport_route`. `unknown` for chunk-level
217    /// records with no peer.
218    pub route: String,
219    /// Why `route` is `unknown` when that is the case; `None` once a real
220    /// transport source is classified, and for chunk-level records.
221    pub route_note: Option<String>,
222    /// Whether `node.is_peer_connected(peer)` returned `true` when sampled
223    /// before the send; `None` for chunk-level records.
224    pub peer_connected_before_request: Option<bool>,
225    /// Number of diagnostics-enabled peer requests active in this process
226    /// immediately after this request entered the active set.
227    pub active_requests_at_start: Option<usize>,
228    /// Adaptive fetch concurrency cap snapshot at the time of this record;
229    /// `None` when diagnostics are disabled (never emitted in that case).
230    pub fetch_cap: Option<usize>,
231    /// Elapsed time until the complete response was reassembled and
232    /// delivered; `None` for chunk-level records with no peer attempt.
233    pub response_elapsed_ms: Option<u64>,
234    /// Time to first byte. Always `null` — see `ttfb_unavailable_reason`.
235    pub ttfb_ms: Option<u64>,
236    /// Explicitly `false` so complete-response latency is never presented as
237    /// TTFB.
238    pub ttfb_available: bool,
239    /// Why TTFB is unavailable.
240    pub ttfb_unavailable_reason: String,
241    /// Valid returned chunk bytes; `0` for non-`found` outcomes.
242    pub bytes: u64,
243    /// Attempt outcome. See [`DownloadDiagnosticsOutcome`].
244    pub outcome: DownloadDiagnosticsOutcome,
245    /// Bounded diagnostic error category/detail; no secrets. `None` for
246    /// successful records.
247    pub error: Option<String>,
248}
249
250impl DownloadDiagnosticsRecord {
251    /// The shared TTFB-unavailable reason string used by every record.
252    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    /// The shared route-unknown note used when `classify_peer_transport_route`
257    /// returns `Unknown`: the transport source was absent (no response) or did
258    /// not match any known typed peer dial address.
259    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    /// Build a peer-attempt record. `lookup_duration_ms` is attached only when
264    /// this is the first peer attempt of the sweep (`peer_attempt == 1`).
265    /// `route_note` should be `Some` only when `route` is `"unknown"`.
266    #[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    /// Build a chunk-level record (no peer attempt): cache hit, lookup error,
340    /// or exhausted peer set.
341    #[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/// A cloneable, bounded sender for diagnostic records.
392///
393/// Cloning is cheap (a single `mpsc::Sender` handle). `try_emit` never
394/// blocks: when the bounded channel is full the record is dropped, so a slow
395/// writer cannot stall the download path. Dropped records are counted and the
396/// writer reports the total when it exits.
397#[derive(Clone)]
398pub struct DownloadDiagnosticsSender {
399    tx: mpsc::SyncSender<DownloadDiagnosticsRecord>,
400    dropped: Arc<AtomicU64>,
401}
402
403impl DownloadDiagnosticsSender {
404    /// Enqueue a record without blocking. Drops the record if the bounded
405    /// channel is full.
406    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
413/// Open `<path>` for writing (truncated), spawn a dedicated OS thread that
414/// drains records from a bounded channel and writes one JSON line per record
415/// to a buffered writer, flushing on close.
416///
417/// The writer runs on a plain OS thread (not a tokio task) so synchronous file
418/// writes never block the async runtime. The returned sender is cloneable and
419/// can be threaded through the download path; dropping the last clone closes
420/// the channel. Joining the returned thread handle waits for the final flush
421/// and writer exit.
422///
423/// # Errors
424///
425/// Returns an error if the file cannot be opened or the writer thread cannot
426/// be spawned.
427pub 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                        // A record that cannot be serialized is skipped rather
452                        // than dropping the whole sidecar; the writer keeps
453                        // draining so later valid records survive.
454                        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
471/// Bound an error message to `DIAGNOSTICS_ERROR_MAX_CHARS` chars, preserving
472/// a leading category if one is supplied.
473///
474/// `category` is a short stable label (e.g. `"timeout"`); `detail` is the
475/// free-form error text that may be truncated. No credentials are added — the
476/// caller passes only a bounded diagnostic string.
477pub 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
492/// Format the current UTC time as an RFC 3339 string (`YYYY-MM-DDTHH:MM:SSZ`)
493/// without a `chrono`/`time` dependency.
494fn 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
501/// Current wall-clock Unix time in milliseconds for joining request windows
502/// to external fleet telemetry. Saturates if the platform clock representation
503/// exceeds `u64`.
504pub(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
512/// Convert Unix epoch seconds to an RFC 3339 UTC string.
513///
514/// Uses the well-known civil-from-days algorithm (Howard Hinnant). No leap
515/// seconds; sufficient precision for a diagnostic timestamp.
516fn 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    // Civil date from days since 1970-01-01.
524    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        // Pin field names — schema v4.
609        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        // Explicit unavailable-TTFB representation.
648        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        // Route classified from actual transport source.
658        assert_eq!(obj["route"], serde_json::Value::String("direct".into()));
659        assert_eq!(obj["route_note"], serde_json::Value::Null);
660        // Actual source_peer and transport_source from response metadata.
661        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        // Request bounds, active count, peer state, and fetch cap sampled.
667        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        // First peer attempt carries the lookup duration.
676        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            // No response → no source_peer, no transport_source.
716            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        // +1 for the ellipsis added on truncation.
832        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        // 2021-01-01T00:00:00Z = 1609459200.
845        let s = rfc3339_from_unix_secs(1_609_459_200);
846        assert_eq!(s, "2021-01-01T00:00:00Z");
847        // 1970-01-01T00:00:00Z = 0.
848        assert_eq!(rfc3339_from_unix_secs(0), "1970-01-01T00:00:00Z");
849        // Leap-year day: 2024-02-29T00:00:00Z = 1709164800.
850        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 the last sender: the channel closes and the writer flushes.
919        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}