add jitter matrix (p95/p99/cv) to ping stats

This commit is contained in:
Andrew Pimenov 2026-08-09 17:34:06 +03:00
parent d1d774a199
commit 51d82d997f
5 changed files with 130 additions and 3 deletions

View file

@ -265,6 +265,9 @@ class PingResult:
max_ms: float | None = None max_ms: float | None = None
mdev_ms: float | None = None mdev_ms: float | None = None
jitter_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) samples: list[float | None] = field(default_factory=list)

View file

@ -6,7 +6,7 @@ import time
from netsleuth.models import Capabilities, PingResult from netsleuth.models import Capabilities, PingResult
from netsleuth.netinfo import choose_latency_backend from netsleuth.netinfo import choose_latency_backend
from netsleuth.stats import rtt_stats from netsleuth.stats import jitter_matrix, rtt_stats
def summarize_ping( def summarize_ping(
@ -17,6 +17,7 @@ def summarize_ping(
samples: list[float | None], samples: list[float | None],
) -> PingResult: ) -> PingResult:
s = rtt_stats(samples) s = rtt_stats(samples)
j = jitter_matrix(samples)
return PingResult( return PingResult(
label=label, label=label,
host=host, host=host,
@ -30,6 +31,9 @@ def summarize_ping(
max_ms=s.max_ms, max_ms=s.max_ms,
mdev_ms=s.mdev_ms, mdev_ms=s.mdev_ms,
jitter_ms=s.jitter_ms, jitter_ms=s.jitter_ms,
p95_ms=j.p95_ms,
p99_ms=j.p99_ms,
cv=j.cv,
samples=samples, samples=samples,
) )
@ -88,7 +92,11 @@ async def ping_host(
timeout: float, timeout: float,
backend: str, backend: str,
source_ip: str | None = None, source_ip: str | None = None,
semaphore: asyncio.Semaphore | None = None,
) -> PingResult: ) -> 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) resolved = await _resolve(host)
if backend in ("icmp_win", "icmp_dgram", "icmp_raw"): if backend in ("icmp_win", "icmp_dgram", "icmp_raw"):
try: try:
@ -111,10 +119,14 @@ async def ping_fanout(
interval: float, interval: float,
timeout: float, timeout: float,
source_ip: str | None = None, source_ip: str | None = None,
semaphore: asyncio.Semaphore | None = None,
) -> list[PingResult]: ) -> list[PingResult]:
backend = choose_latency_backend(caps) backend = choose_latency_backend(caps)
return list( return list(
await asyncio.gather( 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
)
) )
) )

View file

@ -51,3 +51,29 @@ def percentile(values: list[float], p: float) -> float:
low = int(rank) low = int(rank)
high = min(low + 1, len(ordered) - 1) high = min(low + 1, len(ordered) - 1)
return ordered[low] + (ordered[high] - ordered[low]) * (rank - low) 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)

View file

@ -62,3 +62,25 @@ def test_summarize_of_zero_probes_is_a_well_formed_empty_result():
def test_the_method_tag_is_preserved_verbatim(): def test_the_method_tag_is_preserved_verbatim():
for method in ("icmp_win", "icmp_dgram", "icmp_raw", "tcp", "cfL4"): for method in ("icmp_win", "icmp_dgram", "icmp_raw", "tcp", "cfL4"):
assert summarize_ping("h", "x", None, method, [1.0]).method == method 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

View file

@ -4,7 +4,7 @@ import math
import pytest import pytest
from netsleuth.stats import percentile, rtt_stats from netsleuth.stats import jitter_matrix, percentile, rtt_stats
def test_all_samples_present(): def test_all_samples_present():
@ -78,3 +78,67 @@ def test_percentile_interpolates_between_ranks():
def test_percentile_handles_single_and_empty_inputs(): def test_percentile_handles_single_and_empty_inputs():
assert percentile([7.0], 90.0) == 7.0 assert percentile([7.0], 90.0) == 7.0
assert percentile([], 90.0) == 0.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)