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 += if curr > prev {
112                curr - prev
113            } else {
114                prev - curr
115            };
116            prev = curr;
117        }
118
119        Some(total_diff / (self.rtt_history.len() - 1) as u32)
120    }
121
122    /// Merges telemetry from another record, Ensuring chronological sortedness 
123    /// and prioritizing the newest data points.
124    ///
125    /// If the incoming record has a larger `max_samples` configuration, this 
126    /// telemetry container will upgrade its own window size to match.
127    pub fn merge(&mut self, mut other: HostTelemetry) {
128        if other.max_samples > self.max_samples {
129            self.max_samples = other.max_samples;
130        }
131
132        if self.max_samples == 0 {
133            return;
134        }
135
136        // Interleave and re-sort samples to maintain network timeline
137        let mut combined: Vec<_> = self
138            .rtt_history
139            .drain(..)
140            .chain(other.rtt_history.drain(..))
141            .collect();
142
143        combined.sort_by_key(|&(time, _)| time);
144
145        let start_idx = combined.len().saturating_sub(self.max_samples);
146        self.rtt_history
147            .extend(combined.into_iter().skip(start_idx));
148
149        if self.ttl.is_none() {
150            self.ttl = other.ttl;
151        }
152        if self.distance_hops.is_none() {
153            self.distance_hops = other.distance_hops;
154        }
155    }
156}
157
158impl std::fmt::Display for HostTelemetry {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self.average_rtt() {
161            Some(avg) => write!(
162                f,
163                "avg={:?}, jitter={:?}",
164                avg,
165                self.jitter().unwrap_or(Duration::ZERO)
166            ),
167            None => write!(f, "no telemetry"),
168        }
169    }
170}
171
172impl Default for HostTelemetry {
173    fn default() -> Self {
174        Self::new(10)
175    }
176}
177
178// ╔════════════════════════════════════════════╗
179// ║ ████████╗███████╗███████╗████████╗███████╗ ║
180// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
181// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
182// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
183// ║    ██║   ███████╗███████║   ██║   ███████║ ║
184// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
185// ╚════════════════════════════════════════════╝
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn telemetry_math_safety() {
193        let t = HostTelemetry::new(10);
194        assert_eq!(t.average_rtt(), None);
195        assert_eq!(t.jitter(), None);
196        assert_eq!(t.lartt(), None);
197    }
198
199    #[test]
200    fn telemetry_averaging_logic() {
201        let mut t = HostTelemetry::new(5);
202        t.add_rtt(Duration::from_millis(10));
203        t.add_rtt(Duration::from_millis(20));
204        assert_eq!(t.average_rtt(), Some(Duration::from_millis(15)));
205    }
206
207    #[test]
208    fn jitter_calculation_consistency() {
209        let mut t = HostTelemetry::new(5);
210        t.add_rtt(Duration::from_millis(100)); // prev
211        t.add_rtt(Duration::from_millis(110)); // diff 10
212        t.add_rtt(Duration::from_millis(105)); // diff 5
213        // (10 + 5) / 2 = 7.5ms
214        assert_eq!(t.jitter(), Some(Duration::from_millis(7) + Duration::from_micros(500)));
215    }
216
217    #[test]
218    fn merge_capacity_upgrade() {
219        let mut t1 = HostTelemetry::new(3);
220        let t2 = HostTelemetry::new(10);
221        t1.merge(t2);
222        assert_eq!(t1.max_samples, 10);
223    }
224
225    #[test]
226    fn merge_zero_capacity_safety() {
227        let mut t1 = HostTelemetry::new(0);
228        let t2 = HostTelemetry::new(0);
229        t1.merge(t2);
230        assert_eq!(t1.rtt_history.len(), 0);
231    }
232}