#5061 "Metric catalog belongs to what emits: plugins register their metrics; names match what is recorded". - The static METRIC_CATALOG is gone. metric_catalog() is core's own sources (the four monitor types and "steward", the app's open_fds/open_fds_pct, which were recorded but never offered) plus each loaded plugin's get_alert_metrics() export. A disabled plugin's sources leave the picker. - traefik offers what it records: error_rate_4xx_pct and error_rate_5xx_pct. The old "error_rate" was never recorded, so a rule on it could not fire. - tests/core/test_metric_catalog.py walks every MetricBatch.add / record_metric call (traefik's tuple loop included) and fails on a recorded metric the catalog lacks; it shows on a synthetic writer that it can fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
409 lines
19 KiB
Python
409 lines
19 KiB
Python
"""Alert tools: what is firing, what fired before, the rules, and maintenance windows."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from steward.alerts import service as alert_svc
|
|
from steward.mcp._context import current_caller
|
|
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule, AlertState, MaintenanceWindow
|
|
from steward.models.users import User, UserRole
|
|
|
|
from ._audit import audit_mcp
|
|
from ._shape import (
|
|
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd,
|
|
)
|
|
|
|
|
|
def _condition(rule: AlertRule) -> str:
|
|
n = rule.consecutive_failures_required or 1
|
|
ok = rule.consecutive_ok_required or 1
|
|
tail = f" for {n} consecutive checks" if n > 1 else ""
|
|
clear = f"; clears after {ok} OK checks" if ok > 1 else ""
|
|
return f"{rule.metric_name} {rule.operator.value} {rule.threshold:g}{tail}{clear}"
|
|
|
|
|
|
def _action(rule: AlertRule) -> dict | None:
|
|
"""The playbook a rule runs when it fires (alerts/routes.py _parse_ansible_action)."""
|
|
a = rule.ansible_action
|
|
if not a:
|
|
return None
|
|
return {k: a.get(k) for k in ("source", "playbook", "inventory", "limit", "tags",
|
|
"extra_vars", "check") if a.get(k) is not None}
|
|
|
|
|
|
async def _usernames(db, ids) -> dict[str, str]:
|
|
ids = {i for i in ids if i}
|
|
if not ids:
|
|
return {}
|
|
return dict((await db.execute(select(User.id, User.username).where(User.id.in_(ids)))).all())
|
|
|
|
|
|
async def list_alerts() -> dict:
|
|
"""Alerts that need attention now: firing, pending, or acknowledged but still breaching.
|
|
|
|
pending: the condition holds but not yet for the rule's required number of
|
|
consecutive checks. firing: it has. acknowledged: someone has seen it and
|
|
it still holds. Each carries the rule's condition, the resource and metric
|
|
it watches, and `since` (its latest state change). Firing, unacknowledged
|
|
alerts come first. An alert that cleared is not here; get_alert_history
|
|
shows the transitions.
|
|
"""
|
|
async with db_session() as db:
|
|
active = await alert_svc.active_alerts(db)
|
|
names = await _usernames(db, [s.acknowledged_by for s, _ in active])
|
|
order = {"firing": 0, "pending": 1, "acknowledged": 2}
|
|
rows = [
|
|
{
|
|
"rule_id": rule.id,
|
|
"rule_name": rule.name,
|
|
"state": state.state.value,
|
|
"source_module": rule.source_module,
|
|
"resource": rule.resource_name,
|
|
"metric": rule.metric_name,
|
|
"condition": _condition(rule),
|
|
"since": iso(state.last_transition_at),
|
|
"age_s": age_s(state.last_transition_at),
|
|
"acknowledged_by": names.get(state.acknowledged_by) if state.acknowledged_by else None,
|
|
"acknowledged_at": iso(state.acknowledged_at),
|
|
}
|
|
for state, rule in active
|
|
]
|
|
rows.sort(key=lambda r: (order.get(r["state"], 3), -(r["age_s"] or 0)))
|
|
return {"alerts": rows, "total": len(rows)}
|
|
|
|
|
|
async def get_alert_history(rule: str = "", range: str = "7d", limit: int = 50) -> dict:
|
|
"""State transitions of alert rules, newest first: when each fired, cleared or was acked.
|
|
|
|
rule: one rule's name or id (list_alert_rules shows them); empty for every rule.
|
|
range: "1h", "6h", "24h", "7d" (default) or "30d".
|
|
limit: default 50, max 500.
|
|
|
|
Each transition carries the metric value that caused it and whether the
|
|
email/webhook notification went out (with the error if not), plus the id
|
|
of any Ansible run the rule's action triggered.
|
|
"""
|
|
since, key = lookback(range)
|
|
limit = clamp_limit(limit, 50, 500)
|
|
async with db_session() as db:
|
|
rules = await alert_svc.list_rules(db)
|
|
stmt = (select(AlertEvent, AlertRule)
|
|
.join(AlertRule, AlertEvent.rule_id == AlertRule.id)
|
|
.where(AlertEvent.transitioned_at >= since)
|
|
.order_by(AlertEvent.transitioned_at.desc())
|
|
.limit(limit + 1))
|
|
chosen = None
|
|
if rule:
|
|
chosen = pick(rule, rules, kind="alert rule", list_tool="list_alert_rules")
|
|
stmt = stmt.where(AlertEvent.rule_id == chosen.id)
|
|
rows = (await db.execute(stmt)).all()
|
|
events = [
|
|
{
|
|
"rule_id": r.id,
|
|
"rule_name": r.name,
|
|
"from_state": e.from_state,
|
|
"to_state": e.to_state,
|
|
"metric_value": rnd(e.metric_value),
|
|
"note": e.note,
|
|
"at": iso(e.transitioned_at),
|
|
"age_s": age_s(e.transitioned_at),
|
|
"email_sent": e.email_sent,
|
|
"email_error": e.email_error,
|
|
"webhook_sent": e.webhook_sent,
|
|
"webhook_error": e.webhook_error,
|
|
"ansible_run_id": e.ansible_run_id,
|
|
}
|
|
for e, r in rows
|
|
]
|
|
out = {"range": key, "rule": chosen.name if chosen else None}
|
|
out.update(bounded(events, limit, key="events",
|
|
more="Pass a larger `limit` (max 500), a shorter `range`, or one `rule`."))
|
|
if out["truncated"]:
|
|
# Fetched limit+1 rows to detect the cut; the true total isn't known.
|
|
out["total"] = None
|
|
out["more"] = f"More than {limit} transitions in range. Pass a larger `limit` (max 500), a shorter `range`, or one `rule`."
|
|
return out
|
|
|
|
|
|
async def list_alert_rules(enabled_only: bool = False, limit: int = 200) -> dict:
|
|
"""Every alert rule: what it watches, its condition, current state and automatic action.
|
|
|
|
enabled_only: true to leave out disabled rules.
|
|
limit: default 200, max 500.
|
|
|
|
`state` is the rule's current state: "inactive" (condition not met),
|
|
"pending", "firing", "acknowledged", "resolved" (cleared since it last
|
|
fired), or null if it has never been evaluated. `ansible_action` is the
|
|
playbook the rule runs when it fires, if any (`check: true` = dry run).
|
|
"""
|
|
limit = clamp_limit(limit, 200, 500)
|
|
async with db_session() as db:
|
|
rules = await alert_svc.list_rules(db)
|
|
states = {s.rule_id: s for s in (await db.execute(select(AlertState))).scalars()}
|
|
if enabled_only:
|
|
rules = [r for r in rules if r.enabled]
|
|
rows = [
|
|
{
|
|
"rule_id": r.id,
|
|
"rule_name": r.name,
|
|
"enabled": r.enabled,
|
|
"source_module": r.source_module,
|
|
"resource": r.resource_name,
|
|
"metric": r.metric_name,
|
|
"condition": _condition(r),
|
|
"state": states[r.id].state.value if r.id in states else None,
|
|
"state_since": iso(states[r.id].last_transition_at) if r.id in states else None,
|
|
"ansible_action": _action(r),
|
|
}
|
|
for r in rules
|
|
]
|
|
return bounded(rows, limit, key="rules")
|
|
|
|
|
|
async def list_maintenance_windows(include_past: bool = False, limit: int = 50) -> dict:
|
|
"""Maintenance windows, during which matching alerts are held back from notifying.
|
|
|
|
include_past: true to include windows that have ended (default: current
|
|
and upcoming only).
|
|
limit: default 50, max 500.
|
|
|
|
`scope` is what a window silences: a source module and/or one resource, or
|
|
everything when both are null. `active` is true while it is in effect.
|
|
"""
|
|
limit = clamp_limit(limit, 50, 500)
|
|
now = now_utc()
|
|
async with db_session() as db:
|
|
windows = await alert_svc.list_maintenance_windows(db)
|
|
rows = [
|
|
{
|
|
"window_id": w.id,
|
|
"name": w.name,
|
|
"start_at": iso(w.start_at),
|
|
"end_at": iso(w.end_at),
|
|
"active": w.start_at <= now <= w.end_at,
|
|
"scope": {"source_module": w.scope_source, "resource": w.scope_resource},
|
|
}
|
|
for w in windows
|
|
if include_past or w.end_at >= now
|
|
]
|
|
return bounded(rows, limit, key="windows")
|
|
|
|
|
|
# ── changes ──────────────────────────────────────────────────────────────────
|
|
|
|
async def get_alert_rule_options(source_module: str = "") -> dict:
|
|
"""What an alert rule can watch: the choices create_alert_rule accepts.
|
|
|
|
source_module: empty lists every source and its metrics; a source's name
|
|
also lists its resources (hosts, containers, monitors… whatever that
|
|
source reports under).
|
|
|
|
A monitor reports under its type (icmp/tcp/dns/http) with its name as the
|
|
resource: is_up (1 up, 0 down) and response_ms.
|
|
"""
|
|
async with db_session() as db:
|
|
sources = alert_svc.source_modules()
|
|
if not source_module:
|
|
return {"sources": {src: await alert_svc.metrics_for(db, src) for src in sources},
|
|
"operators": [o.value for o in AlertOperator]}
|
|
if source_module not in sources:
|
|
raise ValueError(f"source_module {source_module!r} is not one of "
|
|
f"{', '.join(sources)}.")
|
|
return {"source_module": source_module,
|
|
"metrics": await alert_svc.metrics_for(db, source_module),
|
|
"resources": await alert_svc.resources_for(db, source_module),
|
|
"operators": [o.value for o in AlertOperator]}
|
|
|
|
|
|
async def _rule(db, rule: str) -> AlertRule:
|
|
return pick(rule, await alert_svc.list_rules(db), kind="alert rule", list_tool="list_alert_rules")
|
|
|
|
|
|
def _rule_row(r: AlertRule) -> dict:
|
|
return {"rule_id": r.id, "rule_name": r.name, "enabled": r.enabled,
|
|
"source_module": r.source_module, "resource": r.resource_name,
|
|
"metric": r.metric_name, "condition": _condition(r), "ansible_action": _action(r)}
|
|
|
|
|
|
async def create_alert_rule(name: str, source_module: str, resource: str, metric: str,
|
|
operator: str, threshold: float, consecutive_checks: int = 1,
|
|
clear_after_checks: int = 1, enabled: bool = True) -> dict:
|
|
"""Add an alert rule: notify when `metric` of `resource` crosses `threshold`.
|
|
|
|
source_module, resource, metric: get_alert_rule_options lists the valid
|
|
choices; a resource must have reported (or, for a monitor, exist).
|
|
operator: ">", "<", ">=", "<=", "==" or "!=".
|
|
consecutive_checks: how many checks in a row must breach before it fires
|
|
(default 1); higher values ride out a single blip.
|
|
clear_after_checks: how many checks in a row must be OK before a firing or
|
|
acknowledged alert resolves (default 1). An acknowledgement holds until
|
|
then, however long the breach lasts.
|
|
|
|
Notifications go to the channels set in Steward's Settings. The playbook a
|
|
rule runs when it fires is set separately (set_alert_action, admin).
|
|
Recorded in the audit log as "<you> via MCP".
|
|
"""
|
|
caller = current_caller()
|
|
async with db_session() as db, db.begin():
|
|
fields = await alert_svc.rule_fields(
|
|
db, name=name, source_module=source_module, resource_name=resource,
|
|
metric_name=metric, operator=operator, threshold=threshold,
|
|
consecutive_failures_required=consecutive_checks,
|
|
consecutive_ok_required=clear_after_checks)
|
|
r = await alert_svc.add_rule(db, fields, created_by=caller.user_id, enabled=enabled)
|
|
await audit_mcp("alert_rule.created", "alert_rule", r.name,
|
|
{"rule_id": r.id, "source": r.source_module, "resource": r.resource_name,
|
|
"metric": r.metric_name})
|
|
return _rule_row(r)
|
|
|
|
|
|
async def update_alert_rule(rule: str, name: str | None = None, source_module: str | None = None,
|
|
resource: str | None = None, metric: str | None = None,
|
|
operator: str | None = None, threshold: float | None = None,
|
|
consecutive_checks: int | None = None,
|
|
clear_after_checks: int | None = None,
|
|
enabled: bool | None = None) -> dict:
|
|
"""Change an alert rule. Only the arguments you pass change; the rest stay.
|
|
|
|
rule: its name or id (list_alert_rules). Pass enabled=false to silence it
|
|
without deleting it. The changed rule is validated as create_alert_rule
|
|
validates a new one. Changing what it watches (source, resource, metric,
|
|
operator, threshold) or disabling it puts its alert back to inactive; a
|
|
firing alert records a resolved event saying why. Recorded in the audit log.
|
|
"""
|
|
async with db_session() as db, db.begin():
|
|
r = await _rule(db, rule)
|
|
fields = await alert_svc.rule_fields(
|
|
db,
|
|
name=r.name if name is None else name,
|
|
source_module=r.source_module if source_module is None else source_module,
|
|
resource_name=r.resource_name if resource is None else resource,
|
|
metric_name=r.metric_name if metric is None else metric,
|
|
operator=r.operator.value if operator is None else operator,
|
|
threshold=r.threshold if threshold is None else threshold,
|
|
consecutive_failures_required=(r.consecutive_failures_required
|
|
if consecutive_checks is None else consecutive_checks),
|
|
consecutive_ok_required=(r.consecutive_ok_required
|
|
if clear_after_checks is None else clear_after_checks))
|
|
await alert_svc.apply_rule_change(db, r, fields, enabled=enabled)
|
|
changed = sorted(k for k, v in {"name": name, "source_module": source_module,
|
|
"resource": resource, "metric": metric, "operator": operator,
|
|
"threshold": threshold, "consecutive_checks": consecutive_checks,
|
|
"clear_after_checks": clear_after_checks,
|
|
"enabled": enabled}.items() if v is not None)
|
|
await audit_mcp("alert_rule.updated", "alert_rule", r.name, {"rule_id": r.id, "changed": changed})
|
|
return _rule_row(r)
|
|
|
|
|
|
async def delete_alert_rule(rule: str) -> dict:
|
|
"""Delete an alert rule and its history. To silence it but keep it,
|
|
update_alert_rule(enabled=false). Admin only; recorded in the audit log."""
|
|
async with db_session() as db, db.begin():
|
|
r = await _rule(db, rule)
|
|
await db.delete(r)
|
|
await audit_mcp("alert_rule.deleted", "alert_rule", r.name, {"rule_id": r.id})
|
|
return {"deleted": r.name, "rule_id": r.id}
|
|
|
|
|
|
async def acknowledge_alert(rule: str) -> dict:
|
|
"""Acknowledge a firing alert: you have seen it. It stays "acknowledged"
|
|
while the condition holds and resolves when it clears.
|
|
|
|
rule: the firing rule's name or id (list_alerts). Recorded in the audit log.
|
|
"""
|
|
caller = current_caller()
|
|
async with db_session() as db, db.begin():
|
|
r = await _rule(db, rule)
|
|
try:
|
|
state = await alert_svc.acknowledge(db, r.id, caller.user_id)
|
|
except LookupError:
|
|
raise ValueError(f"{r.name} has never been evaluated, so there is nothing to "
|
|
"acknowledge.") from None
|
|
await audit_mcp("alert_rule.acknowledged", "alert_rule", r.name, {"rule_id": r.id})
|
|
return {"rule_id": r.id, "rule_name": r.name, "state": state.state.value,
|
|
"acknowledged_at": iso(state.acknowledged_at)}
|
|
|
|
|
|
def _when(value: str, what: str) -> datetime:
|
|
try:
|
|
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
except ValueError:
|
|
raise ValueError(f"{what} {value!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def _window_row(w: MaintenanceWindow, now: datetime) -> dict:
|
|
return {"window_id": w.id, "name": w.name, "start_at": iso(w.start_at), "end_at": iso(w.end_at),
|
|
"active": w.start_at <= now <= w.end_at,
|
|
"scope": {"source_module": w.scope_source, "resource": w.scope_resource}}
|
|
|
|
|
|
async def create_maintenance_window(name: str, duration_minutes: int = 0, end_at: str = "",
|
|
start_at: str = "", source_module: str = "",
|
|
resource: str = "") -> dict:
|
|
"""Hold back alert notifications while you work on something.
|
|
|
|
Give the end as duration_minutes OR end_at (ISO-8601; a time with no zone
|
|
is UTC). start_at defaults to now.
|
|
source_module, resource: limit the window to one source, or to one
|
|
resource of it; both empty silences everything.
|
|
|
|
Alerts still change state during a window, so list_alerts shows them;
|
|
only notifications are held. Recorded in the audit log.
|
|
"""
|
|
now = now_utc()
|
|
start = _when(start_at, "start_at") if (start_at or "").strip() else now
|
|
if bool(duration_minutes) == bool((end_at or "").strip()):
|
|
raise ValueError("give exactly one of duration_minutes or end_at.")
|
|
if duration_minutes:
|
|
if duration_minutes < 0:
|
|
raise ValueError("duration_minutes must be positive.")
|
|
end = start + timedelta(minutes=duration_minutes)
|
|
else:
|
|
end = _when(end_at, "end_at")
|
|
fields = alert_svc.window_fields(name=name, start_at=start, end_at=end,
|
|
scope_source=source_module, scope_resource=resource)
|
|
w = MaintenanceWindow(created_by=current_caller().user_id, **fields)
|
|
async with db_session() as db, db.begin():
|
|
db.add(w)
|
|
await audit_mcp("maintenance_window.created", "maintenance_window", w.name,
|
|
{"window_id": w.id, "scope": alert_svc.window_scope(w),
|
|
"start_at": iso(w.start_at), "end_at": iso(w.end_at)})
|
|
return _window_row(w, now)
|
|
|
|
|
|
async def end_maintenance_window(window: str) -> dict:
|
|
"""End a maintenance window now, so notifications resume.
|
|
|
|
window: its name or id (list_maintenance_windows). A window in progress
|
|
ends now and stays on record; one that has not started yet is removed.
|
|
Recorded in the audit log.
|
|
"""
|
|
now = now_utc()
|
|
async with db_session() as db, db.begin():
|
|
windows = await alert_svc.list_maintenance_windows(db)
|
|
w = pick(window, windows, kind="maintenance window", list_tool="list_maintenance_windows")
|
|
if w.end_at <= now:
|
|
raise ValueError(f"{w.name} already ended at {iso(w.end_at)}.")
|
|
if w.start_at > now:
|
|
await db.delete(w)
|
|
outcome = "cancelled"
|
|
else:
|
|
w.end_at = now
|
|
outcome = "ended"
|
|
await audit_mcp(f"maintenance_window.{outcome}", "maintenance_window", w.name,
|
|
{"window_id": w.id})
|
|
return {**_window_row(w, now), "outcome": outcome}
|
|
|
|
|
|
def register(register_tool) -> None:
|
|
for fn in (list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows,
|
|
get_alert_rule_options):
|
|
register_tool(fn, role=UserRole.viewer, read_only=True)
|
|
for fn in (create_alert_rule, update_alert_rule, acknowledge_alert,
|
|
create_maintenance_window, end_maintenance_window):
|
|
register_tool(fn, role=UserRole.operator, read_only=False)
|
|
register_tool(delete_alert_rule, role=UserRole.admin, read_only=False)
|