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:
parent
5222ae3067
commit
2b2d0431ba
2 changed files with 76 additions and 0 deletions
38
metrics.py
38
metrics.py
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue