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
+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."""