feat(alerts): a rule can fire when a reading rises, "rose by N" (#6724)

A rule compared a reading with a fixed number, so Synology Disk 1's bad
sectors going from 3,160 to 3,392 could say nothing: a count that only grows
is soon past any line. "rose by" compares each reading with the one before it,
kept on the alert's state and only for such a rule. A fall (a replaced disk)
doesn't fire. On the rule form, through MCP, and worded in emails, the alert
sentence, history and the timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 19:03:29 -04:00
co-authored by Claude Opus 5.5
parent d83f8ffe9b
commit e51d969c43
15 changed files with 133 additions and 12 deletions
+16
View File
@@ -100,3 +100,19 @@ def test_the_verdict_counts_what_fired_and_for_how_long():
def test_an_up_or_down_rule_says_the_word():
assert fires_when(_rule(metric="is_up", op=AlertOperator.lt, threshold=0.5)) == "fires when down"
def test_a_rule_on_a_rise_reads_as_one():
rule = _rule(metric="disk_bad_sectors", op=AlertOperator.rose, threshold=1.0)
assert fires_when(rule).startswith("fires on a rise of ")
assert AlertOperator.rose.said == "rose by" and AlertOperator.gt.said == ">"
def test_a_rise_is_against_the_reading_before_it():
from steward.core.alerts import _is_breached
rose = AlertOperator.rose
assert _is_breached(3392.0, rose, 1.0, previous=3160.0)
assert not _is_breached(3392.0, rose, 1.0, previous=3392.0) # flat
assert not _is_breached(0.0, rose, 1.0, previous=3392.0) # a replaced disk starts over
assert not _is_breached(3392.0, rose, 1.0) # nothing to have risen from
assert not _is_breached(3161.0, rose, 5.0, previous=3160.0) # less than asked
+48
View File
@@ -230,3 +230,51 @@ def test_a_rule_without_state_gets_one_and_is_watched_again(app):
assert first == (1, 0) # the covering rule has no state of its own
assert second == 1 # a second pass adds nothing
assert state == AlertStateEnum.firing
def test_a_rule_on_a_rise_fires_on_the_rise_and_clears_after_its_ok_run(app):
from sqlalchemy import select
from steward.alerts import service as alert_svc
from steward.core.alerts import record_metric, record_metrics
from steward.models.alerts import AlertState, AlertStateEnum
from steward.models.metrics import PluginMetric
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
res = f"box-{tag}"
async with app.db_sessionmaker() as s:
async with s.begin():
user = User(username=f"rs-{tag}", email=f"rs-{tag}@test", password_hash="x",
role=UserRole.admin)
s.add(user)
await record_metrics(s, [PluginMetric(source_module="host_agent", resource_name=res,
metric_name="cpu_pct", value=100.0)])
await s.flush()
with pytest.raises(ValueError, match="above 0"):
await alert_svc.rule_fields(
s, name=f"flat {tag}", source_module="host_agent", resource_name=res,
metric_name="cpu_pct", operator="rose", threshold=0)
fields = await alert_svc.rule_fields(
s, name=f"rise {tag}", source_module="host_agent", resource_name=res,
metric_name="cpu_pct", operator="rose", threshold=1,
consecutive_failures_required=1, consecutive_ok_required=2)
rule = await alert_svc.add_rule(s, fields, created_by=user.id)
rule_id = rule.id
async def after(v):
async with app.db_sessionmaker() as s:
async with s.begin():
await record_metric(s, "host_agent", res, "cpu_pct", v)
async with app.db_sessionmaker() as s:
state = (await s.execute(select(AlertState).where(AlertState.rule_id == rule_id))).scalar_one()
return state.state
assert await after(100.0) == AlertStateEnum.inactive # the first has nothing to have risen from
assert await after(100.0) == AlertStateEnum.inactive # flat
assert await after(105.0) == AlertStateEnum.firing
assert await after(105.0) == AlertStateEnum.firing # one good check of two
assert await after(105.0) == AlertStateEnum.inactive
assert await after(40.0) == AlertStateEnum.inactive # a fall is no rise
asyncio.run(_go())