Files
FabledSteward/steward/core/norms.py
T
bvandeusenandClaude Opus 5.5 5569f9934e
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m24s
CI / unit (push) Successful in 1m37s
CI / publish (push) Skipped
feat(anomalies): a metric out of its normal is an anomaly to review (#5740)
#5740 "A metric that departs from its normal is recorded as an anomaly to
review, and is findable after it ends", milestone 513.

- Every minute each learned key's last 15 minutes is judged against its
  hour's normal: above the usual hold x1.25 and by the metric's floor opens
  an occurrence, which ends when the level comes back.
- metric_anomalies holds one row per item to review; a repeat while it
  still waits joins it as an occurrence (metric_anomaly_spans).
- An ended row nobody reviews within anomalies.review_days (14, Settings ->
  Thresholds) lapses; an ongoing one never does.
- Learning skips every hour an occurrence covers unless its anomaly was
  marked normal: no normal is learned without review.
- Alerts -> Anomalies lists those to review first, then the reviewed and
  lapsed under day dividers; marking is operator/admin and audited.
- The Overview widget and get_overview warn of open ones and count those
  to review; the host page shows its own; the weekly report lists the
  week's and the lapsed; MCP list_anomalies and review_anomaly.
- Reviewed and lapsed rows are pruned at data.retention_days, and kept
  in backups like alert history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:41:57 -04:00

191 lines
8.1 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 dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import ARRAY, 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."""
source_module: str
metric_name: str
floor: float
LEARNED: dict[tuple[str, str], Learned] = {}
def learn_normal(source_module: str, metric_name: str, floor: float) -> 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))
@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 all_hours AS (
SELECT source_module AS s, resource_name AS r, metric_name AS m, bucket AS b,
COALESCE(value_p95, value_avg) AS hold, value_max AS peak
FROM plugin_metrics_hourly
WHERE bucket >= :week_since AND source_module || '/' || metric_name = ANY(:keys)
UNION ALL
SELECT source_module, resource_name, metric_name, date_trunc('hour', recorded_at),
percentile_cont(0.95) WITHIN GROUP (ORDER BY value), max(value)
FROM plugin_metrics
WHERE recorded_at >= :week_since AND recorded_at < :this_hour
AND source_module || '/' || metric_name = ANY(:keys)
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)))
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))
keys = [f"{s}/{m}" for s, m in LEARNED]
if not keys:
return 0
result = await session.execute(_REBUILD, {
"keys": keys,
"week_since": now - WEEK_WINDOW,
"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)