Files
FabledSteward/steward/mcp/tools/alerts.py
T
bvandeusenandClaude Opus 5.5 c368e93e2c
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m14s
CI / integration (push) Successful in 1m14s
CI / publish (push) Skipped
feat(alerts): silence is alertable — agent offline and no-data (#5062)
Agent offline is an ordinary rule now. The host_agent scheduler records
host_agent/<host>/seconds_since_seen every minute (from last_seen_at, or
registration time for an agent that never reported), replacing the task
that only logged stale agents. The catalog offers the metric, and picking
it on a new rule fills "> 180".

No data: each rule's state tracks its last sample and its usual gap,
smoothed so that an outage counts as at most three gaps. A core task,
alert_silence_check, runs every 60s and marks a rule no_data_since once its
series has had no sample for three usual gaps (at least 5 minutes; an hour
while the cadence is unknown). A never-sampled rule counts from when it
started watching. The mark sits beside the state, so a firing alert that
goes silent stays firing. Each silence records one event and sends one
notification (rule option notify_no_data, default on; muted in maintenance
windows). The next sample clears it with a "data resumed" event and
notification. Creating, changing, disabling or re-enabling a rule restarts
its watch.

Migration 0029 adds the columns and seeds each existing rule's last sample
and gap from its last day of data, so an upgrade does not flag every rule.

Shown on the Alerts page (a NO DATA badge with its start time), in MCP
list_alerts, list_alert_rules and get_host (state "no_data", no_data_since),
in get_overview findings (alert_no_data, or a firing alert marked silent)
and in the weekly report. The rule form has the notify option, and its
metric reference now matches what traefik records and lists host_agent.
Emails say "no data" and carry the event's note.

The metric-catalog guard also reads positional MetricBatch.add arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:06:54 -04:00

427 lines
20 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, acknowledged but still
breaching, or gone silent (no_data).
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. no_data: the metric the rule watches has stopped reporting
(no sample for three of its usual gaps, at least 5 minutes); a firing
alert that goes silent stays "firing" with `no_data_since` set. Each
carries the rule's condition, the resource and metric it watches, `since`
(its latest state change) and `last_sample_at`. 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, "no_data": 1, "pending": 2, "acknowledged": 3}
rows = [
{
"rule_id": rule.id,
"rule_name": rule.name,
"state": alert_svc.alert_label(state),
"no_data_since": iso(state.no_data_since),
"last_sample_at": iso(state.last_sample_at),
"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"], 4), -(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), "no_data" (inactive and its metric has stopped reporting), or null
if it has no state row. `no_data_since` is set while its metric is silent,
whatever the state. `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": alert_svc.alert_label(states[r.id]) if r.id in states else None,
"state_since": iso(states[r.id].last_transition_at) if r.id in states else None,
"no_data_since": iso(states[r.id].no_data_since) 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),
"notify_no_data": r.notify_no_data, "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, notify_no_data: bool = True,
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.
notify_no_data: also notify when the metric stops reporting and when it
resumes (default true). The silence shows in list_alerts either way.
"Agent offline" is source host_agent, metric seconds_since_seen, "> 180".
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, notify_no_data=notify_no_data)
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,
notify_no_data: bool | 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 or re-enabling 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),
notify_no_data=r.notify_no_data if notify_no_data is None else notify_no_data)
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,
"notify_no_data": notify_no_data,
"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)