Files
FabledSteward/steward/mcp/tools/anomalies.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

97 lines
4.3 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.
"""Metric anomaly tools (#5740): metrics that left what is normal for them at
that hour, and marking each normal or real."""
from __future__ import annotations
from steward.core import anomalies as anom
from steward.mcp._context import current_caller
from steward.models.users import UserRole
from ._audit import audit_mcp, mcp_username
from ._shape import bounded, clamp_limit, db_session, iso, rnd
LIST_MAX = 200
def _row(v: anom.AnomalyView) -> dict:
a, last = v.anomaly, v.last_span
return {
"anomaly_id": a.id,
"source_module": a.source_module,
"resource": a.resource_name,
"metric": a.metric_name,
"review": a.review,
"ongoing": v.ongoing,
"started_at": iso(a.started_at),
"ended_at": iso(a.ended_at),
"occurrences": a.occurrences,
"last_occurrence": {"started_at": iso(last.started_at), "ended_at": iso(last.ended_at),
"peak_level": rnd(last.peak_level, 3)} if last else None,
"peak_level": rnd(a.peak_level, 3),
"peak_at": iso(a.peak_at),
"usual_hold": rnd(a.usual_hold, 3),
"usual_peak": rnd(a.usual_peak, 3),
"reviewed_by": a.reviewed_by,
"reviewed_at": iso(a.reviewed_at),
"note": a.note,
"page": v.href,
}
async def list_anomalies(review: str = "to_review", limit: int = 50) -> dict:
"""Metric anomalies: a learned metric whose 15-minute level went well
above what is normal for it at that hour (its usual hold ×1.25, and by
more than the metric's floor).
review: "to_review" (default) — waiting for someone to mark them; "normal"
— accepted, their hours are learned; "real" — a real problem, not learned;
"lapsed" — ended and nobody reviewed them within the review window
(Settings → Thresholds), so they are not learned; "" for all of them.
One row covers every repeat on a resource and metric while it waits for
review: `occurrences` counts them and `last_occurrence` is the latest.
`peak_level` is the highest 15-minute level any occurrence held, against
`usual_hold` (the level that hour usually holds) and `usual_peak` (how high
it usually goes), all in the metric's own unit. `ongoing` is true while an
occurrence is still going. To-review rows come first, newest occurrence
first. review_anomaly marks one.
"""
if review and review not in anom.REVIEWS:
raise ValueError(f"review must be one of {', '.join(anom.REVIEWS)}, or empty for all")
limit = clamp_limit(limit, 50, LIST_MAX)
async with db_session() as db:
rows = await anom.list_anomalies(db, (review,) if review else None, limit=LIST_MAX)
views = await anom.views(db, rows)
to_review = await anom.to_review_count(db)
out = bounded([_row(v) for v in views], limit,
key="anomalies", more="Pass a larger `limit`, or a `review` state.")
out["to_review"] = to_review
return out
async def review_anomaly(anomaly_id: str, review: str, note: str = "") -> dict:
"""Mark a metric anomaly "normal" or "real" (list_anomalies gives ids).
normal: this level is usual for this resource at that time; its hours are
learned into the metric's normal, so it stops being flagged once learned.
real: it was a real problem; its hours stay out of what is learned.
note: optional, kept with the review (say what it was).
A lapsed anomaly may still be marked. Audited as "<user> via MCP".
"""
caller = current_caller()
async with db_session() as db, db.begin():
try:
a = await anom.mark(db, anomaly_id, review, mcp_username(caller.username), note)
except LookupError:
raise ValueError(f"No anomaly {anomaly_id!r}; list_anomalies shows them.") from None
detail = {"source": a.source_module, "resource": a.resource_name,
"metric": a.metric_name, "note": a.note}
result = {"anomaly_id": a.id, "review": a.review, "reviewed_by": a.reviewed_by,
"reviewed_at": iso(a.reviewed_at), "note": a.note}
await audit_mcp(f"metric_anomaly.marked_{review}", "metric_anomaly", anomaly_id, detail)
return result
def register(register_tool) -> None:
register_tool(list_anomalies, role=UserRole.viewer, read_only=True)
register_tool(review_anomaly, role=UserRole.operator, read_only=False)