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
|
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:
|
def source_for_phase(phase: str) -> str:
|
||||||
"""Map a ``climate._request`` phase tag to an external-source label."""
|
"""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 = _inbound[category] = {"total": 0, **{b: 0 for b in _STATUS_BUCKETS}}
|
||||||
row["total"] += 1
|
row["total"] += 1
|
||||||
row[bucket] = row.get(bucket, 0) + 1
|
row[bucket] = row.get(bucket, 0) + 1
|
||||||
|
_bump_recent(_recent_in, category)
|
||||||
except Exception: # noqa: BLE001 - instrumentation must never break a request
|
except Exception: # noqa: BLE001 - instrumentation must never break a request
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -122,6 +153,7 @@ def record_outbound(phase: str, outcome: str) -> None:
|
||||||
if row is None:
|
if row is None:
|
||||||
row = _outbound[source] = {o: 0 for o in _OUTCOMES}
|
row = _outbound[source] = {o: 0 for o in _OUTCOMES}
|
||||||
row[outcome] = row.get(outcome, 0) + 1
|
row[outcome] = row.get(outcome, 0) + 1
|
||||||
|
_bump_recent(_recent_out, source)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -131,6 +163,8 @@ def snapshot() -> dict:
|
||||||
with _lock:
|
with _lock:
|
||||||
inbound = {k: dict(v) for k, v in _inbound.items()}
|
inbound = {k: dict(v) for k, v in _inbound.items()}
|
||||||
outbound = {k: dict(v) for k, v in _outbound.items()}
|
outbound = {k: dict(v) for k, v in _outbound.items()}
|
||||||
|
recent_in = _recent_totals(_recent_in)
|
||||||
|
recent_out = _recent_totals(_recent_out)
|
||||||
return {
|
return {
|
||||||
"pid": os.getpid(),
|
"pid": os.getpid(),
|
||||||
"started_at": _started_at,
|
"started_at": _started_at,
|
||||||
|
|
@ -139,4 +173,8 @@ def snapshot() -> dict:
|
||||||
"outbound": outbound,
|
"outbound": outbound,
|
||||||
"inbound_total": sum(v.get("total", 0) for v in inbound.values()),
|
"inbound_total": sum(v.get("total", 0) for v in inbound.values()),
|
||||||
"outbound_total": sum(sum(v.values()) for v in outbound.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 isinstance(snap["pid"], int)
|
||||||
assert snap["uptime_s"] >= 0
|
assert snap["uptime_s"] >= 0
|
||||||
assert "open-meteo-archive" in snap["outbound"] # stable source rows exist
|
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