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>
239 lines
10 KiB
Python
239 lines
10 KiB
Python
"""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)
|