Repository navigation
PagerDuty: de-duplicate edge-type alerts into one incident - #5368
Conversation
…he operator opt into closing it on recovery
An edge-type alert pair — a monitored connection's "Server Unreachable" / "Server Restored", an availability group's "AG Replica Disconnected" / "AG Replica Reconnected" — is two halves of ONE incident, but PagerDuty shows two: the dedup_key falls back to the metric name when an alert carries no incident fingerprint, and the metric name encodes the current STATE, so each edge mints its own key. DerivePagerDutyDedupKey now renames both edges of a pair to the pair's canonical FIRING name via AlertFamily.RecoveryPairs — {serverId}:Server Unreachable (and {serverId}:AG Replica Disconnected) is one key whichever edge is notifying, so PagerDuty groups the pair into one incident instead of two, and any pair added to that census later joins by construction. The helper's three consumers (the PagerDuty payload, the generic {{dedup_key}} token, and the triage link) share the normalized key, which is the cross-channel correlation those call sites document. The rename deliberately runs before the fingerprint path: a closing edge that ever carried incident fingerprints would hash them under its own state name and split the pair again, and the deliverable recovery set is stateless.
The restore still leaves the incident open — the shipped default, because the tool does not auto-resolve a third party's incident unasked; the closing edge is an info-severity trigger on the shared key, correlated into the open incident. A new PagerDuty-only option, "Auto-resolve the incident when the alert's condition clears" (IAlertSettings.PagerDutyAutoResolve, defaulted on the interface so the deprecated Dashboard and every test fake keep the shipped behaviour), opts into PagerDuty's own lifecycle: with it on, the closing edge is sent as event_action "resolve" on the SAME dedup_key, closing the incident its firing edge's trigger opened. Test notifications always trigger, firing edges never resolve, and Teams/Slack/generic/email are untouched.
Lite stores the flag in settings.json (pagerduty_auto_resolve, documented in settings.sample.json) and authors it in the Settings window beside the EU-region toggle. Darling adds config_notification.pagerduty_auto_resolve as migration V165 — the ladder's next free version above the 164 top, non-null with a FALSE default so every existing row keeps the shipped behaviour — surfaced in the Viewer's Settings window, carried by the service's notification read (appended last, so no existing ordinal re-maps), the Viewer's full and secret-free selects and upsert ($20), the schema probe's newest COLUMN-existence sentinel and map arm (a fully migrated store maps to exactly SchemaVersion), and the new rung test. The column is a behaviour toggle rather than a credential, so unlike the routing key beside it, it stays in the viewer and mcp roles' SELECT grants — DarlingManagedRoles.ViewerRestrictedConfigTables and tools/provision-roles.sql. V164's top-rung claims hand off to the new rung test the same way every previous handoff did.
Tests: the new PagerDutyAutoResolveRungTests pins the rung, its DDL shape, the probe's three sites, every notification-row surface, the ACL and the settings seam; Lite's PagerDutyWebhookTests pins the shared per-pair dedup key (connection and replication alike), the default info-level trigger, and the opt-in resolve (firing edges, test sends and non-edge alerts still trigger).
erikdarlingdata
left a comment
There was a problem hiding this comment.
Claude posting for Erik Darling.
Thank you for this PR. The migration, the role grants, and the Lite and Darling settings look right. Four things need to change before it merges.
-
One replica that reconnects can resolve an incident that another replica still has open. The key at
WebhookAlertService.cs:2665is{serverId}:AG Replica Disconnected, with no availability group or replica name. Lite and Darling raise these alerts per replica but pass no context. So every replica on a server shares one key. If auto-resolve is on and replicas A and B disconnect, A reconnecting closes the incident while B is still down. The re-fire setting defaults to 0, so nothing opens it again. Please add the availability group and replica name to both keys of the pair. Or leave the AG pair out of auto-resolve. -
A server that goes down, comes back, and goes down again leaves no open incident. The recovery resolves the incident. The second "Server Unreachable" then meets the cooldown at
WebhookAlertService.cs:228. That cooldown still uses the original metric name (IncidentCooldown.cs:131), so the alert is not sent. One fix: when auto-resolve is on, clear the cooldown entry for the paired alert when the recovery is sent. -
The key change also applies when the setting is off. The rename at
WebhookAlertService.cs:2665does not check the setting. With the default, "Server Restored" now uses the "Server Unreachable" key. That changes{{dedup_key}}for generic webhook users and the triage link, andIAlertSettings.cs:98-102says the default keeps the shipped behavior. There is also a risk. Someone can resolve the outage incident before the server comes back. Then the recovery can open a new info alert under the outage key. The next outage can merge into that alert instead of paging. Please rename the key only whenPagerDutyAutoResolveis true. -
No test covers the send path. The new tests call
BuildPagerDutyPayloaddirectly. They still pass withautoResolve: _settings.PagerDutyAutoResolveatWebhookAlertService.cs:2413removed. Please add one test that sends through the service with anIAlertSettingsthat returns true, and checks the payload.
Two small items:
ViewerDataService.Notification.cs:273-278: the last lines ofEqualsand its closing brace lost their indentation.QueryStatsHourLedgerTests.cs:192:before.LastIndexOf("EXISTS") >= 0is always true when the line above it passes, so you can remove it.
You do not need a changelog entry, because we add it when the PR merges. The failed check-version job ran while the PR targeted main, and you can ignore it.
…ename, cooldown clear, send-path pins
Conflicts: - PgMigrations.cs: dev's V165 (password key tables) keeps its number; the PagerDuty auto-resolve rung is renumbered V165 -> V166, body unchanged. StorageVersion.SchemaVersion is now 166. - QueryStatsHourLedgerTests.cs: took dev's version (it already expects a rung above the ledger). - DarlingAlertSettings.cs: kept the PagerDuty auto-resolve property, read through dev's opened-webhooks seam. - ViewerDataService.cs: V166 column probe appended after dev's V165 probe; V166 arm sits above V165's in MapProbedSchemaVersion; GetBoolean(141) read added. - AlertContext.cs: kept both AgReplicaIdentity and dev's ShallowCopy. - WebhookAlertService.cs: the auto-resolve cooldown clear and the PagerDuty test override read the per-delivery settings snapshot. Follow-ups for the merge: opened webhook settings copy PagerDutyAutoResolve (and it is part of the change signature); PagerDutyAutoResolveRungTests and PasswordKeyRungTests re-pinned for V166 on top.
erikdarlingdata
left a comment
There was a problem hiding this comment.
Claude posting for Erik Darling.
Thanks for the quick turnaround on round 1. Four of the six points are fixed, and two are only part-way there. The re-check also found two new problems. Your branch had fallen behind our dev and had merge conflicts, so I fixed them: I merged dev into your branch as a990a0678, a plain merge commit with no rebase. The details are under "Already done in the merge" below. Please pull before you push again. If your branch falls behind dev again while you work on this, just say so here and I'll do the merge again, including renumbering the schema rung if another one lands first.
Round 1, point by point
| # | Point | Status |
|---|---|---|
| 1 | The AG key had no AG or replica in it | Fixed. The key now carries the group and replica name, and it's set on both edges in Lite and Darling. |
| 2 | Down, back, down: the second "Server Unreachable" hit the old cooldown | Part-way. See below. |
| 3 | The key rename ran with the setting off | Fixed. The shipped key is kept unless auto-resolve is on. |
| 4 | No test covered the send path | Fixed, with three send-path tests and two delivery-channel tests. |
| 5 | Indentation in ViewerDataService.Notification.cs |
Fixed. |
| 6 | The always-true assert in QueryStatsHourLedgerTests |
Part-way. Assert.Contains("EXISTS", before) is still always true, because the arm text sits inside EXISTS (SELECT 1 WHERE to_regclass(.... Please just remove the line. |
Still to do
- The cooldown clear doesn't survive the history seed (point 2).
- The problem:
ClearMetricremoves the in-memory key, butIncidentCooldown.EvaluateAsyncre-seeds a missing key from the alert history (seedLastSentUtc, lines 75–79). Both production paths pass a history store: the Darling worker and Lite's main window. So the next "Server Unreachable" reads the first outage's send time back from history, and it's throttled again. The tests pass only because none of them uses a seeding history store. - The fix: make the clear stick, for example a "cleared at" time per key that the seed respects, so a history row older than the clear is ignored. Add a send-path test with a fake history store that returns the first outage's send time.
- The problem:
- The replica identity is lost when the alert is copied for rendering.
- The problem:
IncidentDeliveryFilterbuilds newAlertContextobjects at about lines 169 and 236, and copies only the severity, attachment and incidents.PerEventNotification.NewContextdoes the same. The PagerDuty key is derived from that copy (renderContext,WebhookAlertService.csabout line 322). In Summary mode, a repeat that goes through the filter therefore falls back to the per-server key, and the resolve misses the per-replica incident. - The fix: copy
AgReplicaIdentityin those places, or derive the key from the original context, and add a Summary-mode test. (I traced this by reading the code. I didn't run it.)
- The problem:
- Minor: clearing on an AG reconnect is per server, so one replica coming back also re-arms a sibling replica that's still down. A comment saying that's accepted is fine.
- The PR description is out of date:
- it says the closing edge correlates into the open incident by default, which is no longer true;
- it says all three consumers share the normalised key, which is no longer true for the generic webhook;
- the test count has changed;
- it names V165.
Already done in the merge
devtook schema V165 (the password-key tables) first, so your rung is now V166, with its body unchanged.- The probe sentinel is ordinal 141, with its own
hasPagerDutyAutoResolvearm above V165's. PagerDutyAutoResolveRungTestsis re-pinned.- The role comments and
provision-roles.sqlsay V166.
- The probe sentinel is ordinal 141, with its own
- A conflict-free file did conflict in meaning:
dev's webhook-secrets change droppedPagerDutyAutoResolvefrom the opened settings copy, so the setting was always off. Your own test caught that, and the merge fixes it. - Security is clean. The test routing keys are fakes, the send override is internal and never set in production, and the new column is non-secret and granted correctly.
…survives the history seed (cleared-at record, pre-clear rows ignored, post-clear sends re-arm; same 2x-window eviction), the per-replica identity survives the delivery filter's and per-event render copies (WithRoster enumerates five, a census pin holds every future member honest), the per-server clear's sibling re-arm documented as accepted, and the seeding send-path and filtered-render pins take the places the store-less tests could not reach
|
Round-2 items, all in one review-fix commit on top of Point 6 (always-true assert) — already gone: Point 2 part-way (the clear must survive the history seed) — done in New finding 1 (replica identity lost in the render copies) — New finding 2 (per-server clear re-arms a still-down sibling) — documented as accepted at both the fan-out's clear site and PR description — refreshed: the default-off correlation claim is gone (each edge keeps its own state-named key), the shared-key claim is now opt-in-conditional (PagerDuty payload, generic Validation: per-project Release builds (Notifications, Darling Storage/Service/Viewer, Lite, Dashboard, Installer + both test projects) at 0 warnings, Debug spot-check clean; full ungated suites run on this box (Darling in one host; Lite in sequential class chunks covering every class once — the box parks a single all-class host, so the run is the union of per-chunk executions; counts in the PR body) — the only reds are the same pre-existing environmental set that fails identically on the upstream baseline (the machine-speed budget/hang-guard family in the statement filter suites, the culture-dependent browser-date pin, and the machine-time-zone Lite grid pin), none touching the PagerDuty/cooldown/filter surfaces. If |
erikdarlingdata
left a comment
There was a problem hiding this comment.
Claude posting for Erik Darling.
Round 2 is done. The cooldown clear now holds after the history seed, the replica identity survives the delivery and per-event copies, the sibling re-arm is documented, the always-true assert is gone, and the description is current. The full CI run is green. Thanks for working through all of this. Merging now.
…e incident (#5469) (#5470) A second PagerDuty recovery inside the alert cooldown now resolves the incident (#5469) With PagerDuty auto-resolve on, a server that went down, up, down and up inside the alert cooldown sent trigger, resolve and trigger, and then nothing. The second recovery was throttled by the first one's cooldown, so PagerDuty's second incident stayed open. The AG replica pair had the same problem. A delivered PagerDuty outage alert now also resets the cooldown on its recovery alert. The cooldown's history seed checks both alerts of the pair, so this still holds after the app restarts in the middle of an outage. The flag is read once per delivery. With auto-resolve on and PagerDuty set up for the alert, each AG replica keeps its own cooldown. Teams, Slack and the generic webhook then send each replica its own outage and recovery notice. Email stays per server. With auto-resolve off, or with PagerDuty not set up for the alert, nothing changes. The generic webhook's `{{dedup_key}}` is unchanged. The code is in the shared Notifications project, so Lite and Darling both get it, with tests in both. Fixes #5469 SECTION: Fixed ENTRY: - **PagerDuty auto-resolve now resolves a second outage that recovers inside the cooldown** ([#5469]) - The first recovery started its own cooldown. The clear after a delivered close only reset the outage alert, so the second recovery was throttled and PagerDuty's second incident stayed open. A delivered PagerDuty outage alert now also resets the cooldown on its recovery alert, in Lite and Darling. The same holds after the app restarts in the middle of an outage. With auto-resolve on and PagerDuty set up for the alert, each availability group replica also keeps its own alert cooldown. One replica's outage or recovery no longer throttles another replica's alert on the same server. With this on, each replica gets its own outage notice and its own recovery notice in Teams, Slack and the generic webhook. Email stays per server, with one cooldown for each server. With auto-resolve off, or with PagerDuty not set up for the alert, nothing changes. The generic webhook's `{{dedup_key}}` stays on per-state keys either way. This fixes unreleased behaviour from #5368.
What does this PR do?
Adds two related improvements to the PagerDuty channel, shared by Lite and Darling through
PerformanceMonitor.Notifications:a monitored connection's
Server Unreachable/Server Restored, an availability group'sAG Replica Disconnected/AG Replica Reconnected— are two halves of ONE incident, but PagerDuty showed two: when an alert carries no incident fingerprint thededup_keyfell back to the metric name, and the metric name encodes the current state, so each edge minted its own key. With the new setting on,DerivePagerDutyDedupKeyrenames both edges of a pair to the pair's canonical firing name viaAlertFamily.RecoveryPairs, so both edges share{serverId}:Server Unreachable(and{serverId}:AG Replica Disconnected:{AG}:{replica}, scoped per replica — one replica's reconnect must close only ITS incident), and PagerDuty groups the pair into one incident. The rename deliberately runs before the fingerprint path so a closing edge can never re-split the pair, and any pair added toRecoveryPairslater joins by construction. With the flag on, the helper's three consumers — the PagerDuty payload, the generic{{dedup_key}}token, and the triage link — all share the normalized key for a paired edge; with it off (the default), every consumer keeps the shipped per-state keys byte-identical — the closing edge does NOT correlate into the firing edge's incident, and correlation is a behaviour change scoped entirely to the operator who opted in. The AG pair's per-replica scope ridesAlertContext.AgReplicaIdentity, set by both engines' AG fire sites; a pair that arrives without the identity keeps the per-server key rather than failing the render.edge is sent as Events v2
event_action: "resolve"on the same dedup_key, closing the incident the firingedge's trigger opened — and a delivered close re-arms the pair's firing metric in the shared cooldown, so the
next down (or next disconnect) actually posts instead of meeting the old incident's window. Off (the default),
the closing edge remains an info-severity trigger on its own state-named key — nothing in the default path
correlates across edges, and nothing is auto-resolved unasked. Test sends always trigger, firing edges never
resolve, and Teams/Slack/generic/email are untouched.
Round-2 review fixes carried in this update, all pinned by tests:
ClearMetricremoved the in-memory key, butIncidentCooldown.EvaluateAsyncre-seeded a missing key from the alert history — and both production paths pass ahistory store — so the next "Server Unreachable" after a delivered restore was throttled against the FIRST
outage's own send row. The clear now records its instant per metric-level key; the seed ignores any row older
than it (a genuinely post-clear send still re-arms normally), and the record itself evicts under the same
2×-window rule as the cooldown entries.
IncidentDeliveryFilter's filtered copy and rosterpress, and
PerEventNotification's per-message contexts, rebuilt theirAlertContextfrom selected members anddropped
AgReplicaIdentity— so in Summary mode a repeat that went through the filter derived the PagerDuty keyfrom a copy that had lost the replica, and the resolve missed its per-replica incident. All three copy sites
carry the identity now (the reflection census in
Lite.TestspinsAlertContext's member set so the next memberadded is a decision about the copies).
replica's reconnect also re-arms a sibling replica still down on the same server — bounded by its per-replica
PagerDuty incident remaining open, and only ever consulted by a new firing.
EXISTSassert Erik flagged inQueryStatsHourLedgerTestsis gone (removed with thewebhook-secrets merge), and the PR text here was corrected: default-off correlation claims removed, the shared-key
claim made opt-in-conditional, V165 rebuilt as V166, and the test inventory refreshed.
Plumbing:
IAlertSettings.PagerDutyAutoResolveis a defaulted interface member (false) — the deprecated Dashboardand every test fake keep the shipped behaviour with no code change, per the
UncorroboratedFindingRoute/NotificationRoutesDIM precedent.pagerduty_auto_resolveinsettings.json(documented insettings.sample.json), authored by a checkbox in the Settings window beside the EU-region toggle.pagerduty-auto-resolve, the ladder's next free version above the password-keyrung's 165 top) adds
config_notification.pagerduty_auto_resolve boolean NOT NULL DEFAULT FALSE— existing rowskeep the shipped behaviour;
StorageVersion.SchemaVersion = 166. Carried by the service's notification read(column appended last, so no existing ordinal re-maps), the Viewer's full + secret-free selects and upsert (
$20),the schema probe's newest COLUMN-existence sentinel (
hasPagerDutyAutoResolve, probe ordinal 141) and map arm (afully migrated store maps to exactly
SchemaVersion), and the Viewer's Settings window. The column is a behaviourtoggle, not a credential, so — unlike the routing key beside it — it stays in the
viewer/mcproles' SELECTgrants (
DarlingManagedRoles.ViewerRestrictedConfigTables+tools/provision-roles.sql).Tests:
PagerDutyAutoResolveRungTestspins the V166 rung, its DDL shape, the probe's three sites, everynotification-row surface, the ACL classification and the settings seam, with the handoff from QueryStatsHourLedger's
pin in
QueryStatsHourLedgerTests(the established pattern). The send path is pinned THROUGHWebhookAlertService.TrySendWebhookAlertsAsync— three send-path tests in Lite (PagerDutyWebhookTests: theresolve gated on the flag; the replica-scoped reconnect resolve; the down-back-down re-arm through a seeding
history-store fake, so the delivered-close clear is proven against the re-seed that was undoing it) and two
wire-level paired-lifecycle tests in Darling (
AlertDeliveryChannelTests, flag on and off). The builder-level pinsremain in Lite (
PagerDutyWebhookTests) covering the shared per-pair key (connection and replication alike), thedefault info-level trigger, and the opt-in resolve. The delivery-filter's copy sites carry their own pins: the
identity survives the roster press, the filtered render and the derived per-replica key
(
Darling.Tests.IncidentDeliveryFilterTests), the per-event split keeps it (PerEventNotificationTests), and theAlertContextmember census + the copy-carry assert hold every future member honest (AlertIncidentRenderTests).Lite's cooldown-side pins (
IncidentCooldownTests) cover the clear surviving a seeding history store in BOTHdirections — a pre-clear row stays ignored, a post-clear send still re-arms — plus the evicted-clear bound.
Which component(s) does this affect?
How was this tested?
SQL Server version(s): N/A — no SQL Server-facing code changed. No collector T-SQL,
install/script, orSQL Server read/write is touched; the channels consume alert outcomes the collectors already produce, and the
only DDL is the Darling store's PostgreSQL migration V166.
PostgreSQL: V166's DDL is pinned textually (schema-qualified,
IF NOT EXISTS, non-nullFALSEdefault, noDML/grant/trigger) and the ladder/probe census pins assert density and the 166 top. The env-gated live store
tests (DARLING_TEST_PG / DARLING_TEST_PGRUNTIME) were not run locally and will exercise the migration in
gated CI.
Builds:
dotnet build -c Release(per project, matching CI order): Notifications, Darling Storage, Service,Viewer, Lite, Dashboard, Installer, plus both test projects — 0 warnings / 0 errors each (analyzer-equal
to Debug; Debug differs only by optimization).
dotnet build -c Debugspot-checked.Suites: the full ungated suites were run on this box —
Darling.Testsas a single host (27,522 tests,2,616 skipped: the env-gated live tests, which is the no-
DARLING_TEST_PG/DARLING_TEST_SQLexpectation) andLite.Testsas sequential class chunks covering every class exactly once (8,827 tests; the box parks asingle all-class host, so the union stands in for one host — every summary is a per-chunk execution). Red set:
in Darling, the 8 machine-speed budget/hang-guard pins in the statement-filter suites and the culture-dependent
browser-date pin in
WebQueryStoreGridTests; in Lite, the machine-time-zone pin inHistoryGridZoneTests— thesame pre-existing environmental set that fails identically on the upstream baseline, none touching the
PagerDuty/cooldown/delivery-filter surfaces this change edits.
Targeted verification steps:
Server RestoredandServer Unreachablederive the samededup_key; likewise the AGreplica pair per replica (
{serverId}:AG Replica Disconnected:{AG}:{replica}); a non-edge alert still keys on{serverId}:{metricName}.event_action: "trigger", severityinfo, on its OWN state-named key;incident stays open.
event_action: "resolve"on the firing edge's key;AG Replica Reconnectedcloses onlyITS replica's incident; firing edges, high-CPU, and test sends still trigger even with the flag on.
history row.
SchemaVersion(166), one-rung-short stores to 165,etc. — the connect-time gate can no longer refuse a current store.
Checklist
dotnet build -c Debug) (verified on Release per project — analyzer configuration is identical; Debug differs only by optimization)