From 51d82d997f9c4097b7caa51c618d96c317acab38 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 9 Aug 2026 17:34:06 +0300 Subject: [PATCH] add jitter matrix (p95/p99/cv) to ping stats --- src/netsleuth/models.py | 3 ++ src/netsleuth/probes/latency.py | 16 +++++++- src/netsleuth/stats.py | 26 +++++++++++++ tests/test_prober_stats.py | 22 +++++++++++ tests/test_stats.py | 66 ++++++++++++++++++++++++++++++++- 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/netsleuth/models.py b/src/netsleuth/models.py index 5a2111f..14ecdaf 100644 --- a/src/netsleuth/models.py +++ b/src/netsleuth/models.py @@ -265,6 +265,9 @@ class PingResult: max_ms: float | None = None mdev_ms: float | None = None jitter_ms: float | None = None + p95_ms: float | None = None + p99_ms: float | None = None + cv: float | None = None samples: list[float | None] = field(default_factory=list) diff --git a/src/netsleuth/probes/latency.py b/src/netsleuth/probes/latency.py index 6c9ed7e..f52e201 100644 --- a/src/netsleuth/probes/latency.py +++ b/src/netsleuth/probes/latency.py @@ -6,7 +6,7 @@ import time from netsleuth.models import Capabilities, PingResult from netsleuth.netinfo import choose_latency_backend -from netsleuth.stats import rtt_stats +from netsleuth.stats import jitter_matrix, rtt_stats def summarize_ping( @@ -17,6 +17,7 @@ def summarize_ping( samples: list[float | None], ) -> PingResult: s = rtt_stats(samples) + j = jitter_matrix(samples) return PingResult( label=label, host=host, @@ -30,6 +31,9 @@ def summarize_ping( max_ms=s.max_ms, mdev_ms=s.mdev_ms, jitter_ms=s.jitter_ms, + p95_ms=j.p95_ms, + p99_ms=j.p99_ms, + cv=j.cv, samples=samples, ) @@ -88,7 +92,11 @@ async def ping_host( timeout: float, backend: str, source_ip: str | None = None, + semaphore: asyncio.Semaphore | None = None, ) -> PingResult: + if semaphore is not None: + async with semaphore: + return await ping_host(host, label, count, interval, timeout, backend, source_ip) resolved = await _resolve(host) if backend in ("icmp_win", "icmp_dgram", "icmp_raw"): try: @@ -111,10 +119,14 @@ async def ping_fanout( interval: float, timeout: float, source_ip: str | None = None, + semaphore: asyncio.Semaphore | None = None, ) -> list[PingResult]: backend = choose_latency_backend(caps) return list( await asyncio.gather( - *(ping_host(host, label, count, interval, timeout, backend, source_ip) for label, host in hosts) + *( + ping_host(host, label, count, interval, timeout, backend, source_ip, semaphore) + for label, host in hosts + ) ) ) diff --git a/src/netsleuth/stats.py b/src/netsleuth/stats.py index ec64c28..e0c1841 100644 --- a/src/netsleuth/stats.py +++ b/src/netsleuth/stats.py @@ -51,3 +51,29 @@ def percentile(values: list[float], p: float) -> float: low = int(rank) high = min(low + 1, len(ordered) - 1) return ordered[low] + (ordered[high] - ordered[low]) * (rank - low) + + +class JitterMatrix(NamedTuple): + p50_ms: float | None + p95_ms: float | None + p99_ms: float | None + stdev_ms: float | None + cv: float | None + outlier_pct: float + + +def jitter_matrix(samples: list[float | None]) -> JitterMatrix: + got = [s for s in samples if s is not None] + if not got: + return JitterMatrix(None, None, None, None, None, 0.0) + p50 = round(percentile(got, 50.0), 3) + p95 = round(percentile(got, 95.0), 3) + p99 = round(percentile(got, 99.0), 3) + if len(got) == 1: + return JitterMatrix(p50, p95, p99, 0.0, 0.0, 0.0) + mean = sum(got) / len(got) + stdev = (sum((v - mean) ** 2 for v in got) / len(got)) ** 0.5 + cv = 0.0 if mean == 0 else stdev / mean + outliers = sum(1 for v in got if v > p95) + outlier_pct = round(100.0 * outliers / len(got), 3) + return JitterMatrix(p50, p95, p99, round(stdev, 3), round(cv, 3), outlier_pct) diff --git a/tests/test_prober_stats.py b/tests/test_prober_stats.py index f66f35b..6849419 100644 --- a/tests/test_prober_stats.py +++ b/tests/test_prober_stats.py @@ -62,3 +62,25 @@ def test_summarize_of_zero_probes_is_a_well_formed_empty_result(): def test_the_method_tag_is_preserved_verbatim(): for method in ("icmp_win", "icmp_dgram", "icmp_raw", "tcp", "cfL4"): assert summarize_ping("h", "x", None, method, [1.0]).method == method + + +def test_summarize_fills_the_jitter_matrix_fields(): + result = summarize_ping("h", "1.1.1.1", "1.1.1.1", "icmp_dgram", [10.0, 12.0, 14.0, 16.0, 40.0]) + assert result.p95_ms is not None + assert result.p99_ms is not None + assert result.cv is not None + assert result.p99_ms >= result.p95_ms + + +def test_summarize_of_an_all_timeout_run_has_no_jitter_matrix(): + result = summarize_ping("h", "example.test", None, "icmp_win", [None, None, None]) + assert result.p95_ms is None + assert result.p99_ms is None + assert result.cv is None + + +def test_summarize_of_a_single_sample_has_zero_cv(): + result = summarize_ping("h", "1.1.1.1", "1.1.1.1", "cfL4", [9.0]) + assert result.p95_ms == 9.0 + assert result.p99_ms == 9.0 + assert result.cv == 0.0 diff --git a/tests/test_stats.py b/tests/test_stats.py index 5f0a0a4..79b2faf 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -4,7 +4,7 @@ import math import pytest -from netsleuth.stats import percentile, rtt_stats +from netsleuth.stats import jitter_matrix, percentile, rtt_stats def test_all_samples_present(): @@ -78,3 +78,67 @@ def test_percentile_interpolates_between_ranks(): def test_percentile_handles_single_and_empty_inputs(): assert percentile([7.0], 90.0) == 7.0 assert percentile([], 90.0) == 0.0 + + +def test_jitter_matrix_percentiles_match_the_shared_percentile_helper(): + samples = [10.0, 12.0, 14.0, 16.0, 40.0] + m = jitter_matrix(samples) + assert m.p50_ms == pytest.approx(percentile(samples, 50.0), abs=1e-3) + assert m.p95_ms == pytest.approx(percentile(samples, 95.0), abs=1e-3) + assert m.p99_ms == pytest.approx(percentile(samples, 99.0), abs=1e-3) + + +def test_cv_is_stdev_over_mean(): + # mean = 15, population stdev = sqrt(((5)^2+(3)^2+(1)^2+(1)^2+(3)^2+(5)^2)/6) = sqrt(70/6) + samples = [10.0, 12.0, 14.0, 16.0, 18.0, 20.0] + m = jitter_matrix(samples) + mean = sum(samples) / len(samples) + expected_stdev = (sum((v - mean) ** 2 for v in samples) / len(samples)) ** 0.5 + assert m.stdev_ms == pytest.approx(expected_stdev, abs=1e-3) + assert m.cv == pytest.approx(expected_stdev / mean, abs=1e-3) + + +def test_cv_of_a_constant_series_is_zero(): + m = jitter_matrix([10.0, 10.0, 10.0]) + assert m.stdev_ms == 0.0 + assert m.cv == 0.0 + + +def test_cv_of_a_single_sample_is_zero_not_a_division_by_zero(): + m = jitter_matrix([13.5]) + assert m.p50_ms == m.p95_ms == m.p99_ms == 13.5 + assert m.stdev_ms == 0.0 + assert m.cv == 0.0 + assert m.outlier_pct == 0.0 + + +def test_jitter_matrix_of_all_timeouts_is_all_none(): + m = jitter_matrix([None, None, None]) + assert m.p50_ms is None + assert m.p95_ms is None + assert m.p99_ms is None + assert m.stdev_ms is None + assert m.cv is None + assert m.outlier_pct == 0.0 + + +def test_jitter_matrix_empty_input_is_not_a_division_by_zero(): + m = jitter_matrix([]) + assert m.p50_ms is None + assert m.outlier_pct == 0.0 + + +def test_outlier_share_counts_samples_above_p95(): + # Ten samples clustered at 10ms, one spike at 500ms: the spike sits above p95. + samples = [10.0] * 10 + [500.0] + m = jitter_matrix(samples) + above = sum(1 for v in samples if v > m.p95_ms) + assert m.outlier_pct == pytest.approx(100.0 * above / len(samples), abs=1e-3) + assert above >= 1 + + +def test_every_jitter_field_is_finite_so_json_serialization_cannot_break(): + for samples in ([], [None], [1.0], [1.0, None, 3.0], [1.0] * 10 + [500.0]): + m = jitter_matrix(samples) + for value in (m.p50_ms, m.p95_ms, m.p99_ms, m.stdev_ms, m.cv, m.outlier_pct): + assert value is None or math.isfinite(value)