netsleuth/tests/test_metrics.py
Andrew Pimenov 245533f904 add Prometheus/CSV export, auto-diff vs previous run, and --trend
New metrics.py: collect_metrics() does the per-section extraction a flat
dict walk can't (latency needs label/host/method, TLS label/host, path hops
target/ttl/ip/asn), producing a flat Metric list that render_prometheus()
and render_csv() each format in about 15 lines. Simpler than treating them
as two separate renderers. FORMAT_EXTENSIONS gained "prom"/"csv" as the
single wiring point both parse_formats() and Output._normalize_formats()
already key off of.

New history.py finds a previous JSON report for the same ASN by reusing
the exact filename convention exporter.report_filename() already writes,
so the lookup key can't drift from what's on disk. Wired into diagnose()
before write_report() - a compact vs-previous-run summary
(compare.py::render_diff_brief(), new) prints automatically whenever this
run writes JSON and an earlier one exists for the same network; silent on
every other case (first run, prior run wrote no JSON prints one note
instead of guessing). New --no-auto-diff escapes it per run,
history.auto_diff in config disables it entirely.

New trend.py aggregates N saved JSON reports into per-label sparkline
series via --trend N - read-only, and it doesn't touch the network to find
"this network's" history either: history.latest_key() just reads it off
the newest report filename already on disk.

Growing FORMAT_EXTENSIONS to 5 entries broke three pre-existing tests that
hardcoded the old 3-format set; fixed in this commit.

Live-tested: prom/csv artifacts render correctly, auto-diff fired on a
second run, --trend 3 produced readable output, --compare unaffected.

Part of plans/zesty-zooming-wilkinson.md (gitignored).
2026-08-12 22:10:21 +03:00

86 lines
3.5 KiB
Python

from __future__ import annotations
from netsleuth.metrics import Metric, collect_metrics, render_csv, render_prometheus
def _report(**overrides) -> dict:
base = {
"meta": {"run_id": "abc123"},
"ip_geo": {"status": "ok", "data": {"egress_v4": {"asn": "AS64500", "country_code": "RU"}}},
"latency": {
"status": "ok",
"data": [
{"label": "cloudflare-dns", "host": "1.1.1.1", "method": "icmp_win", "avg_ms": 10.0, "loss_pct": 0.0}
],
},
"speed": {"status": "ok", "data": {"download_mbps": 100.0, "upload_mbps": None}},
"tls": {"status": "skipped", "data": None},
"path": {"status": "ok", "data": []},
"prefix_benchmark": {"status": "skipped", "data": None},
"bgp": {"status": "skipped", "data": None},
"vpn_assessment": {"status": "ok", "data": {"confidence": 0.1}},
"reputation": {"status": "skipped", "data": None},
"interpretation": {"overall_score": 90, "findings": [{"severity": "warn"}, {"severity": "warn"}]},
}
base.update(overrides)
return base
def test_collect_metrics_extracts_latency_and_skips_none_fields():
metrics = collect_metrics(_report())
latency_avg = [m for m in metrics if m.name == "netsleuth_latency_avg_ms"]
assert len(latency_avg) == 1
assert latency_avg[0].value == 10.0
assert latency_avg[0].labels == {"label": "cloudflare-dns", "host": "1.1.1.1", "method": "icmp_win"}
# upload_mbps is None -> no metric emitted
assert not [m for m in metrics if m.name == "netsleuth_speed_upload_mbps"]
def test_collect_metrics_a_skipped_section_contributes_nothing():
metrics = collect_metrics(_report())
assert not [m for m in metrics if m.name.startswith("netsleuth_tls_")]
assert not [m for m in metrics if m.name.startswith("netsleuth_bgp_")]
def test_collect_metrics_always_emits_up_and_run_info():
metrics = collect_metrics(_report())
up = [m for m in metrics if m.name == "netsleuth_up"]
assert up == [Metric("netsleuth_up", 1.0)]
run_info = [m for m in metrics if m.name == "netsleuth_run_info"]
assert run_info[0].labels == {"asn": "AS64500", "country": "RU", "run_id": "abc123"}
def test_collect_metrics_aggregates_findings_by_severity():
metrics = collect_metrics(_report())
findings = {m.labels["severity"]: m.value for m in metrics if m.name == "netsleuth_findings_total"}
assert findings == {"warn": 2.0}
def test_render_prometheus_has_one_help_and_type_per_family():
metrics = [
Metric("netsleuth_latency_avg_ms", 10.0, {"label": "a"}),
Metric("netsleuth_latency_avg_ms", 20.0, {"label": "b"}),
]
text = render_prometheus(metrics)
assert text.count("# HELP netsleuth_latency_avg_ms") == 1
assert text.count("# TYPE netsleuth_latency_avg_ms") == 1
assert 'netsleuth_latency_avg_ms{label="a"} 10' in text
assert 'netsleuth_latency_avg_ms{label="b"} 20' in text
def test_render_prometheus_handles_no_labels():
text = render_prometheus([Metric("netsleuth_up", 1.0)])
assert "netsleuth_up 1" in text
def test_render_prometheus_escapes_special_characters_in_labels():
text = render_prometheus([Metric("netsleuth_x", 1.0, {"host": 'weird"name\\here'})])
assert 'host="weird\\"name\\\\here"' in text
def test_render_csv_has_a_header_row_and_quotes_a_comma_in_a_label():
metrics = [Metric("netsleuth_x", 5.0, {"target": "a, b"})]
text = render_csv(metrics)
lines = text.strip().splitlines()
assert lines[0] == "metric,labels,value"
assert '"target=a, b"' in lines[1]