Guard COUNT range-op alerts against a stalled-collector no-data 0 (#3373) - #3378
Conversation
) A COUNT metric compiles to COUNT(*), which reads 0 over an empty window (never the NULL the evaluator's no-data freeze catches), so a stalled collector is indistinguishable from "zero events". The scalar path already rejects a '<'/'<=' COUNT alert for this; the range ops (#3371) did not mirror it. Reject a COUNT + range op whose band fires on 0 (outside [1,N], between [0,N]) at parse time -- the same guard the scalar path uses, keyed on the same plan.Aggregate == Count archetype and routed through the same band authority. A band that does not fire on 0 (outside [0,N], between [1,N]) and every non-COUNT metric are unaffected (SUM/AVG return NULL on an empty window, which the no-data freeze already handles). - Extract the band-membership math into a shared RangeBreaches so IsBreaching, the new BreachesOnZero() predicate, and the parse-time guard share one authority -- no parallel no-data path. - Mirror the rejection in the alert-editor.js save-blocker and the validate_custom_alert_rule MCP tool description, exactly as the scalar trap is. - Tests cover the four band/op combinations on a COUNT, a non-COUNT metric staying unaffected, and BreachesOnZero == IsBreaching(0); adds the previously missing scalar COUNT '<'/'<=' rejection test alongside them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5xoN5PzhzYfHHrfutyeEe
| /// band's own <see cref="IsBreaching"/> verdict at 0 so the band logic stays the single authority; meaningful | ||
| /// only for a range op (a scalar op returns false here — its count trap is the separate '<'/'<=' rejection). | ||
| /// </summary> | ||
| public bool BreachesOnZero() => IsRange && IsBreaching(0); |
There was a problem hiding this comment.
Minor: BreachesOnZero() isn't actually called anywhere in production code — the parse-time guard at line 305 calls the private static RangeBreaches(op, lower, upper, 0) directly (it has to, since there's no CustomAlertRuleDefinition instance yet at parse time), not this method. The only callers are the unit tests.
The doc comment above (lines 127-135) says "This is the predicate the parse-time count guard uses to reject such a rule" — that's not quite accurate; the guard uses the same underlying logic via RangeBreaches, but not this method itself.
Since nothing outside the test suite consumes it, consider either wiring it into an actual call site (e.g. a rule-health/UI affordance that flags "this band would fire on a stalled-collector zero") or dropping the public method and testing RangeBreaches's zero-case behavior through IsBreaching directly, to avoid shipping unused public API surface.
Review summaryReviewed the range-op extension of the stalled-collector count guard (#3373). This is a Darling-only change (custom alerting has no Lite counterpart), so there's no Lite/Darling parity concern here, and no T-SQL is touched. Correctness — Verified the core logic by hand-tracing all four band/op combinations:
Tests — the new theory cases cover the four band/op combinations, the non-COUNT (SUM) exemption, and One minor nit — left inline on No security, injection, or missing-index concerns — this PR only touches C# validation/JS mirror logic, no SQL. |
What
Extends the custom-alert no-data (stalled-collector) guard from the scalar
lt/leCOUNT case to thebetween/outsiderange ops (#3371), completing #3373 (part of #3285).A COUNT metric compiles to
COUNT(*), which reads 0 over an empty window -- never theNULLthe evaluator's no-data freeze catches -- so a stalled collector is indistinguishable from "zero events happened". A range band that treats that 0 as a breach therefore false-fires on a dead collector, exactly the ambiguity the scalar path already rejects.The rule
For a COUNT metric + range op, reject at parse time when the band fires on 0:
outside [1, N]0 < 1)between [0, N]0in band)outside [0, N]0is the inclusive edge)between [1, N]0 < 1)Non-COUNT metrics are unaffected:
SUM/AVGreturnNULLon an empty window, which the no-data freeze already handles.How it reuses the scalar guard (no parallel path)
CustomAlertRuleDefinition.TryParse, right beside the scalarlt/lecount trap, keyed on the sameplan.Aggregate == ComposeAggregate.Countarchetype check.RangeBreaches;IsBreaching, the new publicBreachesOnZero()predicate, and the guard all route through it --IsBreachingstays the single evaluation authority (BreachesOnZero() == IsBreaching(0)for a range op).alert-editor.jssave-blocker (which previously said range ops had "no count trap") and thevalidate_custom_alert_ruleMCP tool description.Tests
CustomAlertRuleDefinitionTests: the four band/op combinations on a COUNT (two rejected, two allowed), a non-COUNT (SUM) band that fires on 0 staying allowed,BreachesOnZero == IsBreaching(0)across range ops andfalsefor a scalar op, plus the previously-missing scalar COUNTlt/lerejection test alongside them.Verification
dotnet build Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Debug->0 Warning(s) / 0 Error(s); test project builds clean too.CustomAlertRuleDefinitionTests55/55 pass; the related non-live evaluate-now / severity-escalation / templates / rule-health classes 36/36 pass (theIsBreachingrefactor is behavior-preserving).Scope
No change to the range predicate JSON shape, the scalar path, or the severity model (no Critical tier -- that's #3372). CHANGELOG untouched (team lead folds it post-wave).
🤖 Generated with Claude Code
https://claude.ai/code/session_01Y5xoN5PzhzYfHHrfutyeEe