Show each custom-alert rule's last-fired time on the rule cards (#3360) - #3370
Conversation
The Alert Rules list cards showed name, description, metric/threshold/scope
chips, and a version line, but not when the rule last fired. Add a per-rule
"last fired ..." / "never fired" to the card, fed by a bounded backend read
folded into the existing list summary.
Backend: CustomAlertRuleStore.ListSql LEFT JOINs each rule to MAX(alert_time)
over the config_alert_log rows keyed on the rule's IMMUTABLE fire key
'Custom:<id>' (CustomAlertEvaluator.MetricNameFor). The correlation is that
exact fire key only: a resolve/teardown row carries a different metric_name
('<name> Resolved'), which can never equal 'Custom:<numeric id>', so a resolve
is never counted as a fire and one rule's fires never bleed into another's.
The LIKE 'Custom:%' is only a pre-filter; the '= Custom:<id>' join key is what
correlates, so even a 'Custom:<id> Resolved' row (which passes the LIKE) does
not join. Every fire delivery writes a 'Custom:<id>' row regardless of
mute/channel, so MAX means "the last time the rule's condition fired" and the
read is deliberately not filtered on alert_sent/notification_type. It is one
round-trip: the grouped subquery scans the fire rows once and joins the whole
rule set (never a per-rule query). last_fired flows through CustomAlertRuleSummary
and BuildRuleSummariesNode, so the /api/alerts list AND the MCP
list_custom_alert_rules tool (which share both types) get it for free.
Frontend: alert-rules.js renders "Last fired <relTime>" (reusing util.js's
shared relTime helper) / "Never fired", styled to draw the eye to active rules.
Tests: a live correlation test (fire counts, natural + adversarial resolve rows
excluded, cross-rule no-bleed, never-fired null, one-read shape) and a pure
wire-shape test for the last_fired field (value + JSON null). Validated against
a local PostgreSQL 18.4 + TimescaleDB throwaway cluster.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4
|
Reviewed the diff (Darling-only: Correctness — the join logic checks out.
Parity — this looked like it might need a Lite twin at first glance (CONTRIBUTING.md's Two-Store Parity section), but Frontend — Minor, non-blocking note: the new grouped subquery filters No blocking issues found. |
What
Part of #3285. The Alert Rules list cards (
wwwroot/js/pages/alert-rules.js) showed name, description, metric/threshold/scope chips, and a version line — but not when the rule last fired. This adds a per-rule "last fired …" / "never fired" line to each card, fed by a bounded backend read folded into the existing list summary.The correlation (the load-bearing part)
A custom-alert fire writes a
config.config_alert_logrow withmetric_name = 'Custom:<rule_id>'(the immutable id —CustomAlertEvaluator.MetricNameFor). A resolve / teardown writes a differentmetric_name:'<display name> Resolved'(DeliverResolveAsync/WriteTeardownResolutionAsync).CustomAlertRuleStore.ListSqlnow LEFT JOINs each rule toMAX(alert_time)over a groupedconfig_alert_logsubquery, keyed on the exact fire key'Custom:' || id:LIKE 'Custom:%'is only a pre-filter to shrink the grouped set; the= 'Custom:' || idjoin key is what actually correlates. A resolve row'<name> Resolved'can never equal'Custom:<numeric id>', so a resolve is never counted as a fire, and one rule's fires never bleed into another's. Even a contrived'Custom:<id> Resolved'row (which passes the LIKE) does not join.Custom:<id>row regardless of mute / channel (the deliver path records the incident even when muted), soMAX(alert_time)means "the last time the rule's condition fired (the incident was recorded)" — the intended meaning. The read is deliberately not filtered onalert_sent/notification_type.config_alert_log's index leads withserver_id, so this deliberately cross-server aggregate scans rather than seeks; that is acceptable on this cold, operator-triggered list render over the retention-bounded history table (the per-(server, metric) cooldown seeds still seek the index).last_fired(ISO-8601 UTC, naive likeupdated_at, ornull) flows throughCustomAlertRuleSummaryandBuildRuleSummariesNode, so bothGET /api/alertsand the MCPlist_custom_alert_rulestool get it — they share both the summary type and the node builder, so it is a clean parity add (the MCP tool's description is updated to match).Frontend
alert-rules.jsrenders"Last fired <relTime>"(reusing the sharedutil.jsrelTimehelper) or"Never fired", with a small CSS touch so a fired rule reads in--fg(draws the eye to active rules) and "never fired" is dimmed.Tests
CustomAlertLastFiredLiveTests, gated onDARLING_TEST_PG): fire rows count (MAX), the natural'<name> Resolved'row is excluded, an adversarial'Custom:<id> Resolved'row is excluded (proving the exact join, not the LIKE, correlates), cross-rule no-bleed, never-fired → null, all resolved in oneListAsync.DarlingWebEndpointsTests): thelast_firedwire mapping (a value + JSONnullwhen never fired), plus the existing summary-shape test updated for the new field.Validated locally against a throwaway PostgreSQL 18.4 + TimescaleDB cluster (live test green; full custom-alert live family green). Service build:
0 Warning(s) / 0 Error(s).Not touched
CHANGELOG.md (folded post-wave), other lanes, the predicate/scope model.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4