#5463 "Acknowledgements on record: an event and who did it", step 1 of milestone 499 "Alerts you can trust". The live walk in #5461 found that acknowledging changed only the alert's current state: once it resolved, nothing said who had acknowledged it or when. - alert_events.actor_id (migration 0040): the person behind a transition. - Acknowledging writes a firing → acknowledged event by the acknowledger. - A rule edit, disable or enable that resets an alert carries its actor (web and MCP alike). - The history page notes "acknowledged by X" and "rule disabled by X"; MCP get_alert_history returns `by`. - One usernames() helper in alerts.service, used by the active list, the history page and the MCP tools. - docs/core/alerting.md: the state diagram no longer shows an acknowledged alert re-firing, and says what an event records. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.1 KiB
Alerting
Alert rules evaluate every metric that flows through record_metric(). There is no separate polling process — evaluation is inline with every metric write.
How It Works
When any monitor or plugin calls record_metric():
- The metric value is written to the
plugin_metricstable, andplugin_metric_latestmoves to it if it is the newest sample of that metric - All enabled
AlertRulerows matching(source_module, resource_name, metric_name)are loaded - Each matching rule is evaluated against the new value
- If a state transition occurs, an
AlertEventrow is written - Notification I/O is deferred outside the transaction via
asyncio.create_task()
Key function: steward/core/alerts.py → record_metric(session, source_module, resource_name, metric_name, value)
record_metric() must always be called inside an active transaction:
async with session.begin():
await record_metric(session, "ping", "my-server", "response_time_ms", 42.3)
Notifications are sent via asyncio.create_task() after the function returns, so no network I/O blocks the DB transaction.
Alert State Machine
Each AlertRule has one associated AlertState row. The state transitions are:
inactive ──(breached)──► pending ──(N breaching checks in a row)──► FIRING
▲ │ │
└──(an OK check)───────┘ │
FIRING ──(acknowledged)──► ACKNOWLEDGED
FIRING / ACKNOWLEDGED ──(M OK checks in a row)──► RESOLVED ──► inactive
N is the rule's consecutive checks, M its checks to clear. An acknowledgement holds for as long as the breach lasts: only a resolve clears it, and a breaching check never sends an acknowledged alert back to firing.
RESOLVED is transient — the evaluator writes the event and immediately sets state back to inactive within the same transaction. RESOLVED never persists as a final state in the DB.
Every transition is an AlertEvent, the history the Alerts → History page and
MCP get_alert_history read. A transition a person made carries them in
actor_id: an acknowledgement (firing → acknowledged), or a rule edit, disable
or enable that reset the alert (→ resolved, with a note such as "rule
disabled"). Transitions caused by readings, or by Steward itself, have none.
State Definitions
| State | Meaning |
|---|---|
inactive |
Threshold not breached |
pending |
Threshold breached but consecutive count not yet met; no notification sent |
firing |
Consecutive count met; FIRING notification sent |
acknowledged |
Someone has seen it; no further notifications; clears when the readings recover |
resolved |
Transient — notification sent, immediately transitions to inactive |
Creating Alert Rules
Alert rules are created in the UI at /alerts/. Each rule requires:
- Name — human-readable label
- Source module —
ping,dns,traefik, or any plugin name - Resource name — host name, router name, etc. (must match exactly what the monitor writes)
- Metric name — the metric key (e.g.
response_time_ms,up,error_rate_5xx_pct) - Operator —
>,<,>=,<=,==,!= - Threshold — numeric value
- Consecutive failures required — how many consecutive breaches before FIRING (default 1)
Available Metrics by Module
source_module |
metric_name |
Description |
|---|---|---|
ping |
response_time_ms |
Probe latency (0.0 if down) |
ping |
up |
1.0 = up, 0.0 = down |
dns |
resolved |
1.0 = resolved, 0.0 = failed |
dns |
ip_changed |
1.0 = IP changed from previous result |
traefik |
request_rate |
Requests per second |
traefik |
error_rate_4xx_pct |
4xx errors as % of requests |
traefik |
error_rate_5xx_pct |
5xx errors as % of requests |
traefik |
latency_p50_ms |
Approximate p50 latency (ms) |
traefik |
latency_p95_ms |
Approximate p95 latency (ms) |
traefik |
latency_p99_ms |
Approximate p99 latency (ms) |
Notifications
Notifications fire on FIRING and RESOLVED transitions. All configured channels receive every notification; there is no per-rule channel routing.
Configured at /settings/ under SMTP. Settings:
| Setting key | Description |
|---|---|
smtp.host |
SMTP server |
smtp.port |
Port (default 587) |
smtp.tls |
STARTTLS (default true) |
smtp.username |
Login |
smtp.password |
Password |
smtp.recipients |
List of email addresses |
Email is skipped if smtp.host is empty.
Webhook
Configured at /settings/ under Webhook. The body is a Jinja2 template rendered to JSON. Content-Type is always application/json. If the rendered template is not valid JSON, the delivery is logged as failed and no request is sent.
| Template variable | Type | Description |
|---|---|---|
alert.rule_name |
str | Alert rule name |
alert.state |
str | FIRING or RESOLVED |
alert.metric |
str | Metric name |
alert.value |
float | Current value |
alert.threshold |
float | Configured threshold |
alert.resource |
str | Resource name |
alert.source_module |
str | ping, dns, traefik, etc. |
alert.timestamp |
str | ISO 8601 UTC |
Default template (Discord-compatible):
{"content": "**{{ alert.state }}** — {{ alert.resource }} — {{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}
Webhook is skipped if webhook.url is empty.
Data Models
Defined in steward/models/alerts.py:
alert_rules— one row per configured rulealert_states— one row per rule, tracks current state and consecutive failure countalert_events— append-only log of all state transitions and notification outcomesplugin_metrics— all metric values written by any monitor or pluginplugin_metric_latest— the newest sample of each (source, resource, metric). Pages that show "what it is now" read this instead of searching history; retention drops a row once its sample leaves the raw window