Skip to content

Fold fire-site severity into the alert context so channels render it (#2090) - #2092

Merged
erikdarlingdata merged 1 commit into
devfrom
severity-plumbing-2090
Aug 7, 2026
Merged

Fold fire-site severity into the alert context so channels render it (#2090)#2092
erikdarlingdata merged 1 commit into
devfrom
severity-plumbing-2090

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Part 1 of #2090 — the bug half, diagnosed precisely in the report and verified against source.

Root cause

Self-alerts fire with explicit severities (Collection Stopped → Critical, etc.) that ride AlertOutcome.Severity into the log line and then die: the channel builders read only Context.SeverityOverride, and self-alerts deliver Context: null. Teams/Slack/PagerDuty/webhooks rendered INFO-blue while the log said Critical.

Fix

Part 2 (machine-readable category/severity tokens in all four payloads per the acceptance criteria) follows separately.

🤖 Generated with Claude Code

…2090)

Every self-alert fires with an explicit severity that rode
AlertOutcome.Severity into the log line and then died: the channel
builders read only Context.SeverityOverride, and self-alerts deliver
with Context: null -- so Collection Stopped rendered INFO-blue in Teams
while its log line said Critical. The deliverer now folds
outcome.Severity into the context once, upstream of every channel
(??= so an explicit override from a context builder wins), and the
folded context serializes into alert history so replays keep it.

Backstop arms in AlertSeverity.ForMetric for the six self-alerts
(Critical, matching their fire sites) and Version Store (PVS)
(WARNING-amber; #1984 deliberately ships it without a severity tier) --
the replay renderer has no context by design. Plus the tripwire: a test
enumerating EVERY fired metric name against the map, because the #1136
fall-through has now shipped five times and nothing forced a new
alert's author to visit the map.

Part 1 of #2090; the machine-readable category/severity payload facts
follow separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +129 to +134
/* #2090: the fire site's severity rode AlertOutcome.Severity but the channel builders read
only Context.SeverityOverride — so every self-alert (fired with Context: null) rendered
INFO-blue in Teams/Slack/PagerDuty/webhooks while its log line said Critical. Fold the
outcome's severity into the context here, once, upstream of every channel; ??= so an
explicit override set by a context builder still wins. The context also serializes into
alert history, so replays keep the severity too. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "replays keep the severity too" claim is incorrect — SeverityOverride is never persisted.

AlertContextSerializer.Serialize (PerformanceMonitor.Notifications/AlertContext.cs:288-303) builds AlertContextDto from only context.Details and context.Incidents; SeverityOverride isn't in the DTO at all. AlertContext.SeverityOverride's own doc comment says so explicitly (AlertContext.cs:30-33): "Deliberately not persisted (like AttachmentXml)... the JSON projection need not carry it."

So this fold has no effect on alert-history replay (AlertDetailWindow / the Viewer equivalent, which reconstruct context via TryDeserialize(contextJson)). Replay correctness for these six self-alerts comes entirely from the new AlertSeverity.ForMetric arms added in this same PR — the fold only affects the live send, and for these fixed-severity self-alerts it's actually redundant there too, since ForMetric(metricName, null) already resolves correctly once the map arm exists.

This matters beyond just an inaccurate comment: a future self-alert author who fires with a runtime-varying severity (like Volume Free Space/Database State do) and reads this comment could reasonably skip adding a metric-name arm, believing the folded context will "carry the severity" into history replay. It won't — and that's exactly the #1136 fall-through this PR's tripwire test exists to catch. The same claim is repeated in CHANGELOG.md:207.

Comment on lines +142 to +151
[Fact]
public void SelfAlertSeverity_FoldedIntoContext_RendersCriticalNotInfo()
{
var ctx = new AlertContext();
ctx.SeverityOverride ??= AlertSeverityLevel.Critical; /* the deliverer's #2090 fold */
var payload = WebhookAlertService.BuildTeamsPayload(
"Collection Stopped", "S1", "5 runs failing", "collecting", Branding, context: ctx);

Assert.Contains("CRITICAL", payload);
Assert.DoesNotContain("2eaef1", payload);

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 test doesn't actually exercise the fix. It manually pre-sets ctx.SeverityOverride and calls WebhookAlertService.BuildTeamsPayload directly — the production fold in DarlingAlertDeliverer.SendAndRecordAsync (DarlingAlertDeliverer.cs:135-138, the context ??= new AlertContext(); context.SeverityOverride ??= outcome.Severity; logic) is never invoked. A regression there (wrong operator, wrong field, the mutation getting dropped/reordered) would pass every test in this PR.

Darling.Tests/DarlingDeliveryModeTests.cs:265 already shows the harness needed to close this gap — it builds a real DarlingAlertDeliverer with fake settings/history/webhooks (no live PG required). A test there calling DeliverAsync with a self-alert-shaped outcome (Context: null, Severity: Critical, e.g. mirroring "Collection Stopped") and asserting on the recorded history row or the actual webhook payload would cover the real code path instead of a hand-simulated stand-in for it.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Reviewed. The fix is well-scoped and the diagnosis matches the code: Darling's self-alerts (DarlingSelfAlertEvaluator.FireAsync) always fire with Context: null + an explicit Severity, and the shared channel builders (WebhookAlertService/EmailTemplateBuilder) only ever consult context?.SeverityOverride, so those six alerts fell through to the INFO-blue default. The new AlertSeverity.ForMetric arms fix that fall-through for both live sends and history replay (replay reconstructs context from context_json, and — see inline comment — SeverityOverride was never in that projection to begin with, so the arm is what actually matters there, not the fold).

No Lite/Darling parity issue: this bug class is Darling-only by construction (Lite has no self-alert path that fires with Context: null + a non-null Severity — the shared AlertEngine's few Severity: null call sites are all cases where Context already carries the same value, or is null together with Severity). The AlertSeverity.cs map itself is shared, so the "Version Store (PVS)" arm fixes PVS rendering identically for both apps.

Left two inline comments:

  1. DarlingAlertDeliverer.cs — the comment (and the matching CHANGELOG line) claims the folded severity "serializes into alert history so replays keep it too." That's not what the code does: AlertContextSerializer.Serialize never includes SeverityOverride (its own doc comment says so). Worth correcting so a future self-alert with a runtime-varying severity doesn't skip adding a map arm on the mistaken belief that the context fold covers replay.
  2. Lite.Tests/AlertSeverityTests.cs — the new "pinned end-to-end" test never actually calls DarlingAlertDeliverer; it hand-simulates the fold's output. Darling.Tests/DarlingDeliveryModeTests.cs already has the fake-settings/history/webhooks harness to exercise the real SendAndRecordAsync fold directly.

Nothing else stood out — no injection/secrets/perf concerns in this diff.

@erikdarlingdata
erikdarlingdata merged commit 7ab36e0 into dev Aug 7, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the severity-plumbing-2090 branch August 7, 2026 07:55
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