fix(alerts): acknowledgements hold until resolve; clear after N OK; rule changes reset (#5059)
#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:
@@ -109,7 +109,8 @@ def _choices(values: list[str], limit: int = 20) -> str:
|
||||
|
||||
|
||||
async def rule_fields(db, *, name: str, source_module: str, resource_name: str, metric_name: str,
|
||||
operator: str, threshold, consecutive_failures_required=1) -> dict:
|
||||
operator: str, threshold, consecutive_failures_required=1,
|
||||
consecutive_ok_required=1) -> dict:
|
||||
"""Validated column values for a new or edited alert rule.
|
||||
|
||||
The same choices the rule form offers: a known source, a resource that
|
||||
@@ -148,9 +149,39 @@ async def rule_fields(db, *, name: str, source_module: str, resource_name: str,
|
||||
raise ValueError("consecutive checks must be a whole number.") from None
|
||||
if consecutive < 1:
|
||||
raise ValueError("consecutive checks must be at least 1.")
|
||||
try:
|
||||
ok_checks = (1 if consecutive_ok_required in (None, "")
|
||||
else int(consecutive_ok_required))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("checks to clear must be a whole number.") from None
|
||||
if ok_checks < 1:
|
||||
raise ValueError("checks to clear must be at least 1.")
|
||||
return {"name": name[:128], "source_module": source_module, "resource_name": resource_name,
|
||||
"metric_name": metric_name, "operator": op, "threshold": threshold,
|
||||
"consecutive_failures_required": consecutive}
|
||||
"consecutive_failures_required": consecutive,
|
||||
"consecutive_ok_required": ok_checks}
|
||||
|
||||
|
||||
# Changing any of these changes what the rule is watching, so whatever state
|
||||
# it had built up no longer means anything.
|
||||
WATCHED_FIELDS = ("source_module", "resource_name", "metric_name", "operator", "threshold")
|
||||
|
||||
|
||||
async def apply_rule_change(db, rule: AlertRule, fields: dict, *, enabled: bool | None = None) -> None:
|
||||
"""Apply validated fields (and optionally enabled) to a rule, in the
|
||||
caller's transaction. A change to what it watches, or disabling it, puts
|
||||
its alert back to inactive (core.alerts.reset_rule_state)."""
|
||||
from steward.core.alerts import reset_rule_state
|
||||
changed = [k for k in WATCHED_FIELDS if k in fields and getattr(rule, k) != fields[k]]
|
||||
for k, v in fields.items():
|
||||
setattr(rule, k, v)
|
||||
disabling = enabled is False and rule.enabled
|
||||
if enabled is not None:
|
||||
rule.enabled = bool(enabled)
|
||||
if disabling:
|
||||
await reset_rule_state(db, rule, "rule disabled")
|
||||
elif changed:
|
||||
await reset_rule_state(db, rule, "rule changed: " + ", ".join(changed))
|
||||
|
||||
|
||||
async def add_rule(db, fields: dict, *, created_by: str, enabled: bool = True,
|
||||
|
||||
Reference in New Issue
Block a user