fix(alerts): an alert on a count says how many of what, and names them (#6400)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 1m8s

"swarm services short on swarm is 6." becomes "6 swarm services are
short of their replicas.", with a Which line naming each service and how
many of its tasks run. The swarm figures get labels, and the counting
figures (swarm, mounts, drives, updates) read as sentences in the email,
its subject and the Alerts page. A plugin names what its count counts
through core.alerts.names_behind; the Docker plugin does for the three
swarm figures, from the stored swarm. Webhooks get alert.which too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 13:00:56 -04:00
co-authored by Claude Opus 5.5
parent ae789bc0f1
commit 0b787a0676
17 changed files with 308 additions and 24 deletions
+14 -2
View File
@@ -163,7 +163,8 @@ def alert_sentence(rule: AlertRule, label: str, value: float | None,
"tail"}, read lead + resource + tail, e.g. "CPU on " + "web-1" +
" is 94% (> 90%), firing for 12m". label is alert_label(state); since is
when that label began (no_data_since for no_data)."""
from steward.core.formatting import human_duration, metric_label, metric_value
from steward.core.formatting import (WHOLE_RESOURCES, count_words, human_duration, metric_label,
metric_value)
metric = rule.metric_name
said = metric_label(metric)
held = human_duration((now - since).total_seconds()) if since else None
@@ -174,6 +175,15 @@ def alert_sentence(rule: AlertRule, label: str, value: float | None,
# not how long ago someone acknowledged it (the page says that apart).
word = "breaching" if label == "acknowledged" else label
for_how_long = f", {word} for {held}" if held else f", {word}"
condition = f" ({rule.operator.value} {metric_value(metric, rule.threshold)})"
counted = count_words(metric, value)
if counted and rule.resource_name in WHOLE_RESOURCES:
# The count is the whole of it, and links where the swarm is shown.
return {"lead": "", "resource": counted[:1].upper() + counted[1:],
"tail": f"{condition}{for_how_long}"}
if counted:
return {"lead": f"{counted[:1].upper() + counted[1:]} on ", "resource": rule.resource_name,
"tail": f"{condition}{for_how_long}"}
if value is None:
reading = " has no reading yet"
elif metric in _BINARY:
@@ -181,7 +191,6 @@ def alert_sentence(rule: AlertRule, label: str, value: float | None,
"tail": f" is {metric_value(metric, value)}{for_how_long}"}
else:
reading = f" is {metric_value(metric, value)}"
condition = f" ({rule.operator.value} {metric_value(metric, rule.threshold)})"
return {"lead": f"{said} on ", "resource": rule.resource_name,
"tail": f"{reading}{condition}{for_how_long}"}
@@ -787,11 +796,14 @@ async def test_alert(db, rule: AlertRule) -> dict:
target = (await db.execute(
select(AlertRule).where(AlertRule.parent_id == rule.id)
.order_by(AlertRule.resource_name).limit(1))).scalar_one_or_none() or rule
from steward.core.alerts import which
return {"rule_name": rule.name, "state": "TEST", "metric": rule.metric_name,
"value": await latest_value(db, target) if target.resource_name else None,
"operator": rule.operator.value, "threshold": rule.threshold,
"resource": target.resource_name or covers_text(rule),
"source_module": rule.source_module,
"which": await which(db, rule.source_module, rule.metric_name, target.resource_name)
if target.resource_name else [],
"note": "A test of this rule's notifications. Nothing has fired.",
"timestamp": datetime.now(timezone.utc).isoformat()}
+29
View File
@@ -54,6 +54,7 @@ class Heartbeat:
_HEARTBEAT: Heartbeat | None = None
_AGENT_FED: dict[str, Callable[[str], str]] = {}
_AWAY: Callable[[AsyncSession], Awaitable[set[str]]] | None = None
_NAMED: dict[tuple[str, str], Callable[[AsyncSession, str], Awaitable[list[str]]]] = {}
def agent_heartbeat(source_module: str, metric_name: str,
@@ -78,6 +79,28 @@ def agents_away(hosts_away: Callable[[AsyncSession], Awaitable[set[str]]]) -> No
_AWAY = hosts_away
def names_behind(source_module: str, metric_name: str,
names: Callable[[AsyncSession, str], Awaitable[list[str]]]) -> None:
"""A figure that counts things: `await names(session, resource)` names
the things it counts right now ("web_app (0 of 1 running)"), so the
alert about it can say which. The plugin recording the figure registers it."""
_NAMED[(source_module, metric_name)] = names
async def which(session: AsyncSession, source_module: str, metric_name: str,
resource: str) -> list[str]:
"""What a counting figure counts right now, by name; [] for a figure no
plugin names, or when naming fails (the alert still goes out)."""
names = _NAMED.get((source_module, metric_name))
if names is None:
return []
try:
return list(await names(session, resource))
except Exception:
logger.exception("Naming what %s on %s counts failed", metric_name, resource)
return []
def is_heartbeat(rule) -> bool:
"""Whether `rule` watches the agent heartbeat ("agent offline")."""
return _HEARTBEAT is not None and (rule.source_module, rule.metric_name) == (
@@ -672,8 +695,14 @@ async def _dispatch_notification(
"resource": rule.resource_name,
"source_module": rule.source_module,
"note": note,
"which": [],
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# A firing count says what it counts, as it stands when it fires.
if notif_type == "firing" and (rule.source_module, rule.metric_name) in _NAMED:
async with app.db_sessionmaker() as db:
alert_data["which"] = await which(db, rule.source_module, rule.metric_name,
rule.resource_name)
# Email waits so alerts that arrive together go out as one message per
# recipient (#5465, #5507); the webhook posts this alert now.
+37
View File
@@ -376,8 +376,45 @@ METRICS: dict[str, tuple[str, str]] = {
"drive_life_used_pct": ("Drive life used", "how much of its rated write life this drive has used"),
"drive_bad_sectors": ("Bad sectors", "media errors, or reallocated, pending and uncorrectable sectors, as the drive counts them"),
"drive_temp_c": ("Drive temperature", "the drive's own temperature sensor"),
"swarm_services_short": ("Swarm services short of replicas", "services running fewer tasks than they want; one scaled to 0 is never short"),
"swarm_nodes_down": ("Swarm nodes down", "nodes that aren't ready, drained ones aside"),
"swarm_managers_unreachable": ("Swarm managers unreachable", "managers the swarm can't reach, drained ones aside; with three, losing two stops the swarm changing"),
}
# Figures that count things. An alert on one says how many of what, not
# "<label> is 6": (for one, for several, for none), each read after the
# count ("6 swarm services are short of their replicas") or alone.
COUNTED: dict[str, tuple[str, str, str]] = {
"swarm_services_short": ("swarm service is short of its replicas",
"swarm services are short of their replicas",
"every swarm service has its replicas"),
"swarm_nodes_down": ("swarm node is down", "swarm nodes are down", "every swarm node is up"),
"swarm_managers_unreachable": ("swarm manager can't be reached", "swarm managers can't be reached",
"every swarm manager can be reached"),
"mounts_stalled": ("mount isn't answering", "mounts aren't answering", "every mount is answering"),
"drives_failing": ("drive is failing", "drives are failing", "no drive is failing"),
"updates_pending": ("update is waiting", "updates are waiting", "no updates are waiting"),
"updates_security": ("security update is waiting", "security updates are waiting",
"no security updates are waiting"),
}
# Resources that are the whole of what their figures measure: a sentence
# about the swarm's figures says nothing by adding "on swarm".
WHOLE_RESOURCES = frozenset({"swarm"})
def count_words(name: str, value: float | None) -> str | None:
"""A counting figure's reading in words: "6 swarm services are short of
their replicas", "every swarm node is up"; None for a figure that isn't
a count, or no reading."""
phrases = COUNTED.get(name or "")
if phrases is None or value is None:
return None
n = int(value)
if n <= 0:
return phrases[2]
return f"{n} {phrases[0] if n == 1 else phrases[1]}"
# Where a rule's metric comes from, as the picker and the rules list say it.
SOURCES: dict[str, str] = {
"icmp": "Ping monitors", "tcp": "TCP monitors", "dns": "DNS monitors",
+46 -8
View File
@@ -201,39 +201,77 @@ def alert_facts(alert: dict, tz: tzinfo, show_utc: bool = True) -> dict:
"""What an alert email says, in words, from the dict alerts dispatch, its
time in `tz` (and in UTC beside it when `show_utc` and they differ)."""
from steward.alerts.service import _BINARY, condition_text
from steward.core.formatting import metric_label, metric_value, status_info
from steward.core.formatting import (WHOLE_RESOURCES, count_words, metric_label, metric_value,
status_info)
tone, label = status_info(alert["state"])
metric, resource, value = alert["metric"], alert["resource"], alert.get("value")
reading = metric_value(metric, value) if value is not None else None
counted = count_words(metric, value)
what = _lower_first(metric_label(metric))
state = alert["state"].lower()
whole = resource in WHOLE_RESOURCES
# The resource mid-sentence: "the swarm", or a host's own name.
named = f"the {resource}" if whole else resource
on = "" if whole else f" on {resource}"
if state == "no_data":
headline = f"Steward has had no {what} reading from {resource}."
headline = f"Steward has had no {what} reading from {named}."
elif state == "data_resumed":
headline = f"{resource} is reporting {what} again."
headline = f"{_upper_first(named) if whole else named} is reporting {what} again."
elif metric in _BINARY:
headline = f"{resource} is {reading or 'back to normal'}."
elif counted and state == "resolved":
headline = _upper_first(f"{counted}{on}, back within its limit.") if int(value) > 0 \
else _upper_first(f"{counted}{on} again.")
elif counted:
headline = _upper_first(f"{counted}{on}.")
elif state == "resolved":
headline = f"{metric_label(metric)} on {resource} is back within its limit" + (
headline = f"{metric_label(metric)}{on} is back within its limit" + (
f", at {reading}." if reading else ".")
elif reading is None:
headline = f"{metric_label(metric)} on {resource} has no reading yet."
headline = f"{metric_label(metric)}{on} has no reading yet."
else:
headline = f"{metric_label(metric)} on {resource} is {reading}."
headline = f"{metric_label(metric)}{on} is {reading}."
which = list(alert.get("which") or [])
at = datetime.fromisoformat(alert["timestamp"])
local, utc = moment(at, tz), moment(at, timezone.utc)
# The subject's tail is the reading where it stands ("web01 94%", "6"),
# then the first of what a count names.
tail = (reading if whole else f"{resource} {reading}") if reading else resource
if which:
tail += f" ({', '.join(which[:SUBJECT_NAMES])}{', …' if len(which) > SUBJECT_NAMES else ''})"
return {
"state": label, "tone": tone, "headline": headline,
"rule": alert["rule_name"], "resource": resource,
"reading": reading or "no data",
"which": _listed(which),
"condition": condition_text(metric, alert["operator"], alert["threshold"]),
"when": local, "when_utc": utc if show_utc and utc != local else "",
"note": alert.get("note") or "",
"subject": f"[Steward] {label}: {alert['rule_name']} — {resource}"
+ (f" {reading}" if reading and state in ("firing", "test") else ""),
"subject": f"[Steward] {label}: {alert['rule_name']} — "
+ (tail if state in ("firing", "test") else resource),
}
# How many names a subject carries before "…", and the email body before
# "and N more": enough to say which, short enough to read at a glance.
SUBJECT_NAMES = 3
LISTED_NAMES = 12
def _listed(names: list[str]) -> str:
"""Names in a sentence: "a, b and c"; past LISTED_NAMES, "a, b, … and 4 more"."""
if not names:
return ""
shown, more = names[:LISTED_NAMES], len(names) - LISTED_NAMES
if more > 0:
return f"{', '.join(shown)} and {more} more"
return shown[0] if len(shown) == 1 else f"{', '.join(shown[:-1])} and {shown[-1]}"
def _upper_first(sentence: str) -> str:
return sentence[:1].upper() + sentence[1:]
def alert_email(cfg: dict, alert: dict, to: Recipient) -> tuple[str, str, str]:
"""(subject, text, html) for an alert, written for one recipient in their
zone, linking to Steward when its address is set."""
+6 -4
View File
@@ -65,11 +65,13 @@ def render_webhook(template_str: str, alert: dict) -> str:
In the template, `alert.<field>` is escaped to sit inside a JSON string
("{{ alert.rule_name }}" stays valid whatever the name holds), with a
missing value reading "no data"; `raw.<field>` is the plain value for use
with |tojson ({{ raw.value | tojson }} gives a number or null). The
template runs sandboxed. ValueError says why a body could not be made.
missing value reading "no data" and a list (`which`) its items joined by
", "; `raw.<field>` is the plain value for use with |tojson
({{ raw.value | tojson }} gives a number or null, {{ raw.which | tojson }}
an array). The template runs sandboxed. ValueError says why a body could
not be made.
"""
shown = dict(alert)
shown = {k: ", ".join(map(str, v)) if isinstance(v, list) else v for k, v in alert.items()}
if shown.get("value") is None:
shown["value"] = "no data"
env = SandboxedEnvironment()
+1
View File
@@ -10,6 +10,7 @@
{{ fact("Rule", a.rule) }}
{{ fact("Resource", a.resource) }}
{{ fact("Reading", a.reading) }}
{% if a.which %}{{ fact("Which", a.which) }}{% endif %}
{{ fact("Fires when", a.condition) }}
{% call fact("When") %}{{ a.when }}{% if a.when_utc %}<br><span style="color:{{ c.muted }};">{{ a.when_utc }}</span>{% endif %}{% endcall %}
{% if a.note %}{{ fact("Note", a.note) }}{% endif %}
+2
View File
@@ -4,6 +4,8 @@
Rule: {{ a.rule }}
Resource: {{ a.resource }}
Reading: {{ a.reading }}
{% if a.which %}Which: {{ a.which }}
{% endif %}
Fires when: {{ a.condition }}
When: {{ a.when }}{% if a.when_utc %} ({{ a.when_utc }}){% endif %}
+1
View File
@@ -13,6 +13,7 @@
<tr><td style="padding:10px 0;border-bottom:1px solid {{ c.border }};">
<div>{{ badge(a.tone, a.state) }}</div>
<div style="padding-top:6px;font:600 15px/1.4 {{ c.font }};color:{{ c.text }};">{{ a.headline }}</div>
{% if a.which %}<div style="padding-top:2px;font:14px/1.5 {{ c.font }};color:{{ c.text }};">Which: {{ a.which }}</div>{% endif %}
<div style="padding-top:2px;font:13px/1.5 {{ c.font }};color:{{ c.muted }};">{{ a.rule }} · fires when {{ a.condition }} · {{ a.when }}</div>
{% if a.note %}<div style="padding-top:2px;font:13px/1.5 {{ c.font }};color:{{ c.muted }};">{{ a.note }}</div>{% endif %}
</td></tr>
+3
View File
@@ -3,6 +3,9 @@
{% for a in alerts %}
{{ a.state }}: {{ a.headline }}
{%- if a.which %}
Which: {{ a.which }}
{%- endif %}
{{ a.rule }} · fires when {{ a.condition }} · {{ a.when }}
{%- if a.note %}
{{ a.note }}