fix(alerts): a series that comes back gets one history event, not two at one instant (#5442)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m29s
CI / unit (push) Successful in 1m41s
CI / publish (push) Successful in 1m25s

When a silent series' sample also moved the rule (fired, resolved), the
resume and the transition were two events: the resume from the
pre-evaluation state at ingest time, the transition at the sample's
time. Ties sort by a random id, and the resume's "→ inactive" landed
after "→ firing", so the history read "silent, firing, back to normal"
at once and the episode closed while the alert was firing.

_track_samples now only clears the mark and returns its note; the first
sample's evaluation records one event from no_data to wherever that
sample took the rule ("firing" or "resolved" notification), or back to
the state it was in ("data_resumed", still only when notify_no_data).

Also fixes the #5441 integration test: its rule needs a recorded
resource to target (CI 8878).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:48:43 -04:00
co-authored by Claude Opus 5.5
parent a9bf4b7f16
commit 480b465b8e
3 changed files with 88 additions and 45 deletions
+12 -7
View File
@@ -104,13 +104,14 @@ def test_silence_is_marked_once_and_cleared_by_data(app):
await asyncio.sleep(0)
st = await state("loud")
assert st.no_data_since is None and st.state == AlertStateEnum.firing
first, *same_instant = await events("loud")
# The resume and the firing it leads to are one event, silent to
# firing: two events at one instant used to sort either way (#5442).
first, back = await events("loud")
assert first[:2] == ("inactive", "no_data")
# The resume and the firing it leads to share the sample's timestamp.
assert sorted(e[:2] for e in same_instant) == [("inactive", "firing"),
("no_data", "inactive")]
assert any((e[2] or "").startswith("data resumed after ") for e in same_instant)
assert [t for _, t, _ in sent if t != "firing"] == ["no_data", "data_resumed"]
assert back[:2] == ("no_data", "firing") and back[2].startswith("data resumed after ")
assert [t for n, t, _ in sent if n == f"loud {tag}"] == ["no_data", "firing"]
# The hushed rule says nothing of silence, but its firing is still news.
assert [t for n, t, _ in sent if n == f"hushed {tag}"] == ["firing"]
# A firing alert that goes silent stays firing, marked.
async with app.db_sessionmaker() as s:
@@ -123,7 +124,7 @@ def test_silence_is_marked_once_and_cleared_by_data(app):
st = await state("loud")
assert st.state == AlertStateEnum.firing and st.no_data_since is not None
assert (await events("loud"))[-1][:2] == ("firing", "no_data")
assert len(await events("hushed")) == 3 # disabled: not watched
assert len(await events("hushed")) == 2 # disabled: not watched
with patch.object(alerts, "_dispatch_notification", fake_dispatch):
asyncio.run(_go())
@@ -138,6 +139,7 @@ def test_a_series_quiet_only_while_steward_was_down_is_not_silent(app):
from steward.core import alerts
from steward.core.watch import watch
from steward.models.alerts import AlertEvent, AlertState
from steward.models.metrics import PluginMetric
from steward.models.users import User, UserRole
sent = []
@@ -154,6 +156,9 @@ def test_a_series_quiet_only_while_steward_was_down_is_not_silent(app):
user = User(username=f"bk-{tag}", email=f"bk-{tag}@test", password_hash="x",
role=UserRole.admin)
s.add(user)
s.add(PluginMetric(source_module="host_agent", resource_name=res,
metric_name="cpu_pct", value=1.0,
recorded_at=now - timedelta(minutes=18)))
await s.flush()
fields = await alert_svc.rule_fields(
s, name=f"back {tag}", source_module="host_agent", resource_name=res,