Skip to main content

zond_engine/core/models/host/
telemetry.rs

1// Copyright (c) 2026 Erik Lening (hollowpointer) and Contributors
2//
3// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4// If a copy of the MPL was not distributed with this file, You can obtain one at
5// https://mozilla.org/MPL/2.0/.
6
7//! # Network Telemetry
8//!
9//! This module provides the [`HostTelemetry`] model for tracking network
10//! performance metrics and path discovery data over time.
11
12use std::{
13    collections::VecDeque,
14    time::{Duration, Instant},
15};
16
17/// Performance and discovery metrics for a specific network host.
18///
19/// `HostTelemetry` maintains a sliding window of Round-Trip Time (RTT)
20/// measurements and performs statistical analysis (Averaging and Jitter)
21/// used for network health assessment.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct HostTelemetry {
24    /// The recent round-trip time measurements with confirmation timestamps.
25    /// Ordered chronologically: oldest at the front, newest at the back.
26    rtt_history: VecDeque<(Instant, Duration)>,
27
28    /// The maximum number of RTT samples to maintain.
29    /// If this limit is reached, adding a new sample will purge the oldest one.
30    pub max_samples: usize,
31
32    /// The Time-to-Live (TTL) value from the most recently received response.
33    pub ttl: Option<u8>,
34
35    /// The calculated network distance in hops, derived from TTL or traceroute probes.
36    pub distance_hops: Option<u8>,
37}
38
39impl HostTelemetry {
40    /// Creates a new `HostTelemetry` instance with a specific sample window size.
41    pub fn new(max_samples: usize) -> Self {
42        Self {
43            rtt_history: VecDeque::with_capacity(max_samples),
44            max_samples,
45            ttl: None,
46            distance_hops: None,
47        }
48    }
49
50    /// Returns a read-only view of the RTT sample history.
51    pub fn history(&self) -> &VecDeque<(Instant, Duration)> {
52        &self.rtt_history
53    }
54
55    /// Adds a new RTT measurement at the current system time.
56    pub fn add_rtt(&mut self, rtt: Duration) {
57        self.add_rtt_at(Instant::now(), rtt);
58    }
59
60    /// Adds a timed RTT measurement to the history, enforcing the sliding window cap.
61    pub fn add_rtt_at(&mut self, time: Instant, rtt: Duration) {
62        if self.max_samples == 0 {
63            return;
64        }
65
66        self.rtt_history.push_back((time, rtt));
67
68        while self.rtt_history.len() > self.max_samples {
69            self.rtt_history.pop_front();
70        }
71    }
72
73    /// Returns the Last-Added Round-Trip Time (LARTT).
74    pub fn lartt(&self) -> Option<Duration> {
75        self.rtt_history.back().map(|&(_, rtt)| rtt)
76    }
77
78    /// Returns the minimum (fastest) RTT recorded in the current window.
79    pub fn min_rtt(&self) -> Option<Duration> {
80        self.rtt_history.iter().map(|(_, rtt)| *rtt).min()
81    }
82
83    /// Returns the maximum (slowest) RTT recorded in the current window.
84    pub fn max_rtt(&self) -> Option<Duration> {
85        self.rtt_history.iter().map(|(_, rtt)| *rtt).max()
86    }
87
88    /// Calculates the arithmetic mean RTT from all samples in the window.
89    pub fn average_rtt(&self) -> Option<Duration> {
90        if self.rtt_history.is_empty() {
91            return None;
92        }
93        let sum: Duration = self.rtt_history.iter().map(|(_, rtt)| *rtt).sum();
94        Some(sum / self.rtt_history.len() as u32)
95    }
96
97    /// Calculates the network jitter as the **Average Absolute Difference**
98    /// between consecutive RTT samples.
99    ///
100    /// Jitter provides a measure of network stability. A high jitter relative
101    /// to the average RTT often indicates network congestion or bufferbloat.
102    pub fn jitter(&self) -> Option<Duration> {
103        if self.rtt_history.len() < 2 {
104            return None;
105        }
106
107        let mut total_diff = Duration::ZERO;
108        let mut prev = self.rtt_history[0].1;
109
110        for &(_, curr) in self.rtt_history.iter().skip(1) {
111            total_diff += curr.abs_diff(prev);
112            prev = curr;
113        }
114
115        Some(total_diff / (self.rtt_history.len() - 1) as u32)
116    }
117
118    /// Merges telemetry from another record, Ensuring chronological sortedness
119    /// and prioritizing the newest data points.
120    ///
121    /// If the incoming record has a larger `max_samples` configuration, this
122    /// telemetry container will upgrade its own window size to match.
123    pub fn merge(&mut self, mut other: HostTelemetry) {
124        if other.max_samples > self.max_samples {
125            self.max_samples = other.max_samples;
126        }
127
128        if self.max_samples == 0 {
129            return;
130        }
131
132        // Interleave and re-sort samples to maintain network timeline
133        let mut combined: Vec<_> = self
134            .rtt_history
135            .drain(..)
136            .chain(other.rtt_history.drain(..))
137            .collect();
138
139        combined.sort_by_key(|&(time, _)| time);
140
141        let start_idx = combined.len().saturating_sub(self.max_samples);
142        self.rtt_history
143            .extend(combined.into_iter().skip(start_idx));
144
145        if self.ttl.is_none() {
146            self.ttl = other.ttl;
147        }
148        if self.distance_hops.is_none() {
149            self.distance_hops = other.distance_hops;
150        }
151    }
152}
153
154impl std::fmt::Display for HostTelemetry {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self.average_rtt() {
157            Some(avg) => write!(
158                f,
159                "avg={:?}, jitter={:?}",
160                avg,
161                self.jitter().unwrap_or(Duration::ZERO)
162            ),
163            None => write!(f, "no telemetry"),
164        }
165    }
166}
167
168impl Default for HostTelemetry {
169    fn default() -> Self {
170        Self::new(10)
171    }
172}
173
174// ╔════════════════════════════════════════════╗
175// ║ ████████╗███████╗███████╗████████╗███████╗ ║
176// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
177// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
178// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
179// ║    ██║   ███████╗███████║   ██║   ███████║ ║
180// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
181// ╚════════════════════════════════════════════╝
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn telemetry_math_safety() {
189        let t = HostTelemetry::new(10);
190        assert_eq!(t.average_rtt(), None);
191        assert_eq!(t.jitter(), None);
192        assert_eq!(t.lartt(), None);
193    }
194
195    #[test]
196    fn telemetry_averaging_logic() {
197        let mut t = HostTelemetry::new(5);
198        t.add_rtt(Duration::from_millis(10));
199        t.add_rtt(Duration::from_millis(20));
200        assert_eq!(t.average_rtt(), Some(Duration::from_millis(15)));
201    }
202
203    #[test]
204    fn jitter_calculation_consistency() {
205        let mut t = HostTelemetry::new(5);
206        t.add_rtt(Duration::from_millis(100)); // prev
207        t.add_rtt(Duration::from_millis(110)); // diff 10
208        t.add_rtt(Duration::from_millis(105)); // diff 5
209        // (10 + 5) / 2 = 7.5ms
210        assert_eq!(
211            t.jitter(),
212            Some(Duration::from_millis(7) + Duration::from_micros(500))
213        );
214    }
215
216    #[test]
217    fn merge_capacity_upgrade() {
218        let mut t1 = HostTelemetry::new(3);
219        let t2 = HostTelemetry::new(10);
220        t1.merge(t2);
221        assert_eq!(t1.max_samples, 10);
222    }
223
224    #[test]
225    fn merge_zero_capacity_safety() {
226        let mut t1 = HostTelemetry::new(0);
227        let t2 = HostTelemetry::new(0);
228        t1.merge(t2);
229        assert_eq!(t1.rtt_history.len(), 0);
230    }
231}