fix(alerts): an alert on a count says how many of what, and names them (#6400)
"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:
+46
-8
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user