fix(alerts): acknowledgements hold until resolve; clear after N OK; rule changes reset (#5059)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 45s
CI / unit (push) Successful in 1m8s
CI / publish (push) Successful in 1m1s

#5059 "Alert state machine: acknowledgement holds until resolve; N
consecutive OK to resolve; editing, disabling or deleting a rule resets it".

- The state machine is now next_step(), a pure function of one sample. An
  acknowledged alert stays acknowledged while breached; before, the next
  breaching sample re-fired it. Firing and acknowledged alerts resolve after
  consecutive_ok_required OK checks in a row (new rule column, default 1;
  rule form, rules list and MCP create/update_alert_rule clear_after_checks).
- Changing what a rule watches (source, resource, metric, operator,
  threshold) or disabling it resets its alert to inactive; a firing or
  acknowledged one records a resolved event whose new note says why, and
  nobody is notified. Web and MCP edits share alerts.service.apply_rule_change.
- Migration 0028: consecutive_ok_required, consecutive_ok_count,
  alert_events.note, and metric_value nullable (a reset has no sample).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:32:17 -04:00
co-authored by Claude Opus 5.5
parent faa605cead
commit a6c24299a2
10 changed files with 346 additions and 70 deletions
+76 -53
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING
@@ -151,6 +152,44 @@ def _is_breached(value: float, operator: AlertOperator, threshold: float) -> boo
return False
@dataclass(frozen=True)
class Step:
"""One sample's effect on an alert: the state after it, the two run
counters, and the event to record ("firing" / "resolved") if any."""
state: AlertStateEnum
fail_count: int
ok_count: int
event: str | None = None
def next_step(state: AlertStateEnum, fail_count: int, ok_count: int, breached: bool,
fail_required: int, ok_required: int) -> Step:
"""The alert state machine, as a pure function of one sample.
inactive → pending → firing when `fail_required` breaching checks run in a
row; a non-breaching check while pending goes back to inactive.
firing and acknowledged resolve (to inactive, with a resolved event) only
after `ok_required` non-breaching checks in a row; a breaching check
resets that run. An acknowledgement holds while the alert is still
breached: only a resolve clears it.
"""
fail_required, ok_required = max(1, fail_required), max(1, ok_required)
S = AlertStateEnum
if state in (S.firing, S.acknowledged):
if breached:
return Step(state, fail_count + 1, 0)
if ok_count + 1 >= ok_required:
return Step(S.inactive, 0, 0, "resolved")
return Step(state, fail_count, ok_count + 1)
# inactive, pending (and the transient resolved, read as inactive)
if not breached:
return Step(S.inactive, 0, 0)
fails = fail_count + 1 if state == S.pending else 1
if fails >= fail_required:
return Step(S.firing, fails, 0, "firing")
return Step(S.pending, fails, 0)
async def _evaluate_rule(
session: AsyncSession,
rule: AlertRule,
@@ -158,73 +197,56 @@ async def _evaluate_rule(
value: float,
now: datetime,
) -> list[tuple[str, str]]:
"""Evaluate a single rule against a metric value. Returns list of (notif_type, event_id)."""
breached = _is_breached(value, rule.operator, rule.threshold)
"""Apply one sample to a rule's state. Returns [(notif_type, event_id)]."""
from_state = state.state
step = next_step(from_state, state.consecutive_failure_count, state.consecutive_ok_count,
_is_breached(value, rule.operator, rule.threshold),
rule.consecutive_failures_required, rule.consecutive_ok_required)
state.consecutive_failure_count = step.fail_count
state.consecutive_ok_count = step.ok_count
notifications: list[tuple[str, str]] = []
if state.state == AlertStateEnum.inactive:
if breached:
state.consecutive_failure_count = 1
if state.consecutive_failure_count >= rule.consecutive_failures_required:
state.state = AlertStateEnum.firing
state.last_transition_at = now
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
notifications.append(("firing", event_id))
else:
state.state = AlertStateEnum.pending
state.last_transition_at = now
elif state.state == AlertStateEnum.pending:
if breached:
state.consecutive_failure_count += 1
if state.consecutive_failure_count >= rule.consecutive_failures_required:
state.state = AlertStateEnum.firing
state.last_transition_at = now
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
notifications.append(("firing", event_id))
else:
state.consecutive_failure_count = 0
state.state = AlertStateEnum.inactive
state.last_transition_at = now
elif state.state == AlertStateEnum.firing:
if not breached:
# RESOLVED is transient: record event, notify, then immediately go inactive
event_id = _add_event(session, rule.id, from_state.value, "resolved", value, now)
notifications.append(("resolved", event_id))
state.consecutive_failure_count = 0
state.state = AlertStateEnum.inactive
state.last_transition_at = now
elif state.state == AlertStateEnum.acknowledged:
if not breached:
event_id = _add_event(session, rule.id, from_state.value, "resolved", value, now)
notifications.append(("resolved", event_id))
state.consecutive_failure_count = 0
state.state = AlertStateEnum.inactive
state.last_transition_at = now
if step.state != from_state:
state.state = step.state
state.last_transition_at = now
if step.state == AlertStateEnum.inactive:
state.acknowledged_by = None
state.acknowledged_at = None
else:
state.consecutive_failure_count += 1
if state.consecutive_failure_count >= rule.consecutive_failures_required:
state.state = AlertStateEnum.firing
state.last_transition_at = now
event_id = _add_event(session, rule.id, from_state.value, "firing", value, now)
notifications.append(("firing", event_id))
if step.event:
event_id = _add_event(session, rule.id, from_state.value, step.event, value, now)
notifications.append((step.event, event_id))
await session.flush()
return notifications
async def reset_rule_state(session: AsyncSession, rule: AlertRule, note: str) -> None:
"""Put a rule's alert back to inactive because the rule itself changed
(what it watches, its threshold, or it was disabled). An alert that was
firing or acknowledged gets a resolved event carrying `note`; nobody is
notified, since nothing about the watched resource changed."""
state = (await session.execute(
select(AlertState).where(AlertState.rule_id == rule.id))).scalar_one_or_none()
if state is None:
return
now = datetime.now(timezone.utc)
if state.state in (AlertStateEnum.firing, AlertStateEnum.acknowledged):
_add_event(session, rule.id, state.state.value, "resolved", None, now, note=note)
if state.state != AlertStateEnum.inactive:
state.last_transition_at = now
state.state = AlertStateEnum.inactive
state.consecutive_failure_count = 0
state.consecutive_ok_count = 0
state.acknowledged_by = None
state.acknowledged_at = None
def _add_event(
session: AsyncSession,
rule_id: str,
from_state: str,
to_state: str,
value: float,
value: float | None,
now: datetime,
note: str | None = None,
) -> str:
"""Add an AlertEvent row and return its ID."""
event_id = str(uuid.uuid4())
@@ -235,6 +257,7 @@ def _add_event(
to_state=to_state,
metric_value=value,
transitioned_at=now,
note=note,
)
session.add(event)
return event_id