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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user