feat(norms): hosts, containers and monitor response times are graded against their own normal (#5742)
Host CPU, memory and load per core, container CPU and memory, and every monitor's response time learn a normal per hour (milestone 513, System 182). A reading past its fixed cutoff but within its normal reads healthy and says what is usual at this hour; one well out of it keeps its grade and is a metric anomaly. Disk, a monitor down and an agent offline keep their cutoffs. - Each normal's floor, the smallest departure that counts, is a setting: Settings -> Thresholds -> Normals (norms.floor.*). Defaults: host CPU 10 points, host memory 5, load per core 10, container CPU 10 points of one core, container memory 5, monitor response 100 ms; Traefik's 2-point 4xx and 50 ms p95 floors move there from constants. learn_normal takes a floor function (floor_setting) read at each pass. - learn_normal(parts=False): a host learns, its cores don't. - Ingest records load_pct (load_1m / cores x 100), the figure pages grade. - Shared grading: anomalies.against / graded, worst_reading(normals=), hosts' host_grades, docker usage(thresholds=, norms=), monitors' latency_grade. Applied to host vitals, the Hosts widget, MCP overview, Docker usage, and the monitor list, widget and page. - The monitor and container pages list their anomalies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,33 @@ def departs(level: float, norm: Norm, floor: float) -> bool:
|
||||
return level > norm.usual_hold * DEPART and level - norm.usual_hold >= floor
|
||||
|
||||
|
||||
def against(value: float, norm: Norm | None, floor: float, unit: str) -> tuple[bool, str]:
|
||||
"""(departs, what is usual) for a page's reading past its fixed cutoff
|
||||
(#5741, #5742). Without a normal it departs, and the cutoff stands; within
|
||||
one it is usual for that resource, and the page says so. `unit` is "%"
|
||||
(one decimal) or " ms" (whole)."""
|
||||
if norm is None:
|
||||
return True, ""
|
||||
usual = f"{norm.usual_hold:.1f}{unit}" if unit == "%" else f"{norm.usual_hold:.0f}{unit}"
|
||||
return departs(value, norm, floor), f"usual {usual} at this hour"
|
||||
|
||||
|
||||
def graded(value: float | None, kind: str, thresholds: dict, norm: Norm | None,
|
||||
floor: float, unit: str) -> tuple[str, str | None]:
|
||||
"""(status class, why) for a behaviour reading on a page (#5742): graded
|
||||
against Settings → Thresholds, except that one past its cutoff but within
|
||||
its own normal for the hour is "ok", and `why` says what is usual. Only
|
||||
for behaviour (CPU, memory, load, latency); a failure keeps its cutoff
|
||||
whatever its history (System 182)."""
|
||||
from steward.core.formatting import status_class
|
||||
cls = status_class(value, kind, thresholds)
|
||||
if cls in ("warn", "crit") and norm is not None:
|
||||
out, said = against(value, norm, floor, unit)
|
||||
if not out:
|
||||
return "ok", said
|
||||
return cls, None
|
||||
|
||||
|
||||
# Each learned key reporting in the window, with the window's average. Keys
|
||||
# come from plugin_metric_latest (one row each), and each average is a range
|
||||
# read of the series index, so the pass never scans the raw table.
|
||||
@@ -64,18 +91,22 @@ CROSS JOIN LATERAL (
|
||||
AND p.recorded_at <= :now
|
||||
) v
|
||||
WHERE l.source_module || '/' || l.metric_name = ANY(:keys)
|
||||
AND (strpos(l.resource_name, ':') = 0 OR NOT l.source_module || '/' || l.metric_name = ANY(:whole))
|
||||
AND l.recorded_at >= :since AND v.level IS NOT NULL
|
||||
""").bindparams(bindparam("keys", type_=ARRAY(String)))
|
||||
""").bindparams(bindparam("keys", type_=ARRAY(String)), bindparam("whole", type_=ARRAY(String)))
|
||||
|
||||
|
||||
async def levels_now(session, now: datetime) -> dict[tuple[str, str, str], float]:
|
||||
"""{(source, resource, metric): its average over the last WINDOW}, for
|
||||
every learned metric and every metric one is gated on."""
|
||||
every learned metric and every metric one is gated on. A metric learned
|
||||
without its parts reads its whole resources only."""
|
||||
keys = sorted({f"{s}/{m}" for s, m in LEARNED}
|
||||
| {f"{k.source_module}/{k.gate[0]}" for k in LEARNED.values() if k.gate})
|
||||
if not keys:
|
||||
return {}
|
||||
rows = await session.execute(_LEVELS, {"keys": keys, "since": now - WINDOW, "now": now})
|
||||
whole = sorted(f"{k.source_module}/{k.metric_name}" for k in LEARNED.values() if not k.parts)
|
||||
rows = await session.execute(_LEVELS, {"keys": keys, "whole": whole,
|
||||
"since": now - WINDOW, "now": now})
|
||||
return {(s, r, m): float(level) for s, r, m, level in rows}
|
||||
|
||||
|
||||
@@ -98,7 +129,7 @@ async def detect(session, now: datetime | None = None) -> dict:
|
||||
for key, level in levels.items():
|
||||
norm = norms.get(key)
|
||||
learned = LEARNED.get((key[0], key[2]))
|
||||
out = norm is not None and learned is not None and departs(level, norm, learned.floor)
|
||||
out = norm is not None and learned is not None and departs(level, norm, learned.least_departure)
|
||||
current = open_now.pop(key, None)
|
||||
if current is not None:
|
||||
span, anomaly = current
|
||||
|
||||
Reference in New Issue
Block a user