Repository navigation
Alert history: High CPU stores its real value, not 0 (#1830) - #1834
Conversation
The High CPU alert fired with NumericCurrentValue null, and both history
stores' fallback parsed the display text "87% (Total CPU)" - which ends
with ')' so TrimEnd('%') was a no-op, the parse failed, and the ': 0' arm
silently stored zero for every High CPU row in Lite AND Darling (the
Dashboard stores text and was immune). Notifications always carried the
correct percentage; only the audit trail and MCP surface were corrupted.
- AlertEngine passes real numerics for High CPU, Blocking Detected, and
Deadlocks Detected (counts must not depend on parse luck).
- Per-event delivery carries per-incident numerics in both apps: each
incident's occurrence count, and the overflow trailer's COUNT - its
"+N more incident(s)" text is unparseable by design and was the second
live instance of the same coercion. Lite's SendDetectedAlertAsync also
stops dropping the outcome numerics on its summary route.
- Both stores' text fallback goes through a new shared
AlertValueParser.ParseOrDefault (leading-numeric extraction,
CurrentCulture on purpose - producers format with it, and de-DE writes
"92,5%"), so a future decorated text cannot re-coin a silent 0.
Tests: the two assertions that PINNED the null-numerics behavior are
flipped to pin the fix (watched red first); per-event tests pin the
per-incident numerics including the overflow row; a store round-trip pins
the exact field case ("87% (Total CPU)", no numerics -> 87); and
AlertValueParserTests cover the real producer strings including the
comma-decimal locale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| while (end < span.Length) | ||
| { | ||
| if (char.IsAsciiDigit(span[end])) | ||
| { | ||
| end++; | ||
| continue; | ||
| } | ||
|
|
||
| if (!seenSeparator | ||
| && span.Slice(end).StartsWith(decimalSeparator, StringComparison.Ordinal) | ||
| && end + decimalSeparator.Length < span.Length | ||
| && char.IsAsciiDigit(span[end + decimalSeparator.Length])) | ||
| { | ||
| seenSeparator = true; | ||
| end += decimalSeparator.Length; | ||
| continue; | ||
| } | ||
|
|
||
| break; | ||
| } |
There was a problem hiding this comment.
Correctness (plausible edge case): grouped/thousands-separator numbers are silently truncated to their first digit-group.
The scan advances on ASCII digits and (once) the culture's NumberDecimalSeparator, but never accounts for NumberGroupSeparator. For en-US input "1,234" the loop reads '1', then hits , — which doesn't match decimalSeparator (".") — and stops, returning 1 instead of 1234. Under de-DE the grouping character is ., so a grouped de-DE value like "1.234,5" would similarly truncate to 1.
None of today's producers format with grouping (effectiveBlockingCount.ToString() etc. are ungrouped), so this doesn't fire yet. But AlertValueParser is now the general-purpose fallback both history stores lean on for any future decorated text, and the whole point of #1830 was to stop numeric values depending on the producer's exact string shape — a parser that quietly returns a truncated (but plausible-looking) number for a grouped value is the same failure mode in a new guise, and harder to notice than the old : 0 case since the output isn't obviously wrong.
Worth either handling NumberGroupSeparator in the scan (skip it between digit runs, same guarded-lookahead shape as the decimal separator) or documenting that grouped input is unsupported so a future caller doesn't assume otherwise.
| stored 0 for every High CPU row. */ | ||
| var currentValue = record.NumericCurrentValue | ||
| ?? (double.TryParse(record.CurrentValueText.TrimEnd('%'), out var cv) ? cv : 0); | ||
| ?? PerformanceMonitor.Notifications.AlertValueParser.ParseOrDefault(record.CurrentValueText); |
There was a problem hiding this comment.
Nit: this file already has using PerformanceMonitor.Notifications; (line 12), so the fully-qualified PerformanceMonitor.Notifications.AlertValueParser.ParseOrDefault(...) here and on the next line can just be AlertValueParser.ParseOrDefault(...). Same in Darling/PerformanceMonitor.Darling.Service/PgAlertHistoryStore.cs, which also already has the using.
ReviewOverview: Fixes #1830 — High CPU (and Blocking/Deadlocks) alert history rows stored
Lite/Darling parity: Good — the root cause lived in the shared Correctness: left two inline notes — a plausible latent edge case in the new
Minor: No missing-index or other DMV-style suggestions here — out of scope per repo convention. Overall this is a well-scoped, well-tested fix; nothing here blocks merging. |
Summary
Fixes #1830: every High CPU row in Alert History showed 0% (rendered
0,0 %in the reporter's locale). Write-time defect, present in Lite and Darling since the alert engine port: the High CPU outcome fired withNumericCurrentValue: null, and both history stores' fallback parsed the display text"87% (Total CPU)"— which ends with), soTrimEnd('%')was a no-op,double.TryParsefailed, and the: 0arm silently stored zero. Detection, cooldowns, toasts, email, and webhooks were always correct (they use the text); only the stored audit trail and the MCP alert tool were wrong — which is exactly why nobody saw it until now. The deprecated Dashboard stores the text and was immune.PerformanceMonitor.Alerting, shared → both apps fixed at once): High CPU passesalertCpuValue+ threshold; Blocking Detected and Deadlocks Detected pass their counts too, so their stored values stop depending on parse luck.PerEventNotification.MessagegainsNumericValue— each incident's occurrence count, and the overflow trailer's count (its"+N more incident(s)"text is unparseable by design and was the second live instance of the same coercion, storing 0 in the field today). Lite'sSendDetectedAlertAsyncalso stops dropping the outcome numerics on its summary route — the engine fix alone couldn't reach Lite's blocking/deadlock rows.AlertValueParser.ParseOrDefault(leading-numeric extraction). Parses with CurrentCulture deliberately — the producers format with it, and a de-DE"92,5%"must parse as 92.5; hardening to invariant would break exactly the reporter's locale. State-string alerts (AG"PRIMARY", Server Unreachable) still store 0 into the NOT NULL column — that's a read-side design question, deliberately not smuggled into this fix.Test plan
Assert.Null(fired.NumericCurrentValue)in Darling'sAlertEngineTestsand Lite'sLiteAlertForwardingTests) watched red against the fix, then flipped to pin the real values{1,2,3}/{3,1,1}, overflow row carries its count, thresholds forwarded on every send"87% (Total CPU)", no numerics → stored 87, not 0AlertValueParserTestsover the real producer strings, including the comma-decimal locale under de-DE and the no-number state strings🤖 Generated with Claude Code