Dashboard: show last-10-minute traffic per category (#143)

Add a rolling short-window view alongside the since-start totals. metrics.py
keeps per-minute buckets (epoch-minute -> {key: count}) for inbound categories
and outbound sources, summed over the last 10 minutes on snapshot() and exposed
as inbound_recent / outbound_recent / window_minutes. Cheap and bounded — at most
WINDOW+1 tiny dicts, pruned as minutes roll off; self-polling still excluded.

The dashboard's TRAFFIC section now renders that count as a dim column next to
each row's total (header reads "total · last 10m"); idle sources show "-".
This commit is contained in:
Emi Griffith 2026-07-16 15:15:35 -07:00 committed by GitHub
parent 5222ae3067
commit 2b2d0431ba
2 changed files with 76 additions and 0 deletions

View file

@ -39,6 +39,36 @@ _outbound: "dict[str, dict[str, int]]" = {
s: {o: 0 for o in _OUTCOMES} for s in SOURCES
}
# Rolling short-window view: per-minute buckets (epoch-minute -> {key: count}) for
# inbound categories and outbound sources, summed over the last WINDOW minutes on
# snapshot(). Cheap and bounded — at most WINDOW+1 tiny dicts, pruned as minutes roll.
WINDOW_MINUTES = 10
_recent_in: "dict[int, dict[str, int]]" = {}
_recent_out: "dict[int, dict[str, int]]" = {}
def _bump_recent(buckets: "dict[int, dict[str, int]]", key: str) -> None:
"""Count one event for ``key`` in the current-minute bucket (caller holds _lock)."""
minute = int(time.time() // 60)
row = buckets.get(minute)
if row is None:
row = buckets[minute] = {}
cutoff = minute - WINDOW_MINUTES
for old in [m for m in buckets if m <= cutoff]:
del buckets[old] # drop minutes that have rolled out of the window
row[key] = row.get(key, 0) + 1
def _recent_totals(buckets: "dict[int, dict[str, int]]") -> "dict[str, int]":
"""Sum each key's count over the last WINDOW minutes (caller holds _lock)."""
cutoff = int(time.time() // 60) - WINDOW_MINUTES + 1
totals: "dict[str, int]" = {}
for minute, row in buckets.items():
if minute >= cutoff:
for key, count in row.items():
totals[key] = totals.get(key, 0) + count
return totals
def source_for_phase(phase: str) -> str:
"""Map a ``climate._request`` phase tag to an external-source label."""
@ -109,6 +139,7 @@ def record_inbound(category: str, status) -> None:
row = _inbound[category] = {"total": 0, **{b: 0 for b in _STATUS_BUCKETS}}
row["total"] += 1
row[bucket] = row.get(bucket, 0) + 1
_bump_recent(_recent_in, category)
except Exception: # noqa: BLE001 - instrumentation must never break a request
pass
@ -122,6 +153,7 @@ def record_outbound(phase: str, outcome: str) -> None:
if row is None:
row = _outbound[source] = {o: 0 for o in _OUTCOMES}
row[outcome] = row.get(outcome, 0) + 1
_bump_recent(_recent_out, source)
except Exception: # noqa: BLE001
pass
@ -131,6 +163,8 @@ def snapshot() -> dict:
with _lock:
inbound = {k: dict(v) for k, v in _inbound.items()}
outbound = {k: dict(v) for k, v in _outbound.items()}
recent_in = _recent_totals(_recent_in)
recent_out = _recent_totals(_recent_out)
return {
"pid": os.getpid(),
"started_at": _started_at,
@ -139,4 +173,8 @@ def snapshot() -> dict:
"outbound": outbound,
"inbound_total": sum(v.get("total", 0) for v in inbound.values()),
"outbound_total": sum(sum(v.values()) for v in outbound.values()),
# Traffic over the last WINDOW_MINUTES, per category / source.
"window_minutes": WINDOW_MINUTES,
"inbound_recent": recent_in,
"outbound_recent": recent_out,
}

View file

@ -90,3 +90,41 @@ def test_snapshot_has_process_facts():
assert isinstance(snap["pid"], int)
assert snap["uptime_s"] >= 0
assert "open-meteo-archive" in snap["outbound"] # stable source rows exist
def test_recent_window_sums_and_rolls_off(monkeypatch):
m = _fresh()
clock = {"now": 1_000_000.0}
monkeypatch.setattr(m.time, "time", lambda: clock["now"])
# minute 0: three page hits + one archive call
for _ in range(3):
m.record_inbound("page", 200)
m.record_outbound("history_fetch", "ok") # -> open-meteo-archive
snap = m.snapshot()
assert snap["window_minutes"] == 10
assert snap["inbound_recent"]["page"] == 3
assert snap["outbound_recent"]["open-meteo-archive"] == 1
# +5 min: still inside the 10-minute window, so the earlier hits still count
clock["now"] += 5 * 60
m.record_inbound("page", 200)
assert m.snapshot()["inbound_recent"]["page"] == 4
# +6 more min (t = 11 min): the minute-0 burst has rolled out of the window;
# only the hit from minute 5 remains, and the archive call is gone entirely.
clock["now"] += 6 * 60
snap = m.snapshot()
assert snap["inbound_recent"].get("page", 0) == 1
assert "open-meteo-archive" not in snap["outbound_recent"]
# the cumulative since-start total is unaffected by the rolling window
assert snap["inbound"]["page"]["total"] == 4
def test_recent_excludes_self_polling():
m = _fresh()
m.record_inbound("metrics", 200) # dashboard polling — never counted
m.record_inbound("page", 200)
snap = m.snapshot()
assert "metrics" not in snap["inbound_recent"]
assert snap["inbound_recent"]["page"] == 1