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
+72
View File
@@ -0,0 +1,72 @@
{# Metric anomalies (#5740), drawn the same on Alerts → Anomalies, a resource's
own page and the overview widget. Import with:
{% from "_anomaly.html" import anomaly_row, anomaly_when with context %}
(with context: the review buttons read session.user_role). #}
{% from "_macros.html" import status_badge %}
{# When it happened: "since …" while going, "from – to" once ended; a repeat
says how often and when the latest was. #}
{% macro anomaly_when(v) -%}
{%- set a = v.anomaly -%}
{%- if a.occurrences > 1 -%}
{{ a.occurrences }} times since {{ a.started_at|when }}, last
{% if v.ongoing %}since {{ a.last_started_at|when }}, ongoing{% else %}{{ a.last_started_at|when }} – {{ a.ended_at|when }}{% endif %}
{%- elif v.ongoing -%}
Ongoing since {{ a.started_at|when }}
{%- else -%}
{{ a.started_at|when }} – {{ a.ended_at|when }}
{%- endif -%}
{%- endmacro %}
{# Its highest level against what is usual for that hour. #}
{% macro anomaly_level(a) -%}
Held {{ metric_value(a.metric_name, a.peak_level) }}; usual for that hour
{{ metric_value(a.metric_name, a.usual_hold) }}, peaking at {{ metric_value(a.metric_name, a.usual_peak) }}
{%- endmacro %}
{# The review buttons, or how it was reviewed. `back` is the page to come
back to after marking. #}
{% macro anomaly_review(a, back=none) -%}
{%- set can_review = session.user_role in ('operator', 'admin') -%}
{%- if a.review == "normal" -%}
<span>Marked normal by {{ a.reviewed_by or "someone" }}, {{ a.reviewed_at|when }}.</span>
{%- elif a.review == "real" -%}
<span>Real problem, marked by {{ a.reviewed_by or "someone" }}, {{ a.reviewed_at|when }}{% if a.note %}: {{ a.note }}{% else %}.{% endif %}</span>
{%- elif a.review == "lapsed" -%}
<span>Not reviewed within the review window; lapsed {{ a.reviewed_at|when }}. Its hours are not learned.</span>
{%- endif -%}
{%- if can_review and a.review in ("to_review", "lapsed") %}
<form method="post" action="/alerts/anomalies/{{ a.id }}/review"
style="display:flex;gap:0.4rem;flex-wrap:wrap;align-items:center;margin-top:0.35rem;">
{{ csrf_field() }}
{% if back %}<input type="hidden" name="next" value="{{ back }}">{% endif %}
<input class="input" name="note" placeholder="Note (optional)" aria-label="Note"
style="max-width:220px;font-size:0.8rem;padding:0.25rem 0.5rem;">
<button class="btn btn-ghost btn-sm" name="review" value="normal"
title="Its hours are learned as normal for that time">This is normal</button>
<button class="btn btn-sm" name="review" value="real"
title="Kept out of what is learned">Real problem</button>
</form>
{%- endif -%}
{%- endmacro %}
{# One anomaly as a row: badge, resource and metric, when, level, review. #}
{% macro anomaly_row(v, back=none, show_resource=true) -%}
{%- set a = v.anomaly -%}
<div class="anomaly-row" style="padding:0.55rem 0;border-bottom:1px solid var(--border);">
<div style="display:flex;gap:0.5rem;align-items:baseline;flex-wrap:wrap;">
{{ status_badge("anomaly") }}
{% if a.review != "to_review" %}{{ status_badge(a.review) }}{% endif %}
{% if show_resource %}
{% if v.link %}<a href="{{ v.link }}" style="font-weight:500;">{{ a.resource_name }}</a>
{% else %}<span style="font-weight:500;">{{ a.resource_name }}</span>{% endif %}
<span style="color:var(--text-dim);font-size:0.8rem;">{{ source_label(a.source_module) }}</span>
{% endif %}
<span>{{ metric_label(a.metric_name) }}</span>
</div>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.2rem;">
{{ anomaly_when(v) }} · {{ anomaly_level(a) }}
</div>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.2rem;">{{ anomaly_review(a, back) }}</div>
</div>
{%- endmacro %}