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:
@@ -0,0 +1,25 @@
|
||||
"""A figure that counts things names what it counts in the alert about it
|
||||
(#6400): the plugin recording it registers how, and a lookup that fails
|
||||
leaves the alert to go out without names."""
|
||||
from steward.core import alerts
|
||||
|
||||
|
||||
async def test_a_registered_count_is_named_and_others_are_not(monkeypatch):
|
||||
monkeypatch.setattr(alerts, "_NAMED", {})
|
||||
|
||||
async def short(session, resource):
|
||||
return [f"{resource}: api (0 of 1 running)"]
|
||||
|
||||
alerts.names_behind("docker", "swarm_services_short", short)
|
||||
assert await alerts.which(None, "docker", "swarm_services_short", "swarm") == ["swarm: api (0 of 1 running)"]
|
||||
assert await alerts.which(None, "host_agent", "cpu_pct", "web-1") == []
|
||||
|
||||
|
||||
async def test_a_lookup_that_fails_names_nothing(monkeypatch):
|
||||
monkeypatch.setattr(alerts, "_NAMED", {})
|
||||
|
||||
async def broken(session, resource):
|
||||
raise RuntimeError("database away")
|
||||
|
||||
alerts.names_behind("docker", "swarm_nodes_down", broken)
|
||||
assert await alerts.which(None, "docker", "swarm_nodes_down", "swarm") == []
|
||||
@@ -62,6 +62,18 @@ def test_no_reading_yet_is_said_not_shown_as_a_number():
|
||||
assert _said(says) == "CPU on web-1 has no reading yet (> 90%), firing for 3m"
|
||||
|
||||
|
||||
def test_a_count_says_how_many_of_what():
|
||||
"""#6400: "swarm services short on swarm is 6" named nothing and wasn't a sentence."""
|
||||
now = datetime.now(timezone.utc)
|
||||
swarm = alert_sentence(_rule("swarm_services_short", threshold=0.0, resource="swarm"), "firing", 6.0,
|
||||
now - timedelta(minutes=4), now)
|
||||
assert swarm["resource"] == "6 swarm services are short of their replicas"
|
||||
assert _said(swarm) == "6 swarm services are short of their replicas (> 0), firing for 4m"
|
||||
host = alert_sentence(_rule("mounts_stalled", threshold=0.0, resource="nas"), "firing", 1.0,
|
||||
now - timedelta(minutes=4), now)
|
||||
assert (host["resource"], _said(host)) == ("nas", "1 mount isn't answering on nas (> 0), firing for 4m")
|
||||
|
||||
|
||||
def test_a_silent_series_says_how_long_it_has_been_quiet():
|
||||
now = datetime.now(timezone.utc)
|
||||
says = alert_sentence(_rule(), "no_data", 50.0, now - timedelta(minutes=20), now)
|
||||
|
||||
@@ -106,6 +106,52 @@ def test_each_state_reads_as_a_sentence():
|
||||
assert (down["headline"], down["condition"]) == ("web01 is down.", "is down")
|
||||
|
||||
|
||||
SHORT = {**ALERT, "rule_name": "Swarm services short of replicas", "metric": "swarm_services_short",
|
||||
"value": 6.0, "operator": ">", "threshold": 0.0, "resource": "swarm", "source_module": "docker",
|
||||
"which": ["api_app (0 of 1 running)", "db (0 of 1 running)", "proxy (1 of 2 running)",
|
||||
"web_app (0 of 1 running)"]}
|
||||
|
||||
|
||||
def test_a_count_on_the_swarm_says_how_many_of_what_and_names_them():
|
||||
a = mail.alert_facts(SHORT, timezone.utc)
|
||||
assert a["headline"] == "6 swarm services are short of their replicas."
|
||||
assert a["which"] == ("api_app (0 of 1 running), db (0 of 1 running), proxy (1 of 2 running)"
|
||||
" and web_app (0 of 1 running)")
|
||||
assert a["subject"] == ("[Steward] Firing: Swarm services short of replicas — 6 (api_app (0 of 1 running),"
|
||||
" db (0 of 1 running), proxy (1 of 2 running), …)")
|
||||
one = mail.alert_facts({**SHORT, "value": 1.0, "which": ["db (0 of 1 running)"]}, timezone.utc)
|
||||
assert one["headline"] == "1 swarm service is short of its replicas."
|
||||
clear = mail.alert_facts({**SHORT, "state": "RESOLVED", "value": 0.0, "which": []}, timezone.utc)
|
||||
assert (clear["headline"], clear["subject"], clear["which"]) == (
|
||||
"Every swarm service has its replicas again.",
|
||||
"[Steward] Resolved: Swarm services short of replicas — swarm", "")
|
||||
silent = mail.alert_facts({**SHORT, "state": "NO_DATA", "value": None}, timezone.utc)
|
||||
assert silent["headline"] == "Steward has had no swarm services short of replicas reading from the swarm."
|
||||
|
||||
|
||||
def test_a_count_on_a_host_says_where():
|
||||
stalled = {**ALERT, "metric": "mounts_stalled", "value": 2.0, "operator": ">", "threshold": 0.0}
|
||||
assert mail.alert_facts(stalled, timezone.utc)["headline"] == "2 mounts aren't answering on web01."
|
||||
assert mail.alert_facts({**stalled, "state": "RESOLVED", "value": 1.0}, timezone.utc)["headline"] == \
|
||||
"1 mount isn't answering on web01, back within its limit."
|
||||
|
||||
|
||||
async def test_the_names_reach_both_parts_and_a_burst(monkeypatch):
|
||||
msg = await _sent(monkeypatch, CFG, SHORT)
|
||||
for part in (msg.get_body(("plain",)).get_content(), msg.get_body(("html",)).get_content()):
|
||||
assert "Which" in part and "web_app (0 of 1 running)" in part, part
|
||||
plain = await _sent(monkeypatch, CFG, ALERT)
|
||||
assert "Which" not in plain.get_body(("plain",)).get_content() + plain.get_body(("html",)).get_content()
|
||||
_, text, html = mail.burst_email(CFG, [SHORT, ALERT], EXTRA_NY)
|
||||
assert text.count("Which: ") == 1 and html.count("Which: ") == 1
|
||||
|
||||
|
||||
def test_a_long_list_is_cut_short():
|
||||
names = [f"svc{i:02d}" for i in range(15)]
|
||||
said = mail.alert_facts({**SHORT, "value": 15.0, "which": names}, timezone.utc)["which"]
|
||||
assert said.endswith("svc11 and 3 more") and "svc12" not in said
|
||||
|
||||
|
||||
def test_the_report_has_its_sections_in_both_parts():
|
||||
subject, text, html = mail.report_email(CFG, REPORT, PERSON_NY)
|
||||
assert subject == "[Steward] Weekly report, 4 Oct – 11 Oct 2026"
|
||||
|
||||
@@ -34,6 +34,12 @@ def test_raw_fields_give_bare_json_values():
|
||||
assert json.loads(n.render_webhook(tpl, {**ALERT, "value": None}))["value"] is None
|
||||
|
||||
|
||||
def test_the_names_behind_a_count_read_as_a_list_or_as_an_array():
|
||||
alert = {**ALERT, "which": ["a (0 of 1 running)", 'b "q"']}
|
||||
body = json.loads(n.render_webhook('{"w": "{{ alert.which }}", "raw": {{ raw.which | tojson }}}', alert))
|
||||
assert body == {"w": 'a (0 of 1 running), b "q"', "raw": ["a (0 of 1 running)", 'b "q"']}
|
||||
|
||||
|
||||
def test_a_missing_value_reads_no_data():
|
||||
body = n.render_webhook('{"v": "{{ alert.value }}", "note": "{{ alert.note }}"}', {**ALERT, "value": None})
|
||||
assert json.loads(body) == {"v": "no data", "note": ""}
|
||||
|
||||
@@ -21,6 +21,16 @@ def test_services_short_of_their_replicas_are_counted():
|
||||
{"service_name": "d", "desired": 0, "running": 0}, # scaled to 0: wants none
|
||||
{"service_name": "e", "desired": 1, "running": 2}]} # an update overlapping
|
||||
assert sh.figures(swarm)["swarm_services_short"] == 2
|
||||
# An alert on it names them, with how many of each run (#6400).
|
||||
assert sh.short_services(swarm["services"]) == ["a (0 of 1 running)", "b (2 of 3 running)"]
|
||||
|
||||
|
||||
def test_the_nodes_and_managers_counted_are_named():
|
||||
nodes = [_node("util01", availability="drain", status="down"), _node("docker02", status="down",
|
||||
reachability="unreachable"), _node("docker03"), _node("worker1", role="worker", status="disconnected")]
|
||||
assert sh.down_nodes(nodes) == ["docker02 (down)", "worker1 (disconnected)"]
|
||||
assert sh.unreachable_managers(nodes) == ["docker02"]
|
||||
assert set(sh.NAMERS) == set(sh.SWARM_METRICS)
|
||||
|
||||
|
||||
def test_a_drained_node_going_away_is_expected():
|
||||
|
||||
Reference in New Issue
Block a user