Repository navigation
Render only the incidents outside their own #1154 cooldown window - #3324
Conversation
IncidentCooldown decides WHETHER an alert posts and, per #1154, posts when any one of its fingerprints is outside its own window. Nothing decided WHICH incidents the resulting card contained, so the channel builders rendered every entry in AlertContext.Incidents -- including ones still inside their own window that had been delivered minutes earlier. One fresh fingerprint dragged every co-resident stale one back into the channel with it. Decision now reports the fingerprints that were outside their own window, and IncidentDeliveryFilter narrows the rendered context to them, dropping each suppressed incident's own detail item and appending a one-line footer stating how many were held back. Applied once per send path -- the webhook fan-out and the email branch each filter from their own decision, because the two channels hold separate cooldown key spaces. Returns a copy, so the config_alert_log row the caller writes afterwards still carries every incident.
Both the cooldown's ShouldSend and its deliverable-key list are reductions over an ordered key list, and both fixtures put the fresh fingerprint last -- so a last-one-wins reduction was right on the one order they exercised. Measured: replacing the ShouldSend fold with a plain assignment passed all 37 checks. Each fixture now evaluates both orders, and the same mutation fails.
| /// <summary> | ||
| /// The loopback channel endpoints and alert-history doubles the delivery suites drive. Shared rather than | ||
| /// nested per test class because the SMTP one in particular is the only interception point email has short | ||
| /// of the protocol (see its own remarks), and a second copy of a protocol responder is a second thing to | ||
| /// get subtly wrong. Used by <see cref="AlertDeliveryChannelTests"/> (#3169/#3297/#3303) and | ||
| /// <see cref="IncidentDeliveryFilterTests"/> (#3313). | ||
| /// </summary> | ||
| /// <summary> | ||
| /// A loopback endpoint that records the bodies posted to it. <see cref="System.Net.Sockets.TcpListener"/> | ||
| /// rather than <c>HttpListener</c> on purpose: HttpListener wants a URL ACL on Windows, and the four | ||
| /// lines of HTTP a webhook POST needs are cheaper than that dependency. The same choice | ||
| /// <c>NpgsqlRootCertificateValidationTests</c> and <c>DarlingStoreUpgradeTests</c> already make. | ||
| /// </summary> | ||
| internal sealed class CapturingWebhookEndpoint : IDisposable |
There was a problem hiding this comment.
Nit: two consecutive <summary> blocks are attached to CapturingWebhookEndpoint (lines 22-28 and 29-34). Only one <summary> per doc comment is valid — most XML-doc tooling (IntelliSense, DocFX, etc.) only surfaces the first one, so the actual per-class summary ("A loopback endpoint that records the bodies posted to it...") would be silently dropped by anything that renders docs, and the file-level rationale ("Shared rather than nested per test class...") ends up mis-attached to this one class instead of the file/namespace.
Consider making the first block a plain // file-banner comment (or moving it above a namespace-level doc target) so it isn't parsed as this class's XML doc.
|
Reviewed the delivery-filter change ( Correctness: traced the core logic carefully — the null-vs-empty distinction for Lite/Darling parity: the fix lives entirely in the shared Security/perf: no new I/O, secrets, or injection surface; the new loopback TCP test doubles are appropriately test-only. No performance concerns — this is a small filtering pass over an already-small incident list on the alert-send path, not a collector query. One minor doc nit left as an inline comment on |
…cked summary A file-level <summary> with no member of its own stacks against the first type's, which DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks forbids. Caught by the Darling PostgreSQL tests job.
|
Reviewed the diff (all four commits). Summary: no correctness, Lite/Darling parity, or security issues found. Scope: all production changes live in the shared Things I specifically checked and found correct:
No findings to post inline. |
Closes #3313.
IncidentCooldownanswers whether an alert posts, and #1154 makes that answer "yes if any of its fingerprints is outside its own window" so a genuinely distinct incident is never throttled by an unrelated prior one. Nothing answered the second question — which incidents the resulting card contains — so the channel builders rendered every entry inAlertContext.Incidents, including ones still inside their own window that had been delivered minutes earlier. One fresh fingerprint dragged every co-resident stale one back into the channel with it, which is why raising the cooldown cannot fix it: the repeat is a passenger on another fingerprint's card, not a second firing of its own.IncidentCooldown.Decisionnow reports the fingerprints that were outside their own window — a member of the same value asShouldSend, because both come from one pass over one key set and a render deciding freshness for itself would not be answering the question the send decision answered.IncidentDeliveryFilter.ForDeliverynarrows the rendered context to them: each suppressed incident's own detail item is dropped, every other item is kept, and a one-lineOther Incidents / Still Open: N other incident(s) still open (already reported)footer states what was held back.Applied once per send path, which is one place per channel family rather than four: the webhook fan-out filters once for Teams, Slack, PagerDuty and the generic channel (alongside
triageUrl, and for the same reason — four channels describing one firing must not disagree about what it covers), andEmailSendCore's email branch filters from its own decision. Deliberately not shared between the two: email and webhook hold separate cooldown key spaces, so an email that failed to send last cycle is still owed a delivery where the webhook's is not. Lite runs the samePerformanceMonitor.Notificationssend core, so this is one fix for both SKUs.Delivery filters; persistence does not
ForDeliveryreturns a copy and never mutates its input, so theconfig_alert_logrow the caller writes after the send still carries every incident in bothdetail_textand the context JSON — what the MCP reader, the triage page, the Viewer's detail pane andAlertMuteContext.PopulateFromDetailTextread is unchanged. Same split #3317 shipped for analysis prose.The #2109 remediation prose is structurally out of reach
AgAlertContexts.ForDatabasebuilds one plain detail item and never touchesAlertIncidentRenderer, so an AG alert carries no fingerprint, is evaluated on the metric-level fallback key, and reports anulldeliverable set — "nothing to filter", which returns the input instance unchanged. The filter cannot reach that prose even in principle. The existing pins on both SKUs stay green, and the one shape no pin covered — prose and fingerprinted incidents on the same alert — is now pinned on the wire.That last shape is also why the prose is resolved against the unfiltered context. Every engine alert's
detailTextisAlertDetailText.Flattenof its own context, which is howProseForDeliveryrecognises it as a restatement and suppresses it; comparing against the filtered copy would find them unequal and print the whole flattened alert as prose, resurrecting exactly the incidents just removed from the structure. Five channel builders re-runProseForDeliveryon whatever they are handed, so rather than leave that composition argued, the one shape that would break it takes the unfiltered path.Per-event mode does not have this defect, and is not changed
PerEventNotification.Splitalready gives each message a single-incident context and each goes through its own send, so an in-window fingerprint fails its ownShouldSendand never posts at all. Its trailing+N moreoverflow message is the one per-event payload carrying several fingerprints, and it is filtered by the same call because that call sits at the delivery boundary every message crosses. Established from the code and pinned:PerEventMode_PostsTheFreshFingerprintOnly_AndRecordsBothstays green with the filter disabled, and it fails if the filter is ever moved ahead of the split (which would cost the suppressed incident its history row).Two consequences worth naming
PagerDuty's
dedup_keynow anchors on the first deliverable incident, not the first one the alert happens to carry, becauseDerivePagerDutyDedupKeyreads the rendered context (as does the triage link, so link, token and PD all still correlate). That is the behaviour #1154 was after: a batch of{in-window, fresh}previously triggered under the in-window incident's key and folded into its existing PD alert, so the new incident never surfaced there at all.The forensic attachment is carried over unchanged and is not per-incident.
BuildDeadlockContextsets oneAttachmentXmlfrom the first deadlock in the window, with no mapping from graph to fingerprint, so the filter has nothing to select on. Carrying it is the right direction — dropping it would lose the graph entirely — but the card's attachment may belong to an incident the card no longer lists. Pre-existing and unchanged by this PR; noted rather than left to be discovered.Verification
Darling.Tests/Lite.Teststargetnet10.0-windowsand cannot run on macOS, so the actual test.csfiles were compiled into anet10.0console harness with an xunit shim and run: 37 checks, including the 13 pre-existingAlertIncidentRenderTests/IncidentCooldownTestspins that share this seam. Four mutations, each reverted afterwards:ShouldSendrequires every fingerprint fresh (#1154 crippled)SummaryWithMixedFreshnessand the deliverable-key pinShouldSendfold replaced by a plain assignment (last-key-wins)The third row is the reason for the second commit. Both members are reductions over an ordered key list and both fixtures put the fresh fingerprint last, so a last-one-wins reduction was right on the one order they exercised.
CI is the arbiter for the suites themselves.