Files
FabledSteward/steward/core/norms.py
T
bvandeusenandClaude Opus 5.5 0945d78290
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m30s
CI / integration (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s
feat(mcp): understand and judge a metric anomaly: what happened, what was usual, what else went on (#5837)
get_anomaly gives each occurrence against what is usual at that hour, the
metric around the newest one with its normal, what else happened on the
resource's host around each occurrence (alert transitions, Ansible runs,
other anomalies there and a count elsewhere, plus plugins' own events
through a new mcp_events hook: the host agent's maintenance jobs and
downtime, Docker's container starts, deaths and OOM kills), and how earlier
anomalies on the same metric were judged.

get_metric_normal reads the learned slots and the level that would be
flagged. review_anomaly takes one id or several, all or nothing, with a
note. get_anomaly_settings and (admin) set_anomaly_settings read and change
the review window and each normal's floor, as Settings → Thresholds does.

metric_series.points takes an optional `until`; core.alerts gains
host_of_resource; floor_setting names its floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:17:34 -04:00

239 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)
@property
def floor_name(self) -> str | None:
"""The NORM_FLOORS setting the floor is read from, if it is one."""
return getattr(self.floor, "floor_name", None)
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. The function carries the
floor's name (`floor_name`), so a reader can say which setting it is."""
from steward.core.settings import floor_of
def read() -> float:
return floor_of(app.config.get("THRESHOLDS", {}), name)
read.floor_name = name
return read
@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)