`SampleMode::Exact` retained exact samples for eight of the eleven public timing metrics. Send call, timer error, and server processing always built their `TimeMetric` with retention disabled, so a finite run reported no median for them even though they are the same public `TimeStats` type and appear in the same summary table with a Median column. Derive one `retain_exact` decision from the sample mode in `CoreStats::new` and apply it to every timing metric, so exact mode means the same thing for all of them. Running-only mode is unchanged and still retains none of them. The retention estimator counted eight exact sample streams per measurable reply. Rename it to the eleven exact samples an ordinary successful probe can retain and document the wider optional-measurement upper bound, which raises the finite estimate on this target from 668 to 764 bytes per probe. A test counts the metrics a fully measured probe actually retains and compares it against that constant, so retention and its estimate cannot drift apart unnoticed. No new median-capability type was needed: `TimeStats::median_ns` already distinguishes a retained median from an absent one, and an optional measurement the server never supplied still produces no samples at all.
161 lines
4.5 KiB
Rust
161 lines
4.5 KiB
Rust
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
/// Summary statistics for timing values.
|
|
///
|
|
/// All fields use nanoseconds except `variance_ns2`, which is nanoseconds
|
|
/// squared. Values may represent signed timing quantities such as scheduling
|
|
/// error or one-way delay.
|
|
pub struct TimeStats {
|
|
/// Number of samples included in this summary.
|
|
pub count: u64,
|
|
/// Sum of all samples, in nanoseconds.
|
|
pub total_ns: i128,
|
|
/// Smallest sample, in nanoseconds.
|
|
pub min_ns: Option<i128>,
|
|
/// Largest sample, in nanoseconds.
|
|
pub max_ns: Option<i128>,
|
|
/// Arithmetic mean, in nanoseconds.
|
|
pub mean_ns: f64,
|
|
/// Median, in nanoseconds, when exact samples were retained.
|
|
///
|
|
/// This is `None` when the collector was not configured to retain exact
|
|
/// samples, and also when it was but this metric received none.
|
|
pub median_ns: Option<f64>,
|
|
/// Sample variance, in nanoseconds squared.
|
|
pub variance_ns2: f64,
|
|
}
|
|
|
|
impl TimeStats {
|
|
/// Returns the sample standard deviation, in nanoseconds.
|
|
pub fn stddev_ns(&self) -> f64 {
|
|
self.variance_ns2.sqrt()
|
|
}
|
|
|
|
/// Returns whether this summary contains no samples.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.count == 0
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub(crate) struct TimeMetric {
|
|
running: RunningStats,
|
|
samples: Option<Vec<i128>>,
|
|
}
|
|
|
|
impl TimeMetric {
|
|
pub(crate) fn new(retain_samples: bool) -> Self {
|
|
Self {
|
|
running: RunningStats::default(),
|
|
samples: retain_samples.then(Vec::new),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn push_ns(&mut self, value: i128) {
|
|
self.running.push(value);
|
|
if let Some(samples) = self.samples.as_mut() {
|
|
samples.push(value);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn stats(&self) -> TimeStats {
|
|
self.running
|
|
.stats(self.samples.as_ref().and_then(|samples| median_ns(samples)))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
struct RunningStats {
|
|
count: u64,
|
|
total_ns: i128,
|
|
min_ns: Option<i128>,
|
|
max_ns: Option<i128>,
|
|
mean_ns: f64,
|
|
m2_ns2: f64,
|
|
}
|
|
|
|
impl RunningStats {
|
|
fn push(&mut self, value: i128) {
|
|
self.count += 1;
|
|
self.total_ns = self.total_ns.saturating_add(value);
|
|
self.min_ns = Some(self.min_ns.map_or(value, |min| min.min(value)));
|
|
self.max_ns = Some(self.max_ns.map_or(value, |max| max.max(value)));
|
|
let x = value as f64;
|
|
let delta = x - self.mean_ns;
|
|
self.mean_ns += delta / self.count as f64;
|
|
let delta2 = x - self.mean_ns;
|
|
self.m2_ns2 += delta * delta2;
|
|
}
|
|
|
|
fn stats(&self, median_ns: Option<f64>) -> TimeStats {
|
|
TimeStats {
|
|
count: self.count,
|
|
total_ns: self.total_ns,
|
|
min_ns: self.min_ns,
|
|
max_ns: self.max_ns,
|
|
mean_ns: if self.count == 0 { 0.0 } else { self.mean_ns },
|
|
median_ns,
|
|
variance_ns2: sample_variance(self.count, self.m2_ns2),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sample_variance(count: u64, m2: f64) -> f64 {
|
|
if count < 2 {
|
|
0.0
|
|
} else {
|
|
m2 / (count - 1) as f64
|
|
}
|
|
}
|
|
|
|
fn median_ns(samples: &[i128]) -> Option<f64> {
|
|
if samples.is_empty() {
|
|
return None;
|
|
}
|
|
let mut sorted = samples.to_vec();
|
|
sorted.sort_unstable();
|
|
let mid = sorted.len() / 2;
|
|
Some(if sorted.len() % 2 == 1 {
|
|
sorted[mid] as f64
|
|
} else {
|
|
(sorted[mid - 1] as f64 + sorted[mid] as f64) / 2.0
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn running_duration_stats_use_sample_variance() {
|
|
let mut metric = TimeMetric::new(false);
|
|
metric.push_ns(1);
|
|
metric.push_ns(2);
|
|
metric.push_ns(3);
|
|
let stats = metric.stats();
|
|
assert_eq!(stats.count, 3);
|
|
assert_eq!(stats.total_ns, 6);
|
|
assert_eq!(stats.min_ns, Some(1));
|
|
assert_eq!(stats.max_ns, Some(3));
|
|
assert_eq!(stats.mean_ns, 2.0);
|
|
assert_eq!(stats.variance_ns2, 1.0);
|
|
assert_eq!(stats.stddev_ns(), 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn exact_median_handles_odd_and_even_samples() {
|
|
assert_eq!(median_ns(&[3, 1, 2]), Some(2.0));
|
|
assert_eq!(median_ns(&[4, 1, 2, 3]), Some(2.5));
|
|
assert_eq!(median_ns(&[-5, 1, 3]), Some(1.0));
|
|
assert_eq!(median_ns(&[-5, 1, 3, 7]), Some(2.0));
|
|
}
|
|
|
|
#[test]
|
|
fn single_sample_stddev_is_zero() {
|
|
let mut metric = TimeMetric::new(false);
|
|
metric.push_ns(42);
|
|
let stats = metric.stats();
|
|
assert_eq!(stats.variance_ns2, 0.0);
|
|
assert_eq!(stats.stddev_ns(), 0.0);
|
|
}
|
|
}
|