Repository navigation
Every alert went to every channel because the model had one routing decision, made at install time: a sparse routes table sends self-monitor, reports, agent-jobs and performance pages where their audiences are, the taxonomy is written down, and the ledger says where each post went (#3598) - #3668
Conversation
|
Coordinator review under the review-dark window (the bot cannot post on dev until the #3650 fix reaches main). Read the three hunks the self-review named as riskiest: (1) |
…spliced once Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
…spliced once (#3672) Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
The lie
The notification model had one routing decision in it, made at install time.
config.config_notificationis a single row —CHECK (id = 1)— holding one destination per channel type, andWebhookAlertService.TrySendWebhookAlertsAsyncfanned every alert to every configured channel without consulting what the alert was. One stream interleaved four audiences: the monitor's own health (Compression Job Stuck,Retention Held), scheduled prose (Collector Cost Digest,Fleet Sweep Rollup), Agent job failures, and the deadlock and blocking pages the channel exists for. The product knew the answer at send time — the metric name is in hand at every call site — and threw it away; a reader did the routing in their head on every post. The generic-webhook template branching on{{metric}}in an external router was the only escape hatch, and it forfeited the native Slack rendering.The fix — V131, a sparse routes table layered over the parent row
config.config_notification_routes:route_id(identity),metric_match(a family name or an exact metric name), one destination per channel type under the parent row's own column names —teams_url,slack_url,generic_url,pagerduty_routing_key,smtp_recipients, allNOT NULL DEFAULT ''—enabled,modified_at, and a generatedconfigured_channelspresence column. Theconfig_collector_schedules-over-code-defaults shape: empty means inherit, so a store with zero rows behaves byte for byte as it did and the rung is a no-op for every existing install. A V117-shaped statement-level trigger bumpsconfig_versionon every write, so the running service hot-reloads a route on its next sweep (design point 5, pinned live).The taxonomy is written down (design point 1).
AlertFamilyin Notifications is the closed enum —self-monitor,reports,agent-jobs,performance— with the census of every delivered metric name each family owns, the two dynamic prefixes (Custom:andAnalysis:→ performance), and the two delivered recoveries paired with their firing. A census test walks every metric constant the engines declare plus every string literal at anAlertOutcome(/FireAsync(fire site (read from source) and asserts each is classified explicitly, that no census entry is an orphan, and that everyAlertSeverity.ForMetricarm is in the census — so a new alert cannot ship unclassified. The fall-through isperformance, deliberately: an unclassified alert lands where every alert landed yesterday, never in a report channel someone stopped watching.Resolution (
NotificationRouter.Resolve, pure): per channel, an enabled route matching the metric exactly — its own name first, then the firing a recovery pairs with — then an enabled route matching its family, then the parent default; within a level the lowestroute_idwith a non-empty column. "Per channel" is the load-bearing phrase: an exact route that sets only Slack takes Slack and lets Teams fall to the family route and then to the parent. A route can enable a channel the parent lacks — a PagerDuty key on theperformanceroute alone, with none on the parent, is how "only pages page" is spelled. Resolution runs after the cooldown and the repeat budget in both the webhook fan-out and the email path (design point 2): it changes where a post lands, never whether it is sent, and a throttled alert never consults a route.Recoveries follow their firing (design point 4). The shared engine's condition-cleared notices never reach a channel —
AlertResolutionis a history row by design — so the only recoveries a route sees are the two the self-alert evaluator fires under their own names:Server Restoredroutes asServer Unreachable,AG Replica ReconnectedasAG Replica Disconnected. An exact route on the firing catches the clear; a route spelling the recovery's own name outranks it.The ledger says where it went (design point 3).
AlertContextDtogains a trailingRoutemember beside #3635'sSeverity—{Family, RouteId, Destinations:[{Channel, RouteId, Source}]}— written byDarlingAlertDelivererandDarlingFindingAlertSenderfrom the decision the fan-out actually used, no schema change.get_alert_historysurfaces it asrouteon both SKUs (Lite's is always null; it has no routes and says so).Secrets. Four of the five destination columns are the same bearer secrets they are on the parent row, so they join the fail-closed
viewer/mcpcolumn carve (ViewerRestrictedConfigTables, mirrored inprovision-roles.sql). The generatedconfigured_channelscolumn exists precisely for the roles denied the URLs: it discloses which channels a route sets and nothing else.mcpgets exactly the two writes that move no destination —UPDATE (enabled, modified_at)andDELETE— matching the parent's posture (the oneconfig_notificationwrite mcp holds is the cooldown column); authoring a route is the Viewer's Settings window.Surfaces
MuteRulesWindow-shaped grid + edit dialog whose family list is filled fromAlertFamily.All, never a copy). A read-only seat sees the routes with presence, not URLs.get_notification_routes(taxonomy + routes, destinations withheld, resolution order stated),set_notification_route_enabled,delete_notification_route. Lite getsget_notification_routestoo (taxonomy +routes_supported: false). Not folded intoget_alert_settings: that payload's read→modify→write invariant (every emitted key writable) is pinned, and mute rules have their own read tool for the same reason.Why this shape
Store-only (
[JsonIgnore]onDarlingConfig.NotificationRoutes), like mute rules: a route is live tuning through the Settings grid, and a darling.json copy would be overwritten by the first store reload exactly asAlertsis. Reaches the shared services through a default interface member onIAlertSettings(NotificationRoutes => Array.Empty<>(), theICollectorSchemaInfo.RequiredPgExtensionsprecedent), so Lite's adapter opts into nothing and only Darling's store-backed adapter overrides it. Proxies, the generic channel's headers/template and the PagerDuty region stay on the parent: they say how a channel type is reached, not where a family lands.What it does NOT do
performanceroute and off the parent. A sink sentinel is the natural next rung and is deliberately not here.viewer-role write);mcpcannot author or re-point a route.Analysis: …findings areperformance(they carry a severity and a remedy over a monitored server's data). Their hash-suffixed names make exact routes impractical; if that audience differs, a fifth family is a one-line census change.Tests
Executed on this Mac via a throwaway harness (net10.0-windows, WindowsDesktop framework entry removed): NotificationRoutingTests (15) — census in both directions + severity arms, zero-routes ⇒ every channel
Same()as the settings member for every delivered metric, exact > family > default per channel, route-enables-a-channel, disabled skipped / lowest id wins, recovery pairing, case/trim, ledger DTO round trip and pre-#3598 rows, and a real loopback fan-out throughDarlingAlertDeliverer(parent endpoint gets the page whose body equalsBuildSlackPayload's own render on the fixed clock; the self-monitor endpoint gets the self-alert; fire+clear both land on the exact route's endpoint; four ledger rows carry the route). NotificationRoutesRungTests (rung DDL/trigger/no-ACL, viewer carve classification vs parent, mcp grant shape, MCP-store non-secret read, service/viewer/DDL column parity, validation, MCP descriptions) and NotificationRoutesLivePostgresTests (DARLING_TEST_PG,timescale/timescaledb:2.28.1-pg18: full ladder 1→131 applies, generated column populates, beacon bumps on INSERT/UPDATE/DELETE,StoreConfigProviderreads the row,ApplyToConfigswaps it by reference, the resolver routes on it). Plus the neighbouring pins re-run green:DarlingManagedRolesTests,ProvisionRolesAclDriftTests,McpConfigReadAvoidsSecretColumnsTests,McpPageContractTests,McpLatestSnapshotStampTests,EngineCapabilityMissTests,AsOfWindowAnchorTests,DocCommentHygieneTests,AlertDeliveryChannelTests,DarlingMcpAlertToolsTests,AlertHistoryRowSeverityTests, and Lite'sCrossAppMcpToolInventoryPinTests(tool censuses 88/156). The viewer probe's top-arm pin and the WPF windows are first executed in CI.Closes #3598