Skip to content

Add the custom-alert core: rule store, persistence gate, evaluator (#3285) - #3289

Merged
erikdarlingdata merged 3 commits into
devfrom
feature/3285-custom-alert-core
Sep 11, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
feature/3285-custom-alert-core

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

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

Verification

  • Full solution Rebuild: 0 Warning(s) / 0 Error(s).
  • 75+ tests green: the persistence gate, the definition parser, the role-grant shape pins, and the schema-version probe pins. No live-DB test in CI yet (see below).

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, count with </<= (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 show Custom:<id>), and BYO / non-Windows viewer-pool wiring (the evaluator only starts on a managed Windows store).

Not in this PR

  • Live end-to-end on DARLING01 (applying V116 to the production store + running against the live fleet) — held for explicit go.
  • CHANGELOG entry — deferred to the batch splice per the parallel-wave protocol.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4

erikdarlingdata and others added 2 commits September 11, 2026 07:31
…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
Comment on lines +161 to +166
private static void AddNullableTimestamp(NpgsqlCommand command, DateTime? value) =>
command.Parameters.Add(new NpgsqlParameter
{
NpgsqlDbType = NpgsqlDbType.Timestamp,
Value = (object?)value ?? DBNull.Value,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DateTimeKind before binding a timestamp parameter — DateTime.SpecifyKind(value, DateTimeKind.Unspecified). Npgsql infers timestamptz from a Utc or Local kind... 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.

Comment on lines +693 to +695
-- 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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

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:

  1. Likely runtime bug (CustomAlertStateStore.cs, AddNullableTimestamp): LastEvaluatedAt/NextDueAt (both DateTime.UtcNow-derived, Kind = Utc) are bound straight through to a NpgsqlDbType.Timestamp parameter without stripping the DateTimeKind, contrary to the convention CONTRIBUTING.md documents and that every other Darling store follows (Naive()/AsNaive() helpers throughout). With Npgsql 10 this throws on SaveAsync, which — because the exception lands after delivery and is swallowed by the per-rule catch in CustomAlertEvaluator — means state never persists and a firing rule likely re-fires every sweep instead of firing once and resolving normally. There's no live-DB test in this PR exercising SaveAsync, which is probably why it wasn't caught.

  2. Misleading grant comment (DarlingManagedRoles.cs): the comment claims config.custom_alert_state "deliberately gets NO viewer/mcp grant," but the blanket GRANT SELECT ON ALL TABLES IN SCHEMA config (re-run every provisioning start) already gives viewer/mcp full read access to it — there's no ViewerRestrictedConfigTables-style carve for this table. Not a secret-exposure issue, but the stated access boundary doesn't match reality.

Everything else — the persistence-gate semantics (fire/resolve edges, streak capping, non-positive sample clamping), the rule-definition validator (scalar-only, count+</<= rejection, critical-vs-warn ordering, window ceiling), the store's parameterization (all bound $N params, no string-built SQL), and the migration ladder bookkeeping (rung/version/pins/viewer-probe arm) — looked correct and consistent with existing patterns in the codebase.

🤖 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
Comment on lines +161 to +166
private static void AddNullableTimestamp(NpgsqlCommand command, DateTime? value) =>
command.Parameters.Add(new NpgsqlParameter
{
NpgsqlDbType = NpgsqlDbType.Timestamp,
Value = (object?)value ?? DBNull.Value,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DateTimeKind before 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:

Suggested change
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,
});

Comment on lines +100 to +101
var lastEvaluatedAt = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5);
var nextDueAt = reader.IsDBNull(6) ? (DateTime?)null : reader.GetDateTime(6);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Review summary

Solid first slice — the AlertPersistenceGate primitive is cleanly separated (pure, well-tested edge cases including overflow-clamping and degenerate sample counts), the rule validator reuses the compose TryParsePanel authority instead of re-validating metrics, the migration rung follows the established V31/custom_views pattern (schema-qualified, no bump trigger, sentinel wired into MapProbedSchemaVersion above the previous top rung), and the evaluator is properly failure-isolated per rule.

One correctness bug that looks blocking, left as an inline comment: CustomAlertStateStore.AddNullableTimestamp binds state.LastEvaluatedAt/NextDueAt (both DateTimeKind.Utc, from DateTime.UtcNow) to a NpgsqlDbType.Timestamp parameter without stripping the Kind first. Npgsql 10 (pinned in this repo) throws InvalidCastException writing a Utc-kind DateTime to a naive timestamp column, and there's no legacy-timestamp switch set anywhere in the repo to relax that. Every other store here does DateTime.SpecifyKind(value, DateTimeKind.Unspecified) first (PgAlertStateStore.Naive(), PgMuteRuleStore.Naive(), etc.), and CONTRIBUTING.md calls this exact landmine out under "Darling's PostgreSQL store." Net effect: CustomAlertStateStore.SaveAsync throws on essentially every real evaluation, gets swallowed by the per-rule catch in CustomAlertEvaluator.EvaluateServerAsync, and the feature silently never persists state against a live store. Consistent with the PR's own "No live-DB test in CI yet" caveat — neither CustomAlertStateStore nor CustomAlertEvaluator is referenced from Darling.Tests, so nothing currently exercises this write path.

Lite/Darling parity: no gap here — custom_views (the pattern this PR explicitly mirrors) is itself Darling-only with no Lite counterpart, so a Darling-only migration rung + store for a centrally-authored, web-editor-surfaced entity is consistent with existing precedent, not a drift from it.

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

@erikdarlingdata
erikdarlingdata merged commit bcbe9cf into dev Sep 11, 2026
8 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/3285-custom-alert-core branch September 11, 2026 12:01
pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Sep 18, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant