feat(anomalies): a metric out of its normal is an anomaly to review (#5740)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m24s
CI / unit (push) Successful in 1m37s
CI / publish (push) Skipped

#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>
This commit is contained in:
2026-10-14 13:41:57 -04:00
co-authored by Claude Opus 5.5
parent cddf7fc6c4
commit 5569f9934e
34 changed files with 1343 additions and 25 deletions
+30 -6
View File
@@ -7,6 +7,7 @@ from datetime import timedelta
from sqlalchemy import select
from steward.alerts import service as alert_svc
from steward.core import anomalies as anom
from steward.hosts import service as host_svc
from steward.mcp._context import mcp_app
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
@@ -140,6 +141,22 @@ def run_findings(runs) -> list[dict]:
return out
def anomaly_findings(open_now) -> list[dict]:
"""Metric anomalies going on now (#5740): a warning each."""
from steward.core.formatting import metric_label, metric_value
out = []
for a in open_now:
out.append(finding(
"warning", "metric_anomaly", f"{a.resource_name} {metric_label(a.metric_name)}",
f"Metric anomaly: {metric_label(a.metric_name)} on {a.resource_name} held "
f"{metric_value(a.metric_name, a.peak_level)} where "
f"{metric_value(a.metric_name, a.usual_hold)} is usual for that hour"
+ (f" ({a.occurrences} times since {iso(a.started_at)})" if a.occurrences > 1 else ""),
since=a.last_started_at, ids={"anomaly_id": a.id},
next=f'list_anomalies(review="{a.review}")'))
return out
async def _plugin_findings(server) -> tuple[list[dict], list[str]]:
found, unavailable = [], []
for plugin, source in sorted((getattr(server, "overview_sources", None) or {}).items()):
@@ -162,12 +179,15 @@ async def get_overview() -> dict:
still-breaching acknowledged alerts, TLS certificates near expiry, host
agents that stopped reporting, hosts over their CPU, memory or disk
limits (all cutoffs are set under Settings → Thresholds), Ansible runs that failed
in the last 24h, and anything enabled plugins add (unhealthy containers,
for one). Each finding carries the ids to follow and `next`, the call
that shows its evidence.
in the last 24h, metrics going on now well above what is normal for them
at this hour (metric anomalies), and anything enabled plugins add
(unhealthy containers, for one). Each finding carries the ids to follow
and `next`, the call that shows its evidence.
`counts` sizes the fleet, and `active_maintenance` names windows
currently silencing alerts. `all_clear` is true when nothing is wrong.
`counts` sizes the fleet; `counts.anomalies_to_review` is how many metric
anomalies wait for someone to mark them normal or real (list_anomalies).
`active_maintenance` names windows currently silencing alerts.
`all_clear` is true when nothing is wrong.
"""
app = mcp_app()
now = now_utc()
@@ -187,12 +207,15 @@ async def get_overview() -> dict:
.limit(20)
)).scalars())
windows = await alert_svc.list_maintenance_windows(db)
open_anomalies = await anom.open_anomalies(db)
to_review = await anom.to_review_count(db)
thresholds = app.config.get("THRESHOLDS") or {}
findings = (monitor_findings(monitors, states, host_names, now, thresholds.get("tls_days"))
+ alert_findings(active)
+ agent_findings(hosts, agent, thresholds)
+ run_findings(runs))
+ run_findings(runs)
+ anomaly_findings(open_anomalies))
plugin_found, unavailable = await _plugin_findings(getattr(app, "mcp_server", None))
findings = rank(findings + plugin_found)
@@ -208,6 +231,7 @@ async def get_overview() -> dict:
"alerts_active": len(active),
"critical": sum(1 for f in findings if f["severity"] == "critical"),
"warning": sum(1 for f in findings if f["severity"] == "warning"),
"anomalies_to_review": to_review,
},
"active_maintenance": [
{"window_id": w.id, "name": w.name, "end_at": iso(w.end_at),