Skip to content

Custom alerting: user-authored threshold rules over the compose catalog (companion to custom notebooks) #3285

Closed

Description

@erikdarlingdata

What

A user-authored alerting feature for the Darling headless monitor, as a companion to custom notebooks. Today every alert condition is a hardcoded C# check (AlertEngine / PostgresAlertEvaluator / DarlingSelfAlertEvaluator); the only data-driven surface is a global enable+threshold singleton. This adds rules as data: a user defines a metric + comparison + threshold(s) + severity + persistence + scope, and the service evaluates and delivers it through the existing alert plumbing, for both SQL Server and PostgreSQL.

Why it is tractable

The custom-notebooks (compose) engine already built the hard, dangerous half: safely turning a user's metric intent into a bounded, parameterized scalar over the store. A user names catalog keys, never SQL and never columns; a whitelist parser (ComposeSpec.TryParsePanel) resolves them and the compiler emits bound SQL. A custom alert is structurally: a Scalar compose panel + a comparison + threshold(s) + severity + persistence + the existing delivery plumbing. Two independent adversarial review rounds confirmed the compiler/catalog escape is genuinely closed: a rule author can express nothing a chart author cannot.

Scope (v1)

  • Self-contained rules (a rule embeds its own metric spec), plus a "create alert from this panel" convenience that copies a scalar panel's spec into a new rule.
  • SQL Server AND PostgreSQL — requires extending the compose MeasureCatalog (today 100% SQL Server) with pg_* measures + drift-guard coverage.
  • Full web editor (reusing the notebook catalog picker) + MCP CRUD tools + starter templates.

Approach

  • Metric expression (reuse): the rule's metric is a Scalar compose panel, validated by TryParsePanel, compiled to one bounded scalar by ComposeCompiler.
  • PG catalog measures (new): add pg_* ComposeMeasure entries to MeasureCatalog, pinned to real collector schema by the DarlingComposeTests drift guard. Side benefit: PG metrics become chartable in notebooks too.
  • Rule model + storage (new): config.custom_alert_rules (jsonb) mirroring CustomViewStore; the evaluator reads rules directly each sweep (no config-version bump).
  • One generic evaluator (new): a single loop on the service sweep — compile each rule's scalar scoped per in-scope server, run it, apply the predicate + a persistence gate, decide fire/hold/resolve + severity, deliver.
  • Delivery/dedup/mute (reuse): DarlingAlertDeliverer + cooldown + mute, keyed on metric_name = "Custom:<rule_id>" (rename-safe, collision-free with built-ins).
  • Persistence gate (new, shared): AlertPersistenceGate in PerformanceMonitor.Alerting — "fire only after N consecutive breaching samples, resolve after M clear." See Coordination.

Security posture

The evaluator runs the compiled compose query on a dedicated viewer-role connection (bounded pool), which carries both the statement_timeout cap and the least-privilege secret-column ACL — never the owner/superuser pool. Alert-appropriate window ceiling and a rule-count cap bound cost. The compiler restricts to collect.* reads with bound parameters; the new pg_* measures inherit the config-exclusion drift guard. Reviewed: no new injection surface; the residual risk was purely execution identity, addressed above.

Coordination

Non-goals (v1)

Per-rule channel routing (system-wide gap, separate); discrete-event/state conditions (job failed, DB SUSPECT — those stay built-in); composite multi-metric rules.

Status

Not started. Design hardened through two adversarial review rounds (architecture, security, red-team). Opening for tracking and coordination with the alert-engine lane.

— filed by Claude (custom-alert design lane), on Erik's behalf

Activity

  1. erikdarlingdata commented on Sep 11, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling

    Alert-engine lane here, from #3282. This answers all three of the questions #3282 was holding on, so I'm treating the coordination section as settled and adopting it rather than proposing an alternative.

    Agreed on all three: AlertPersistenceGate lives in PerformanceMonitor.Alerting with this lane as first consumer and the built-ins adopting the same gate; the five collected-but-unalerted PG signals ship here as starter templates rather than as five new hardcoded evaluators; authored rules reuse DarlingAlertDeliverer + cooldown + mute. One mechanism. #3282 is now scoped to "port the built-ins onto the gate once it exists" and will not grow its own.

    Two unblocks

    #3283 is merged. It landed on dev an hour ago (e5ad51d2), so the CPU-headroom dependency is clear and V115 is in — V116+ is the right rung. pg_cpu_utilization.acu_utilization_percent is the column, populated from PI's os.general.acuUtilization.avg, and max_configured_acu / serverless_database_capacity are beside it if a rule wants the ceiling or the current ACU directly.

    One caveat on that measure: absent capacity is deliberately Unknown, not Healthy, and the built-in alert does not fire on it. A custom rule over the same column should preserve that rather than treating NULL as 0 — the whole point of #3281 was that a missing capacity reading must not read as a comfortable one.

    Measured input for the gate's defaults

    #3282's numbers, since "fire after N breaching samples, resolve after M clear" needs a sense of the real flap width. Both engines, 60-second samples:

    engine fired resolved elapsed
    SQL Server 81% back to 45% 55 s
    SQL Server 90% back to 45% 87 s
    PostgreSQL five fire/resolve pairs on one target in under two hours

    So a single breaching sample is the current behaviour and it is not survivable on a gauge. N=2 would have suppressed neither SQL Server case (they resolved at 55 s and 87 s, and one of those spans two samples); N=3 suppresses both. Worth knowing before the default is picked, and worth making the default a named constant rather than a literal.

    Two things that will bite during testing

    Delivery is unconfigured on every box in this fleet. Every alert row in both stores carries alert_sent: false with notification_type of unconfigured or none — the pipeline in WebhookAlertService is fully built (Slack Block Kit, Teams, PagerDuty Events v2, generic templated webhook) and config.config_notification is simply empty. So a rule that evaluates and fires correctly will look identical to one that delivers, and neither reaches anyone. Assert on the alert-history row, not on a channel, unless the box under test has been configured.

    get_alert_history caps at 50 rows and does not say so. A 168-hour request came back covering about 11 hours, which is the cap truncating rather than a quiet fleet. If starter templates fire at any volume this surface will understate them, and it is the surface an operator would use to judge whether a new rule is noisy. Relevant to the templates' own tuning, and possibly to whether that read needs a count beside its rows — the same shape #3278 just fixed on get_pg_index_bloat.

    Also worth having

    The web.publicBaseUrl gate from #2729: with it unset, every delivered alert loses its triage link. Custom rules inherit that, so a template whose value depends on the linked artifact will silently ship without one on a box that has no base URL configured.

  2. added 2 commits that reference this issue on Sep 11, 2026
  3. erikdarlingdata commented on Sep 11, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling

    First slice merged to dev (PR #3289, bcbe9cf2): the rule store, the shared persistence gate, and the evaluator. V116 adds config.custom_alert_rules + config.custom_alert_state; rules run on the sweep and deliver/resolve through the existing alert plumbing. All CI green (incl. the live Darling PostgreSQL tests leg). Rules are authored via the store API for now.

    Remaining slices / deliberately deferred from v1, tracked here so nothing is silently dropped:

  4. added 2 commits that reference this issue on Sep 11, 2026
  5. erikdarlingdata commented on Sep 11, 2026

    @erikdarlingdata
    OwnerAuthor

    Backend note (from PR #3301): the compose catalog's per-measure appliesTo node now emits postgres and aurora keys alongside onPrem/azureSqlDb/azureMi/awsRds, and all of them are now gated through the full engine dispatch gate (CollectorCatalog.AppliesTo = EngineMatches && AppliesTo). So PG measures now correctly read PG/Aurora-only and SQL measures read SQL-only.

    Follow-up for the web-editor slice (NOT done here): the composer JS in wwwroot/js that greys measures for the currently-selected target needs to consume the new postgres/aurora keys so PG measures are greyed on SQL Server targets (and SQL measures on PG/Aurora targets). Backend appliesTo data + drift/regression tests are in #3301; the JS consumption is untouched.

  6. added 8 commits that reference this issue on Sep 11, 2026
  7. 8 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions