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>
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user