Files
FabledSteward/steward/core/norms.py
T
bvandeusenandClaude Opus 5.5 f3433fe3ce
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m26s
CI / integration (push) Failing after 1m27s
CI / publish (push) Skipped
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>
2026-10-14 16:17:04 -04:00

229 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Each metric's normal, by time slot (#5739, milestone 513).
A fixed cutoff suits some resources and not others: a router that serves
error pages answers 4xx all day, and a host that backs up at 02:00 runs hot
for that hour every night. So Steward learns, for each metric that opts in,
what it usually does in each hour, and a reading is judged against the
normal for its own hour (System 182 "Metric Norms and Anomalies").
What a slot's normal is:
- usual_hold: the level the slot's hours usually held. Each hour's p95
(plugin_metrics_hourly.value_p95, #5738), at its 90th percentile across
past days. A two-minute spike doesn't raise it; an hour spent high does.
- usual_peak: the same for each hour's max, so a page can say how high the
slot usually goes.
The slots:
- An hour of the day (24 slots) once a key has DAY_MIN_DAYS of history.
- An hour of the week (168 slots) once its history reaches back WEEK_MIN_DAYS,
so a weekly job is learned too.
- Slots are UTC hours. Each takes in its neighbours (±NEIGHBOURS), so a job
that starts a few minutes late, or an hour off across a daylight-saving
change, stays inside its normal.
Only metrics registered with learn_normal() are learned: a host agent alone
reports thousands of per-core, per-disk and per-interface series, and most
have no use for a normal.
An hour an anomaly covers is learned only once someone marks the anomaly
normal (steward.core.anomalies): a level that jumps and stays is never
absorbed on its own, and a lapsed anomaly is never learned.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import ARRAY, Boolean, DateTime, String, bindparam, delete, select, text, tuple_
from steward.models.norms import MetricNorm
DAY_MIN_DAYS = 5 # days of history before an hour-of-day normal is trusted
WEEK_MIN_DAYS = 21 # history reaching back this far learns hour-of-week instead
DAY_WINDOW = timedelta(days=28) # what an hour-of-day normal learns from
WEEK_WINDOW = timedelta(days=56) # what an hour-of-week normal learns from: 8 of each slot
NEIGHBOURS = 1 # hours either side a slot takes in
USUAL = 0.9 # "usual" is this percentile across past days: one odd day in ten is ignored
@dataclass(frozen=True)
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. 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. `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 | 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 | Callable[[], float], *,
gate: tuple[str, float | Callable[[], float]] | 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, 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)
class Norm:
kind: str # "day" or "week"
slot: int
usual_hold: float
usual_peak: float
days_seen: int
def slot_of(at: datetime, kind: str) -> int:
"""The slot `at` falls in: its UTC hour of the day (0–23), or its hour of
the week counted from Monday 00:00 UTC (0–167)."""
at = at.astimezone(timezone.utc)
if kind == "week":
return at.weekday() * 24 + at.hour
return at.hour
# One statement learns every normal: the hours of each learned key (rolled up,
# plus the raw window not rolled up yet) less those an anomaly covers that
# nobody marked normal (#5740), which kind of slot its history supports, then
# each slot's usual hold and peak across past days, every hour counted in its
# own slot and its neighbours'.
_REBUILD = text("""
WITH learned AS (
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 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 (
SELECT * FROM all_hours h
WHERE NOT EXISTS (
SELECT 1 FROM metric_anomaly_spans sp
JOIN metric_anomalies a ON a.id = sp.anomaly_id
WHERE a.review <> 'normal'
AND a.source_module = h.s AND a.resource_name = h.r AND a.metric_name = h.m
AND sp.started_at < h.b + interval '1 hour'
AND COALESCE(sp.ended_at, :now) > h.b)
),
keyed AS (
SELECT s, r, m, count(DISTINCT date_trunc('day', b)) AS days, min(b) AS first
FROM hours GROUP BY s, r, m
),
kinds AS (
SELECT s, r, m, days, CASE WHEN first <= :week_ready THEN 'week' ELSE 'day' END AS kind
FROM keyed WHERE days >= CAST(:day_min_days AS int)
),
slotted AS (
SELECT h.s, h.r, h.m, k.kind, k.days, h.hold, h.peak,
CASE k.kind
WHEN 'week' THEN (EXTRACT(ISODOW FROM h.b AT TIME ZONE 'UTC')::int - 1) * 24
+ EXTRACT(HOUR FROM h.b AT TIME ZONE 'UTC')::int
ELSE EXTRACT(HOUR FROM h.b AT TIME ZONE 'UTC')::int
END AS slot,
CASE k.kind WHEN 'week' THEN 168 ELSE 24 END AS slots
FROM hours h JOIN kinds k ON k.s = h.s AND k.r = h.r AND k.m = h.m
WHERE k.kind = 'week' OR h.b >= :day_since
)
INSERT INTO metric_norms (source_module, resource_name, metric_name, slot, kind,
usual_hold, usual_peak, days_seen, updated_at)
SELECT s, r, m, (slot + n.off + slots) % slots, kind,
percentile_cont(CAST(:usual AS float8)) WITHIN GROUP (ORDER BY hold),
percentile_cont(CAST(:usual AS float8)) WITHIN GROUP (ORDER BY peak),
days, :now
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("parts", type_=ARRAY(Boolean)))
async def rebuild_norms(session, now: datetime | None = None) -> int:
"""Relearn every normal from history, replacing the last pass's. Runs
inside the caller's transaction. Returns how many slot rows it wrote."""
now = now or datetime.now(timezone.utc)
await session.execute(delete(MetricNorm))
if not LEARNED:
return 0
week_since = now - WEEK_WINDOW
result = await session.execute(_REBUILD, {
"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),
"day_min_days": DAY_MIN_DAYS,
"usual": USUAL,
"neighbours": NEIGHBOURS,
"now": now,
})
return result.rowcount or 0
async def norms_at(session, keys: list[tuple[str, str, str]],
at: datetime) -> dict[tuple[str, str, str], Norm]:
"""{(source, resource, metric): its normal for the slot `at` falls in}.
A key with no normal yet (too little history, or not learned) is absent,
and its caller falls back to the fixed cutoffs."""
if not keys:
return {}
day, week = slot_of(at, "day"), slot_of(at, "week")
rows = (await session.execute(
select(MetricNorm)
.where(tuple_(MetricNorm.source_module, MetricNorm.resource_name,
MetricNorm.metric_name).in_(keys))
.where(((MetricNorm.kind == "day") & (MetricNorm.slot == day))
| ((MetricNorm.kind == "week") & (MetricNorm.slot == week)))
)).scalars().all()
return {(n.source_module, n.resource_name, n.metric_name):
Norm(n.kind, n.slot, n.usual_hold, n.usual_peak, n.days_seen) for n in rows}
async def run_norms(app) -> None:
"""The hourly pass: relearn every normal in one transaction."""
async with app.db_sessionmaker() as session:
async with session.begin():
await rebuild_norms(session)