feat(norms): hosts, containers and monitor response times are graded against their own normal (#5742)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m26s
CI / integration (push) Failing after 1m27s
CI / publish (push) Skipped

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:
2026-10-14 16:17:04 -04:00
co-authored by Claude Opus 5.5
parent f3cfb89612
commit f3433fe3ce
30 changed files with 638 additions and 93 deletions
+30 -11
View File
@@ -35,7 +35,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import ARRAY, DateTime, String, bindparam, delete, select, text, tuple_
from sqlalchemy import ARRAY, Boolean, DateTime, String, bindparam, delete, select, text, tuple_
from steward.models.norms import MetricNorm
@@ -51,31 +51,45 @@ USUAL = 0.9 # "usual" is this percentile across past days: one odd d
class Learned:
"""A metric that learns a normal. `floor` is the smallest departure,
in the metric's own unit, worth calling an anomaly (#5740), so that
0.1% → 0.3% of a share or 2 ms → 4 ms of latency is never news.
0.1% → 0.3% of a share or 2 ms → 4 ms of latency is never news. It may
be a function, read at each pass, for a floor that is a setting (#5742).
`gate` is (metric, least): the resource is judged only while that metric
of its own averages at least `least` over the window, as a router's
error share means nothing over a handful of requests (#5741). `least`
may be a function, read at each pass, for a minimum that is a setting. `since`:
history before it is not learned from, for a metric whose meaning
changed then."""
changed then. `parts=False` learns only whole resources, not their parts
("<host>:core3"): a host's CPU has a normal, each of its cores doesn't."""
source_module: str
metric_name: str
floor: float
floor: float | Callable[[], float]
gate: tuple[str, float | Callable[[], float]] | None = None
since: datetime | None = None
parts: bool = True
@property
def least_departure(self) -> float:
return float(self.floor() if callable(self.floor) else self.floor)
LEARNED: dict[tuple[str, str], Learned] = {}
def learn_normal(source_module: str, metric_name: str, floor: float, *,
def learn_normal(source_module: str, metric_name: str, floor: float | Callable[[], float], *,
gate: tuple[str, float | Callable[[], float]] | None = None,
since: datetime | None = None) -> None:
since: datetime | None = None, parts: bool = True) -> None:
"""Have Steward learn this metric's normal for every resource reporting
it. A plugin calls this from setup(), as it calls prune_by_age."""
LEARNED[(source_module, metric_name)] = Learned(source_module, metric_name, float(floor),
gate, since)
LEARNED[(source_module, metric_name)] = Learned(
source_module, metric_name, floor if callable(floor) else float(floor), gate, since, parts)
def floor_setting(app, name: str) -> Callable[[], float]:
"""A floor read from Settings → Thresholds (norms.floor.<name>) at each
pass, so a saved change applies at once."""
from steward.core.settings import floor_of
return lambda: floor_of(app.config.get("THRESHOLDS", {}), name)
@dataclass(frozen=True)
@@ -103,20 +117,23 @@ def slot_of(at: datetime, kind: str) -> int:
# own slot and its neighbours'.
_REBUILD = text("""
WITH learned AS (
SELECT key, since FROM unnest(CAST(:keys AS text[]), CAST(:starts AS timestamptz[])) AS k(key, since)
SELECT key, since, parts
FROM unnest(CAST(:keys AS text[]), CAST(:starts AS timestamptz[]), CAST(:parts AS boolean[]))
AS k(key, since, parts)
),
all_hours AS (
SELECT h.source_module AS s, h.resource_name AS r, h.metric_name AS m, h.bucket AS b,
COALESCE(h.value_p95, h.value_avg) AS hold, h.value_max AS peak
FROM plugin_metrics_hourly h
JOIN learned k ON k.key = h.source_module || '/' || h.metric_name
WHERE h.bucket >= k.since
WHERE h.bucket >= k.since AND (k.parts OR strpos(h.resource_name, ':') = 0)
UNION ALL
SELECT p.source_module, p.resource_name, p.metric_name, date_trunc('hour', p.recorded_at),
percentile_cont(0.95) WITHIN GROUP (ORDER BY p.value), max(p.value)
FROM plugin_metrics p
JOIN learned k ON k.key = p.source_module || '/' || p.metric_name
WHERE p.recorded_at >= k.since AND p.recorded_at < :this_hour
AND (k.parts OR strpos(p.resource_name, ':') = 0)
GROUP BY 1, 2, 3, 4
),
hours AS (
@@ -157,7 +174,8 @@ SELECT s, r, m, (slot + n.off + slots) % slots, kind,
FROM slotted CROSS JOIN generate_series(-CAST(:neighbours AS int), CAST(:neighbours AS int)) AS n(off)
GROUP BY s, r, m, (slot + n.off + slots) % slots, kind, days
""").bindparams(bindparam("keys", type_=ARRAY(String)),
bindparam("starts", type_=ARRAY(DateTime(timezone=True))))
bindparam("starts", type_=ARRAY(DateTime(timezone=True))),
bindparam("parts", type_=ARRAY(Boolean)))
async def rebuild_norms(session, now: datetime | None = None) -> int:
@@ -172,6 +190,7 @@ async def rebuild_norms(session, now: datetime | None = None) -> int:
"keys": [f"{s}/{m}" for s, m in LEARNED],
# Each key learns from WEEK_WINDOW back, or from its own `since`.
"starts": [max(week_since, k.since) if k.since else week_since for k in LEARNED.values()],
"parts": [k.parts for k in LEARNED.values()],
"day_since": now - DAY_WINDOW,
"this_hour": now.replace(minute=0, second=0, microsecond=0),
"week_ready": now - timedelta(days=WEEK_MIN_DAYS),