"""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 " 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)