Repository navigation
Add the custom-alert core: rule store, persistence gate, evaluator (#3285) - #3289
Conversation
…3285) First implementation slice of user-authored custom alerting for Darling. Rules are data (config.custom_alert_rules): a Scalar compose metric + predicate + hysteresis + scope, evaluated on the service sweep and delivered through the existing alert plumbing. SQL Server metrics, authored via the store API for now (MCP tools, web editor, and PG catalog measures are later slices). - Storage V116: config.custom_alert_rules + config.custom_alert_state; SchemaVersion 116. - AlertPersistenceGate (PerformanceMonitor.Alerting): the shared fire-after-N / resolve-after-M hysteresis primitive (the #3282 persistence contract), pure + unit-tested. - CustomAlertRuleStore / CustomAlertStateStore (owner pool) + CustomAlertRuleDefinition, validated by the compose TryParsePanel authority and pinned to Scalar. - CustomAlertEvaluator: runs each rule's compiled compose metric on a dedicated VIEWER-role pool (statement_timeout cap + least-privilege ACL, never the owner pool), applies the predicate + persistence gate, delivers/resolves via DarlingAlertDeliverer keyed on metric_name="Custom:<rule_id>" (rename-safe). No-data freezes the streak. Runs above the connect gate so a rule still evaluates for a disconnected server. - Viewer schema probe: V116 sentinel + top-rung arm. - Role grant: viewer+mcp INSERT/UPDATE/DELETE on config.custom_alert_rules. Hardened through two adversarial review rounds (architecture, security, red-team). Full solution Rebuild is 0 Warning(s) / 0 Error(s); gate + parser + schema/roles pin tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4
| private static void AddNullableTimestamp(NpgsqlCommand command, DateTime? value) => | ||
| command.Parameters.Add(new NpgsqlParameter | ||
| { | ||
| NpgsqlDbType = NpgsqlDbType.Timestamp, | ||
| Value = (object?)value ?? DBNull.Value, | ||
| }); |
There was a problem hiding this comment.
Likely runtime bug: unstripped DateTimeKind bound to a timestamp (no-tz) column.
state.LastEvaluatedAt / state.NextDueAt originate from DateTime.UtcNow in CustomAlertEvaluator (Kind = Utc), and are bound here straight through with NpgsqlDbType.Timestamp. custom_alert_state.last_evaluated_at / next_due_at are declared timestamp (naive) in the V116 migration.
CONTRIBUTING.md calls this out explicitly under "Darling's PostgreSQL store":
Strip the
DateTimeKindbefore binding a timestamp parameter —DateTime.SpecifyKind(value, DateTimeKind.Unspecified). Npgsql inferstimestamptzfrom aUtcorLocalkind... the row lands at the wrong hour with no error anywhere.
With Npgsql 10 (this repo's pinned version), binding a Kind=Utc DateTime to an explicit NpgsqlDbType.Timestamp parameter throws InvalidCastException at execute time (Cannot write DateTime with Kind=Utc to PostgreSQL type 'timestamp without time zone') — there's no global Npgsql.EnableLegacyTimestampBehavior switch anywhere in this repo, and every other store in Darling (PgCollectorRowWriter.Naive, QueryStorePlanMap.Naive, PgStatementText.Naive, ViewerDataService.MuteRules.Naive, etc.) strips the Kind first for exactly this reason.
If this throws, SaveAsync fails on every evaluation after DeliverFireAsync/DeliverResolveAsync already ran (see CustomAlertEvaluator.EvaluateRuleForServerAsync), the exception is swallowed by the per-rule catch in EvaluateServerAsync, and the state row never persists — so LoadAsync keeps returning Fresh every sweep. For a breachSamples = 1 rule that means the fire notification re-sends every ~60s sweep forever instead of firing once and resolving normally; for breachSamples > 1 it means the rule can never actually reach threshold. This path has no live-DB test in this PR, which is likely why it wasn't caught.
Suggest applying the same fix as elsewhere: Value = (object?)(value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Unspecified) : (object?)null) ?? DBNull.Value.
| -- carve, no config_command pivot). Created by V116, so provisioning runs after migration. NOTE: the sibling | ||
| -- config.custom_alert_state is written by the CustomAlertEvaluator on the OWNER pool only, so it deliberately | ||
| -- gets NO viewer/mcp grant here (add a viewer SELECT only when the editor surfaces a rule's firing status). |
There was a problem hiding this comment.
This comment says config.custom_alert_state "deliberately gets NO viewer/mcp grant here", but that's not actually true given how provisioning works: section 2 above (GRANT SELECT ON ALL TABLES IN SCHEMA {config} TO {admin}, {viewer}; and the {mcp} twin in section 6) is a blanket, re-run-every-start grant over every table in config, and custom_alert_state has no entry in ViewerRestrictedConfigTables (that mechanism only strips/re-grants column-level access for the three secret-holding tables). So after V116 creates custom_alert_state and provisioning re-runs, viewer and mcp already have full-table SELECT on it — the state/firing data isn't actually gated behind "when the editor surfaces a rule's firing status."
Not a security-critical leak (firing state isn't secret), but the comment is asserting an access boundary that doesn't exist, which could mislead whoever later adds the editor's read path into thinking they need to add a grant that's already implicitly there — or, if hiding this table's contents from viewer/mcp was actually intended, it needs an explicit carve (like ViewerRestrictedConfigTables) rather than just omitting the INSERT/UPDATE/DELETE grant.
|
Reviewed the custom-alert core slice (rule store, persistence gate, evaluator). This is Darling-only by design (Postgres/viewer-pool wiring; Lite has no analogous custom-rule surface yet), and that's explicitly scoped out in the PR description, so no parity flag there. Findings posted inline:
Everything else — the persistence-gate semantics (fire/resolve edges, streak capping, non-positive sample clamping), the rule-definition validator (scalar-only, count+ 🤖 Generated with Claude Code |
Adding V116 (custom-alert-core) displaced V115 (pg-cpu-capacity-headroom) as the ladder's top rung, so PgCpuCapacityHeadroomTests' "I am the top rung" assertions went red - exactly the handoff #3283's own test comment anticipated. Relax those to non-top (future-proof: turn off every sentinel above this rung's ordinal), and add CustomAlertCoreMigrationTests carrying the top-rung + probe-mapping claims for V116. These two were the only CI failures; the full non-live suite is otherwise green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4
| private static void AddNullableTimestamp(NpgsqlCommand command, DateTime? value) => | ||
| command.Parameters.Add(new NpgsqlParameter | ||
| { | ||
| NpgsqlDbType = NpgsqlDbType.Timestamp, | ||
| Value = (object?)value ?? DBNull.Value, | ||
| }); |
There was a problem hiding this comment.
Bug: SaveAsync will throw on essentially every call — custom-alert state never persists.
value here is state.LastEvaluatedAt / state.NextDueAt, which in CustomAlertEvaluator.EvaluateRuleForServerAsync are derived from DateTime.UtcNow (var now = DateTime.UtcNow; in EvaluateServerAsync, then nextDue = now.AddSeconds(...)) — both have DateTimeKind.Utc. They're bound here with NpgsqlDbType = NpgsqlDbType.Timestamp (i.e. PG timestamp without time zone) without stripping the Kind.
Npgsql 10 (this repo pins Npgsql 10.0.3) rejects writing a Utc-kind DateTime to a timestamp column — it throws InvalidCastException at ExecuteNonQueryAsync, and there's no Npgsql.EnableLegacyTimestampBehavior switch set anywhere in the repo to relax that. Every other store in this codebase strips the kind first (PgAlertStateStore.Naive(), PgMuteRuleStore.Naive(), the DateTime.SpecifyKind(x, DateTimeKind.Unspecified) calls throughout DarlingCollectorRunner/DarlingObservability/etc.), and CONTRIBUTING.md calls this out explicitly under "Darling's PostgreSQL store":
Strip the
DateTimeKindbefore binding a timestamp parameter —DateTime.SpecifyKind(value, DateTimeKind.Unspecified).
Net effect: CustomAlertStateStore.SaveAsync throws every time LastEvaluatedAt/NextDueAt is non-null (i.e. every real evaluation). It's swallowed by the per-rule catch (Exception ex) in CustomAlertEvaluator.EvaluateServerAsync, so the service won't crash, but the persistence gate's counters/firing/next_due_at never actually get written — the rule re-evaluates from Fresh state on every sweep and the feature is non-functional against a live store. This is almost certainly why it wasn't caught: the PR has no live-DB test exercising this store (CustomAlertStateStore/CustomAlertEvaluator aren't referenced anywhere in Darling.Tests), and the PR description notes "No live-DB test in CI yet."
Fix:
| private static void AddNullableTimestamp(NpgsqlCommand command, DateTime? value) => | |
| command.Parameters.Add(new NpgsqlParameter | |
| { | |
| NpgsqlDbType = NpgsqlDbType.Timestamp, | |
| Value = (object?)value ?? DBNull.Value, | |
| }); | |
| private static void AddNullableTimestamp(NpgsqlCommand command, DateTime? value) => | |
| command.Parameters.Add(new NpgsqlParameter | |
| { | |
| NpgsqlDbType = NpgsqlDbType.Timestamp, | |
| Value = value is DateTime dt ? DateTime.SpecifyKind(dt, DateTimeKind.Unspecified) : (object)DBNull.Value, | |
| }); |
| var lastEvaluatedAt = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5); | ||
| var nextDueAt = reader.IsDBNull(6) ? (DateTime?)null : reader.GetDateTime(6); |
There was a problem hiding this comment.
Minor, secondary to the write-side bug below: reader.GetDateTime(5)/(6) reads these timestamp (naive) columns back as DateTimeKind.Unspecified, but every other store that round-trips a naive-UTC column re-stamps it on read (DateTime.SpecifyKind(reader.GetDateTime(...), DateTimeKind.Utc) — see PgMuteRuleStore.CreatedAtUtc, PgAlertStateStore, DarlingSelfAlertEvaluator, etc.). Doesn't cause a functional bug today (DateTime comparisons ignore Kind), but worth doing for consistency once the write side is fixed, since these values do get compared against DateTime.UtcNow in CustomAlertEvaluator.
Review summarySolid first slice — the One correctness bug that looks blocking, left as an inline comment: Lite/Darling parity: no gap here — No other correctness, security, or performance issues found — SQL is fully parameterized throughout, migration is properly schema-qualified, and the severity-escalation / disable-orphan-reconcile gaps called out in the PR description as deferred scope are indeed handled as "no-op" rather than silently wrong. 🤖 Generated with Claude Code |
One consolidated [Unreleased] "Added" entry for the user-authored custom alert rules feature, shipped across the core (erikdarlingdata#3289) plus the follow-up slices: MCP CRUD tools (erikdarlingdata#3300), PostgreSQL compose measures (erikdarlingdata#3301), display-name in notifications (erikdarlingdata#3303), broken/never-firing self-health (erikdarlingdata#3304), resolve-on-delete reconcile (erikdarlingdata#3305), and evaluate-now (erikdarlingdata#3299). Byte-precise scripted insert: CRLF preserved, bare-CR census unchanged (0 before/after), diff is +5/-0 with no line-ending renormalization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4
First implementation slice of custom alerting (#3285): the rule store, the shared persistence gate, and the evaluator. Backend only — rules are authored via the store API in this slice; the MCP tools, the web editor / "alert from panel", the PostgreSQL catalog measures, and starter templates are the following slices.
What's in
config.custom_alert_rules+config.custom_alert_state;SchemaVersion116; viewer schema-probe sentinel + top-rung arm.AlertPersistenceGate(PerformanceMonitor.Alerting) — the shared fire-after-N / resolve-after-M hysteresis primitive (the No alert requires its condition to PERSIST: real fire/resolve pairs 55-87 seconds apart on both engines — and it is not the re-fire class closed seven times #3282 persistence contract), pure + 12 unit tests. The built-ins adopt the same gate as No alert requires its condition to PERSIST: real fire/resolve pairs 55-87 seconds apart on both engines — and it is not the re-fire class closed seven times #3282's own work.CustomAlertRuleStore/CustomAlertStateStore(owner pool),CustomAlertRuleDefinitionvalidated by the composeTryParsePanelauthority and pinned to Scalar (15 parser tests).CustomAlertEvaluator— compile → run on a dedicated viewer-role pool (which carries thestatement_timeoutcap and the least-privilege ACL, never the owner/superuser pool) → predicate → persistence gate → deliver/resolve viaDarlingAlertDeliverer, keyed onmetric_name="Custom:<rule_id>"(rename-safe). No-data freezes the streak; runs above the connect gate so a rule still evaluates for a disconnected server.INSERT/UPDATE/DELETEonconfig.custom_alert_rules(the state table stays owner-only).Verification
Scope boundaries (tracked on #3285)
Deferred to later slices: MCP CRUD tools, web editor + "alert from panel", PG catalog measures, starter templates, severity-escalation re-delivery, tag scope,
countwith</<=(validator rejects it — dead-collector footgun), the broken-rule self-health alert (currently logs a warning), disable/delete orphan-reconcile, display-name in notification titles (the body carries the rule name; titles still showCustom:<id>), and BYO / non-Windows viewer-pool wiring (the evaluator only starts on a managed Windows store).Not in this PR
🤖 Generated with Claude Code
https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4