feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax - #3071

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars
Sep 4, 2026
Merged

feat(app): list all webhook template variables; fix sourceQuery and thresholdMax#3071
kodiakhq[bot] merged 5 commits into
mainfrom
warren/expose-additional-alert-template-vars

Conversation

@wrn14897

@wrn14897wrn14897 commented Sep 3, 2026

Copy link
Copy Markdown
Member

The webhook form advertised seven template variables while the Generic and incident.io transports render nineteen. The ten enriched variables added in #3057{{alertId}}, {{status}}, {{comparator}}, {{value}} and friends — were undiscoverable unless you read docs/alert-webhook-template-variables.md.

Writing that list out surfaced two variables that don't hold up their end, both flagged by Greptile on #3057. They're fixed here.

UI

The help box under Webhook Body now lists every variable in buildWebhookTemplateVariables, each with a one-line description lifted from the docs, in a two-column grid that collapses to one column on narrow viewports. A footer states the two rules a template author needs: strings are JSON-escaped and safe inside quotes, numbers are emitted raw, and a variable the alert doesn't carry renders empty.

{{link}}'s description interpolates the brand display name rather than hardcoding "HyperDX".

DEFAULT_GENERIC_WEBHOOK_BODY is untouched — it still drives the default body and the editor placeholder, and must keep matching DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE in transports/generic.ts. The new list is a separate display-only constant. The app package can't import from packages/api, so a type can't enforce the pairing; a comment points at the API function as the source of truth.

{{sourceQuery}} was empty for chart-backed alerts

It read only savedSearch?.where. An inline-query alert keeps its query on the alert's own chartConfig, and a tile alert on the tile's config, so both rendered an empty string — the variable was advertised but never arrived. getAlertSourceQuery now resolves the query from whichever config backs the alert, reading the builder where or the raw sqlTemplate.

Greptile flagged only the inline case. Tile alerts have the identical shape, so leaving them empty would be an arbitrary gap; the docs sentence that described tile alerts as intentionally empty is updated.

Narrowing is on configType rather than the isRawSqlSavedChartConfig / isPromqlSavedChartConfig guards: those predicate on SavedChartConfig, and an inline alert's AlertChartConfig union is built from the without-alert variants, so the guards can't subtract a member from it. There's a comment saying so, to stop a future reader "simplifying" it back.

{{thresholdMax}} — the missing half of a range condition

A between / outside alert sent only alert.threshold, so a receiver saw a lower bound with no way to reconstruct the condition. New {{thresholdMax}} carries the upper bound, left undefined (and so rendered empty) for every other comparator rather than leaking a bound that isn't part of the condition.

Like the other raw numbers it's emitted unquoted, so an empty value in an unquoted slot produces invalid JSON. That trade-off already existed for the other optional numbers; the docs now show the {{#if}} guard.

Testing

WebhookForm.test.tsx asserts every variable renders for a Generic webhook and that the box is absent for Slack — verified against the rendered DOM, not just the markup.

Five integration tests cover the API fixes: both bounds on a range condition, no upper bound on a non-range one, and sourceQuery for tile, inline builder, and inline raw SQL alerts. I checked they're not vacuous by reverting the fix — four of the five fail without it. (omits the upper bound passes either way, since it asserts absence.)

Screenshots

image

The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
@changeset-bot

changeset-botBot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 334e939

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
NameType
@hyperdx/apiPatch
@hyperdx/appPatch
@hyperdx/otel-collectorPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
hyperdx-ossReadyReadyPreviewSep 4, 2026 6:19am UTC
hyperdx-storybookReadyReadyPreviewSep 4, 2026 6:19am UTC

Request Review

@github-actionsgithub-actionsBot added the review/tier-2 Low risk — AI review + quick human skim label Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 43 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/template.ts
    • packages/api/src/tasks/checkAlerts/transports/generic.ts
    • packages/api/src/tasks/checkAlerts/transports/types.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 5
  • Production lines changed: 160 (+ 242 in test files, excluded from tier calculation)
  • Critical-path lines changed: 43
  • Branch: warren/expose-additional-alert-template-vars
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR expands webhook template-variable guidance and fixes range-bound and source-query values in generated webhook messages.

  • Lists supported variables and descriptions in the Generic webhook form.
  • Adds thresholdMax for range conditions and resolves source queries from saved searches, dashboard tiles, and inline chart configurations.
  • Populates enriched variables in test webhook messages and adds API and UI coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/api/src/tasks/checkAlerts/template.tsResolves source queries by alert source and includes the upper threshold only for range comparators.
packages/api/src/routers/api/webhooks.tsPopulates test webhook messages with representative values for every supported template variable.
packages/app/src/components/TeamSettings/WebhookForm.tsxAdds a responsive, described list of Generic webhook template variables.
packages/app/src/components/TeamSettings/tests/WebhookForm.test.tsxCovers visibility of the template-variable help box for Generic and Slack webhook selections.

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/expose-a..." | Re-trigger Greptile

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2Test reuses display source

The assertion derives its expected names from the same getWebhookTemplateVariables helper that the form renders, so removing or mistyping an entry changes both sides and leaves the test green while the UI drifts from the API-supported variable set. Use an independent expected list to enforce the synchronization contract documented in WebhookForm.tsx.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude CodeFix in ConductorFix in CursorFix in Codex

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The change is additive and back-compatible: {{thresholdMax}} and the enriched sourceQuery/test-payload fields extend the outbound contract without altering existing variables, getAlertSourceQuery handles null/missing configs without throwing, and the API fixes are covered by integration tests. The items below are recommended cleanups, several of which restate still-open prior-review threads.

🟡 P2 -- recommended

  • packages/app/src/components/TeamSettings/WebhookForm.tsx:476 -- the new footer tells authors "Numbers are emitted raw for unquoted slots," but the body editor lints raw text with jsonParseLinter via jsonLinterWithEmptyCheck (WebhookForm.tsx:437), so a body written that way is flagged as invalid JSON in the same panel that recommends it.
    • Fix: substitute {{...}} expressions with placeholder literals before delegating to jsonParseLinter, or show the quoted "{{value}}" form as the supported shape.
    • adversarial, correctness, previous-comments
  • packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx:61 -- the test asserts the rendered DOM against getWebhookTemplateVariables, the same function that produced the DOM, so it stays green for any drifted list and cannot detect divergence from the API builder buildWebhookTemplateVariables; descriptions are unasserted.
    • Fix: assert against a literal expected array of variable names and add a test pinning that array to the API builder's key set.
    • testing, previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/components/TeamSettings/WebhookForm.tsx:47 -- the variable set is now hand-maintained in three places (getWebhookTemplateVariables, buildWebhookTemplateVariables in packages/api/src/tasks/checkAlerts/transports/generic.ts:84, and docs/alert-webhook-template-variables.md), held together only by a comment; both packages already import @hyperdx/common-utils/dist/types.
    • Fix: hoist the canonical name list into @hyperdx/common-utils and key both the builder and the form off it, adding satisfies Record<...> so drift fails to compile.
  • packages/api/src/tasks/checkAlerts/template.ts:160 -- getAlertSourceQuery hand-rolls 'configType' in chartConfig narrowing on the stated basis that isRawSqlSavedChartConfig / isPromqlSavedChartConfig cannot apply, yet packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig.
    • Fix: reuse the shared guards, or correct the comment if the compiler genuinely rejects them for the tile-config union.
  • docs/alert-webhook-template-variables.md:35 -- the documented {{#if thresholdMax}} guard uses Handlebars if, which treats 0 as falsy, so a valid between -10 and 0 alert omits the bound.
    • Fix: document the guard as {{#if thresholdMax includeZero=true}}.

Reviewers (7): correctness, adversarial, testing, maintainability, kieran-typescript, api-contract, previous-comments.

Testing gaps:

  • No test covers a NOT_BETWEEN (outside) alert carrying thresholdMax — only BETWEEN is exercised.
  • No test covers the getAlertSourceQuery branch where a tile's config is non-SQL/PromQL or the tile is missing, i.e. the empty-string fallback.

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — "Test Webhook" sends a payload in which all 12 newly-advertised variables are empty

The test message built in packages/api/src/routers/api/webhooks.ts:463-471 only sets hdxLink, title, body, startTime, endTime, state, eventId — every enriched field (status, alertId, alertType, comparator, threshold, value, groupKey, sourceQuery, teamId, note, and both *TimeISO) is undefined. So a user who writes a body from the list this PR adds and immediately clicks Test Webhook gets blanks for most of it, and for the numeric slots the copy just told them to leave unquoted ("value": {{value}}, as in docs/alert-webhook-template-variables.md:44) Handlebars renders nothing, producing {"value": } — malformed JSON that the receiver rejects, surfacing as "Failed to send test webhook. Please check your webhook configuration." for a body that would work in production. Populate the enriched fields with sample values in the test message (e.g. status: 'firing', alertType: 'search', comparator: '>=', threshold: 1, value: 2, alertId: 'test-alert-id', teamId, ISO times) so Test Webhook exercises the same variable set the form documents.

await user.click(screen.getByRole('radio', { name: 'Generic' }));

const variables = screen.getByTestId('webhook-template-variables');
for (const { name } of getWebhookTemplateVariables('HyperDX')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The new test is a tautology — it asserts the rendered list against the same function that renders it

for (const { name } of getWebhookTemplateVariables('HyperDX')) compares the DOM to the very constant that produced the DOM, so it passes for any list, including one that has drifted from buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) — which is the only failure mode the new comment at WebhookForm.tsx:47 warns about. Assert against a literal expected array of the 19 names (so adding/removing a variable forces a deliberate test edit), and add a test pinning that array to the API key set once the list is shared (see the common-utils finding); the descriptions are currently unasserted too.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Third hand-maintained copy of the variable set; a shared list in common-utils would make it compiler-enforced

The set now lives in three places that must be edited together: buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84), docs/alert-webhook-template-variables.md, and this constant. The PR description's rationale ("the app package can't import from packages/api, so a type can't enforce the pairing") holds only for a direct app→api import — both packages already import @hyperdx/common-utils (this file imports dist/types, generic.ts imports dist/types too, and WebhookSchema lives there). Put the canonical name list in common-utils and type the API builder's return as satisfies Record<WebhookTemplateVariable, string | number>, so adding a variable in one place fails to compile in the other; that also keeps the 45 lines of static data out of a component file the repo conventions already want under 300 lines (it is now 504).

</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers
are emitted raw for unquoted slots. A variable the alert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The advice "numbers are emitted raw for unquoted slots" is flagged as an error by the editor directly above it

The body field is linted with jsonLinterWithEmptyCheck (line 92), plain jsonParseLinter. A template following the new guidance — {"value": {{value}}, "threshold": {{threshold}}} — is not valid JSON, so CodeMirror marks it with a red error squiggle immediately above the help box that recommends it. Either lint the template with {{…}} slots substituted for placeholder literals before parsing, or drop the unquoted-number sentence and show "{{value}}" as the supported form.

defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Hand-rolled Mantine wrapper duplicates the global renderWithMantine, and the QueryClientProvider is dead

renderWithMantine is defined in packages/app/src/setupTests.tsx:36 and used by 74 other test files; use it instead of a bespoke MantineProvider wrapper. The QueryClient/QueryClientProvider can also go — @/api is fully mocked and WebhookForm touches react-query only through useSaveWebhook/useUpdateWebhook/useTestWebhook.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Review

6 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 5 minor

6 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 342 passed • 1 skipped • 1341s

StatusCount
✅ Passed342
❌ Failed0
⚠️ Flaky0
⏭️ Skipped1

Tests ran across 4 shards in parallel.

View full report →

…oldMax
Two gaps in the enriched webhook template variables from #3057.
sourceQuery read only savedSearch.where, so an inline-query or
dashboard-tile alert rendered it empty — the query for those lives on the
alert's or the tile's chart config. It now resolves from whichever config
backs the alert, reading the builder `where` or the raw `sqlTemplate`.
A between/outside condition sent only its lower bound, so a receiver
could not reconstruct the range that fired. Adds {{thresholdMax}}, left
undefined (and so rendered empty) for every other comparator.
@wrn14897wrn14897 changed the title feat(app): list all webhook template variables in the webhook formfeat(app): list all webhook template variables; fix sourceQuery and thresholdMaxSep 3, 2026
@github-actionsgithub-actionsBot added review/tier-4 Critical — deep review + domain expert sign-off and removed review/tier-2 Low risk — AI review + quick human skim labels Sep 3, 2026
))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The form recommends unquoted numeric slots, which the body editor's own JSON linter marks as a syntax error

The new footer says "Numbers are emitted raw for unquoted slots", but the body editor above it runs jsonParseLinter() on the raw Handlebars source (jsonLinterWithEmptyCheck, WebhookForm.tsx:96-100), so the recommended {"value": {{value}}} is flagged as invalid JSON in the editor. Either strip/replace {{…}} expressions with equal-length filler before handing the text to jsonParseLinter (keeps diagnostic offsets valid), or drop the raw-number guidance from the form. Also carry over the caveat this PR adds to docs/alert-webhook-template-variables.md:34 — that an absent optional number breaks the JSON, hence {{#if thresholdMax}}…{{/if}} — since the form, not the doc, is where bodies get written.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Second source of truth for the variable set, and the new test can't detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment holding them together, and the new WebhookForm.test.tsx iterates that very array to assert the DOM — so it pins the component to itself and would still pass if the API added or dropped a variable. Put the canonical name list in packages/common-utils/src/types.ts next to WebhookService (both packages already import from there), have the form render it, and add satisfies Record<WebhookTemplateVariable, unknown> to the object literal in buildWebhookTemplateVariables so a variable added on either side is a compile error rather than silent drift.

The new inline/tile cases reached for `as any` and matching eslint
disables, which pushed packages/api past the escape-hatch ratchet
baseline. Build the chart configs as typed AlertChartConfig literals and
let makeTileView take the tile, so no cast is needed at all.
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');

// Mirrors buildWebhookTemplateVariables in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The variable list is a second source of truth for buildWebhookTemplateVariables, and the new test cannot detect drift

getWebhookTemplateVariables restates the 20 keys of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a comment holding them together, and WebhookForm.test.tsx asserts the rendered DOM against that same list, so dropping or renaming an entry — or adding a variable on the API side — still passes. Move the canonical name list into packages/common-utils (both packages already import @hyperdx/common-utils/dist/types) and have buildWebhookTemplateVariables key off it, or at minimum assert the form list against a literal expected array in the test so drift fails.

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented {{#if}} guard silently drops a value of 0

Handlebars' if treats 0 as falsy unless includeZero=true, so {{#if thresholdMax}}"max": {{thresholdMax}},{{/if}} omits the bound for a valid between -10 and 0 alert, and the same pattern applied to {{value}} drops the very common value: 0 case. Document the guard as {{#if thresholdMax includeZero=true}}...{{/if}}.

</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">
Strings are JSON-escaped, so they are safe inside quotes. Numbers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New help text recommends unquoted numeric slots that the body editor's own JSON linter flags as errors

The body CodeMirror runs linter(jsonLinterWithEmptyCheck()) (line 437), which parses the raw text, so a body following the new advice — {"value": {{value}}} — is underlined as invalid JSON even though it renders to valid JSON at delivery time. Make jsonLinterWithEmptyCheck substitute {{...}} expressions with a placeholder literal (e.g. 0 outside quotes) before delegating to jsonParseLinter, so template bodies lint on their rendered shape.

if (chartConfig == null) {
return '';
}
// Narrowed on `configType` rather than through isRawSqlSavedChartConfig /

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Re-implements the existing chart-config type guards inline, on a justification the codebase contradicts

The comment says isRawSqlSavedChartConfig / isPromqlSavedChartConfig (packages/common-utils/src/guards.ts:58) can't be used because they predicate on SavedChartConfig, but packages/api/src/controllers/alerts.ts:122 already calls isRawSqlSavedChartConfig on an AlertChartConfig — the without-alert variants are assignable, since alert is optional on the saved variants. Replace the hand-rolled 'configType' in chartConfig narrowing with if (isRawSqlSavedChartConfig(cfg)) return cfg.sqlTemplate; / if (isPromqlSavedChartConfig(cfg)) return ''; / return cfg.where ?? ''; per the repo's DRY rule, or correct the comment if the compiler actually rejects it.

The webhook form lists all 20 variables directly above the Test Webhook
button, but the test payload carried only the original 7. Enriched string
variables rendered empty, and because threshold, thresholdMax and value
are emitted unquoted, a body like {"value": {{value}}} was sent as
{"value": } — invalid JSON, so the receiver rejected a template that
works on a real firing.
Populate every field with a sample, using a range comparator so
thresholdMax is exercised too, and type the literal as Message so a new
field has to be given one. State moves to ALERT to agree with
status: 'firing'.
state: AlertState.INSUFFICIENT_DATA,
startTime: now - ms('5m'),
endTime: now,
state: AlertState.ALERT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Test Webhook now sends a firing payload, so testing an incident.io webhook opens a real alert

state went from AlertState.INSUFFICIENT_DATA to AlertState.ALERT. The incident.io body the form generates (WebhookForm.tsx:186) is "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}", so a test send now posts status: "firing" with deduplication_key: "test-event-id" — incident.io creates an alert that can escalate/page and stays open until someone resolves it by hand (and every subsequent test dedupes onto the same one). Previously it rendered resolved, which was a no-op. Keep all the newly populated sample fields but pick a non-firing pairing — state: AlertState.OK with status: 'resolved' — so a test send still exercises every variable without paging on-call.

// Mirrors buildWebhookTemplateVariables in
// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
// variables are added or removed there.
export const getWebhookTemplateVariables = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Variable list is a second source of truth kept in sync by a comment, and the new test can't detect drift

getWebhookTemplateVariables restates the key set of buildWebhookTemplateVariables (packages/api/src/tasks/checkAlerts/transports/generic.ts:84) with only a "keep in sync" comment; WebhookForm.test.tsx:46 then iterates that same exported array, so it re-asserts the list against itself and would still pass after the next drift — which is exactly the 7-vs-19 drift this PR exists to fix. The app can share with the API via common-utils (both already import @hyperdx/common-utils/dist/types, which is where the webhook schemas live): put the variable name/description table there and have the API builder key off it (satisfies Record<WebhookTemplateVariable, unknown>) so adding a variable on one side fails to compile on the other.

name: '{{status}}',
description: 'firing, resolved, no_data, pending or error',
},
{ name: '{{eventId}}', description: 'Unique id for this firing' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor{{eventId}} is described as "Unique id for this firing" but it is constant across firings

eventId is objectHash({ alertId, channel, isGrouped, groupId }) (packages/api/src/tasks/checkAlerts/template.ts:561) — no time component, so every firing of the same alert/channel/group produces the identical value. That's why generic.ts:203 hashes eventIdtogether withstartTime/endTime/state to build the Idempotency-Key. A template author following this copy would collapse every firing into one receiver-side event. Describe it as "stable dedup key for this alert/channel/group" (and fix the same wording in docs/alert-webhook-template-variables.md:13).

))}
</span>
</SimpleGrid>
<Text size="xs" c="dimmed" mt="xs">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Form footer omits the empty-raw-number caveat, and the test sample always populates thresholdMax so the trap passes the test

The footer says numbers are emitted raw and a missing variable "renders as an empty string", but doesn't say that combination yields invalid JSON — the exact warning added to docs/alert-webhook-template-variables.md:34. It bites hardest for {{thresholdMax}}: the test message hardcodes comparator: 'between' with thresholdMax: 10, so {"max": {{thresholdMax}}} passes Test Webhook and then sends {"max": } on every non-range alert. Add the docs' one-liner to the footer, e.g. "An optional number left unset renders as nothing — guard it: {{#if thresholdMax}}\"max\": {{thresholdMax}},{{/if}}."

an empty `{{groupKey}}`, and a non-range alert has an empty `{{thresholdMax}}`.

An empty variable in an unquoted numeric slot produces invalid JSON, so guard
the optional numbers: `{{#if thresholdMax}}"max": {{thresholdMax}},{{/if}}`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Recommended {{#if thresholdMax}} guard silently drops a thresholdMax of 0

Handlebars #if is falsy on 0, so an alert configured as between -10 and 0 renders no "max" key at all — the receiver silently loses the bound the guard was meant to preserve. Either note the caveat or show a presence check that survives zero, e.g. {{#unless (eq thresholdMax undefined)}}"max": {{thresholdMax}},{{/unless}} using the already-registered eq helper (packages/api/src/tasks/checkAlerts/transports/generic.ts:113).

placeholder: jest.fn(),
}));

function renderForm() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test hand-rolls a Mantine wrapper and wraps in a QueryClientProvider that nothing uses

packages/app/src/setupTests.tsx:36 already exposes a global renderWithMantine (used by SearchWhereInput.test.tsx, TimelineMinimap.test.tsx, …), and it also mounts <Notifications />, which this form needs if a test ever exercises the submit/test paths. The QueryClientProvider is dead scaffolding here — @/api is mocked wholesale at the top of the file, so no react-query hook ever runs. Replace renderForm with renderWithMantine(<WebhookForm onClose={jest.fn()} onSuccess={jest.fn()} />) and drop the @tanstack/react-query import.

@kodiakhq
kodiakhqBot merged commit 25a3b01 into mainSep 4, 2026
28 checks passed
@kodiakhq
kodiakhqBot deleted the warren/expose-additional-alert-template-vars branch September 4, 2026 06:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automergereview/tier-4Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@wrn14897@jordan-simonovski