Repository navigation
Give the generic webhook raw-JSON context tokens and the cross-channel dedup key (#2302) - #2310
Conversation
| ["timestamp"] = EscapeForJson(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)), | ||
| /* Raw JSON values — never EscapeForJson (see the doc comment). "{}" / "[]" rather than empty | ||
| so a template's `"context": {{context_json}}` stays well-formed on a context-less alert. */ | ||
| ["context_json"] = context is null ? "{}" : AlertContextSerializer.Serialize(context), |
There was a problem hiding this comment.
Data exposure: {{context_json}} bypasses the T-SQL redaction every other channel enforces.
AlertContextSerializer.Serialize(context) emits AlertDetailItemDto.Body (and Remediation) unconditionally — it doesn't look at IsCodeBlock at all. But every other outbound-webhook path in this same file deliberately withholds that content from third parties and substitutes TsqlWebhookHint instead:
- Teams:
WebhookAlertService.cs:345-348 - Slack:
WebhookAlertService.cs:517-520 - PagerDuty
custom_details:WebhookAlertService.cs:1082-1085 - This channel's own
{{context}}flattening, one screen up:RenderContextForTemplate(WebhookAlertService.cs:732-736), whose doc comment literally says "Follows the Teams/Slack rule: remediation T-SQL is never inlined, it points at the email / in-app dialog."
{{context_json}} is a brand-new path that ships the full Body/Remediation payload — copy-paste remediation T-SQL, force-plan targets, etc. — raw, to whatever arbitrary URL the operator has configured for the generic webhook. That's a real change in what leaves the process for a channel whose whole reason for existing is posting to third-party automation (the PR body even lists n8n/Opsgenie/GitHub repository_dispatch as examples).
{{incidents_json}} doesn't have this problem — AlertIncidentDto never carries Body/IsCodeBlock.
Worth either redacting IsCodeBlock bodies in the context_json projection (mirroring the other channels) or, if the full-fidelity round-trip is intentional, calling this trade-off out explicitly in the settings-window help text and PR description rather than leaving it implicit.
| /* PagerDuty's caller passes the numeric serverId when it has one; a test send has neither, so the | ||
| key falls back to the server name — same precedence as the PagerDuty call site's `serverId ?? | ||
| serverName`, which is what keeps the two channels' keys equal for the same alert. */ | ||
| var dedupKey = DerivePagerDutyDedupKey( | ||
| string.IsNullOrEmpty(serverId) ? serverName : serverId, metricName, context); |
There was a problem hiding this comment.
Comment claims a precedence match with the PagerDuty call site that doesn't actually hold on the live send path.
This does string.IsNullOrEmpty(serverId) ? serverName : serverId before deriving the key — i.e. it falls back to serverName for an empty (not just null) serverId.
The comment says this matches "the PagerDuty call site's serverId ?? serverName" — but the actual PagerDuty send path (TrySendPagerDutyAlertAsync, line 944) calls DerivePagerDutyDedupKey(serverId, metricName, context) directly, with no fallback at all. And the serverId ?? serverName this comment is presumably referring to (BuildPagerDutyPayload, line 1018) is dead on that path anyway, since dedupKey is always already non-null by the time it gets there — and even if it weren't dead, ?? only triggers on null, not on "", so it wouldn't reproduce this method's IsNullOrEmpty fallback either.
Net effect: if serverId is ever genuinely "" for a real alert, this method keys on serverName while PagerDuty keys on the raw empty string — two different dedup_keys for the same alert, which undermines the cross-channel correlation this PR is for. Every current caller happens to pass a non-empty serverId ("0" from Lite's int default, a parsed server key from Darling), so this looks unreachable today — but it's worth either fixing TrySendPagerDutyAlertAsync's call to apply the same fallback, or correcting the comment so it doesn't claim a guarantee the code doesn't provide.
ReviewScoped to the actual diff: three new generic-webhook tokens ( Parity: clean. Correctness of the refactor: Left two inline comments on
No missing-index folklore to flag, and nothing else jumped out on injection handling — the raw-token bypass of |
|
Both findings addressed in f662f0c:
|
| /// A template that quotes a raw token anyway produces malformed JSON and is caught by the caller's | ||
| /// well-formedness check, surfacing as a config error rather than a silent bad post. |
There was a problem hiding this comment.
Save/Test-time validation won't actually catch a mis-quoted raw token.
ValidateGenericConfig (line 830) and SendTestGenericAsync (line 217) both call BuildGenericPayload with context: null (no context param on either signature), so context_json renders to the trivial "{}" and incidents_json to "[]" — neither of which contains a " character. If an operator writes "context": "{{context_json}}" (quoting the raw token, exactly the mistake called out in the doc comment above), the Save-time validation and the "Send Test" button both produce "context": "{}", which is valid JSON — so no error is surfaced.
The break only appears once a real alert with a non-empty AlertContext fires, because that's the first time the serialized JSON contains embedded " characters that clash with the manually-added surrounding quotes. And level/threshold alerts (High CPU, Collection Stopped, etc. — the ones this PR's own dedup-key fallback targets) pass context: null in production too, so this class of alert would never trip IsWellFormedJson either. An operator could have a broken generic-webhook template that "validates fine" and "test-sends fine," and only discovers it's broken when a deadlock/blocking alert with actual structured content fires — at which point it just fails silently into RecordGenericFailure/logs, not back to the settings window.
Given the header-validation code just above (HasControlChar, line ~921-924) explicitly calls out that Save/Test-time checks exist so "the operator sees it immediately," this seems like a real gap relative to that same design intent — worth exercising ValidateGenericConfig/SendTestGenericAsync with a small non-empty stand-in AlertContext (or at least one with a quote-bearing field) so the mis-quoting mistake is caught where the operator can actually see it.
There was a problem hiding this comment.
Fixed in cf81c64 — Save-time validation and the Test send now render the raw tokens against a quote-bearing stand-in AlertContext (one detail with a quoted field value, one incident), so the mis-quoting mistake fails at IsWellFormedJson where the operator can see it. Pinned both directions: the quoted forms of both raw tokens are rejected at Save, and the correct unquoted usage still validates null. Verified against the built assembly: the quoted template that previously validated clean now returns the well-formedness error.
Review summaryReviewed the diff (C#, no T-SQL changes — the shared Lite/Darling parity: clean. Both Redaction correctness: Regex/token ordering: One correctness gap flagged inline: the Save-time ( No security issues (JSON construction goes through |
…son-token # Conflicts: # CHANGELOG.md
Review: automation webhook tokens (
|
Closes #2302, implementing the proposal as filed — the main token plus both optional ones, since they turned out to be exactly as cheap as the issue predicted.
What lands
Three new tokens on the generic webhook channel (shared
PerformanceMonitor.Notifications, so both SKUs):{{context_json}}— theAlertContextserialized as a raw JSON value,{}when the alert carries none. The shape is deliberately the existingAlertContextSerializerprojection — byte-identical to the alert-historyContextJson— so a consumer parses ONE shape whether it reads the webhook or the history row, and the round-trip test pins that equivalence through the same serializer.{{incidents_json}}— just the incidents array,[]when none; the incident projection is factored so this and the full serialize cannot drift.{{dedup_key}}— the same keyDerivePagerDutyDedupKeycomputes, including the stable serverId+metric fallback that existed in code but was never exposed to any consumer. The generic sender now receives theserverIdthe fan-out already had (same precedence as the PagerDuty call site), so tickets correlate across channels — and level/threshold alerts (High CPU, Collection Stopped, …) finally have a stable key instead of a title-matching heuristic.The one subtlety, handled as proposed
The two
*_jsontokens bypassEscapeForJsonvia an explicit raw set in the values dictionary — they are JSON values substituted unquoted, and escaping them would turn structure back into the flattened string they exist to replace. The single-passMatchEvaluatoris untouched, so a field VALUE containing the literal text of a raw token is still not expanded (tested). A template that quotes a raw token anyway produces malformed JSON and is caught by the existingIsWellFormedJsonsend-path check as a config error. Alternation order is longest-first socontext_jsonnever leans on backtracking pastcontext.Compatibility
DefaultGenericBodyTemplateis byte-identical and a test now pins that it carries none of the automation tokens; unknown{{foo}}stays literal as before. Settings-window help text (both SKUs) and theIAlertSettingsdoc gained the token list, flagged as raw/unquoted.Tests
Five new tests in
GenericWebhookTests(the channel's existing suite): raw structure with a hostileVictim SQLcarrying both flattening delimiters and a quote arriving byte-exact; the serializer round-trip;{}/[]for context-less alerts; all three dedup derivations (incident key, serverId fallback, serverName fallback); and the default-template pin.Verification scope
Builds clean on macOS (Notifications, Lite.Tests, Darling service, Darling viewer — both XAML edits compile). The Windows suite can't run locally, so all scenarios were executed against the real
PerformanceMonitor.Notificationsbuild via a throwaway harness (named into the existingInternalsVisibleToset): 19/19 pass. Not covered locally: the xUnit tests executing (CI), and a live webhook post.One naming note for the reviewer: the issue's worked example sketched snake_case (
context.incidents[].dedup_key); this ships the PascalCase persisted-ContextJson shape instead, on the argument that one canonical shape across webhook and history beats matching the sketch — flagging it since it's the only place the implementation deliberately differs from the issue text.🤖 Generated with Claude Code