Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/alert-webhook-template-variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@hyperdx/api': minor
---

Add enriched template variables to Generic and incident.io webhook bodies:
`{{alertId}}`, `{{status}}`, `{{alertType}}`, `{{comparator}}`, `{{threshold}}`,
`{{value}}`, `{{groupKey}}`, `{{sourceQuery}}`, `{{teamId}}`, `{{note}}`, and
ISO-8601 `{{startTimeISO}}` / `{{endTimeISO}}` alongside the existing Unix-ms
`{{startTime}}` / `{{endTime}}`.

Receivers can now route, filter and dedupe on an alert's identity and condition
without parsing the rendered message body. Existing templates are unaffected —
every new variable is additive and renders empty when an alert doesn't carry it.
50 changes: 50 additions & 0 deletions docs/alert-webhook-template-variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Alert webhook template variables

**Generic** and **incident.io** webhook bodies are Handlebars templates. These
variables are available inside them, so a receiver can route, filter or dedupe
a HyperDX alert without parsing the human-readable message body.

| Variable | Type | Notes |
| --- | --- | --- |
| `{{title}}` | string | Alert title. |
| `{{body}}` | string | Rendered message body (markdown). |
| `{{link}}` | string | Deep link back into HyperDX. |
| `{{state}}` | string | Raw internal alert state. |
| `{{eventId}}` | string | Unique id for this firing. |
| `{{startTime}}` / `{{endTime}}` | number | Evaluation window, Unix ms. |
| `{{startTimeISO}}` / `{{endTimeISO}}` | string | The same window, ISO-8601. |
| `{{alertId}}` | string | Stable id of the alert itself — the key to dedupe on. |
| `{{status}}` | string | `firing`, `resolved`, `no_data`, `pending` or `error`. |
| `{{alertType}}` | string | `search`, `dashboard_chart` or `inline_query`. |
| `{{comparator}}` | string | `>=`, `>`, `<`, `<=`, `=`, `!=`, `between`, `outside`. |
| `{{threshold}}` | number | The configured threshold. |
| `{{value}}` | number | The value that triggered or resolved the alert. |
| `{{groupKey}}` | string | The breaching group, for a grouped alert. |
| `{{sourceQuery}}` | string | The search expression or SQL behind the alert. |
| `{{teamId}}` | string | Team the alert belongs to. |
| `{{note}}` | string | The alert's freeform note — commonly a runbook link. |

Strings are JSON-escaped, so they are safe to drop into a quoted slot.
Numbers (`startTime`, `endTime`, `threshold`, `value`) are emitted raw for
unquoted slots. Every enriched variable is optional and renders as an empty
string when the alert doesn't carry it — an alert with no group has an empty
`{{groupKey}}`, and a dashboard-tile alert has an empty `{{sourceQuery}}`.

## Example

Routing by severity and deduping on the alert rather than the firing:

```json
{
"alert_id": "{{alertId}}",
"dedup_key": "{{alertId}}-{{groupKey}}",
"status": "{{status}}",
"summary": "{{title}}",

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 — {{status}} is documented with values the code can never emit

Notifications are only dispatched from trySendNotification with AlertState.ALERT or AlertState.OK (packages/api/src/tasks/checkAlerts/index.ts:1425, 1462, 1681), so {{status}} is always firing or resolved; no_data, pending and error are unreachable, and the INSUFFICIENT_DATA/DISABLED/PENDING/ERROR rows of ALERT_STATUS_BY_STATE (template.ts:114-117) are dead — DISABLED → 'no_data' is also semantically wrong. Document only firing and resolved, and either drop the extra mappings or note in the table that they are reserved.

"urgency": "{{#if (eq alertType \"dashboard_chart\")}}low{{else}}high{{/if}}",
"value": {{value}},
"threshold": {{threshold}},
"window": { "start": "{{startTimeISO}}", "end": "{{endTimeISO}}" },
"runbook": "{{note}}",
"link": "{{link}}"
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,69 @@ describe('renderAlertTemplate', () => {
});
});

// The enriched fields are what a receiver routes and dedupes on, so they have
// to survive the render, not just the variable builder in isolation.
describe('enriched message fields', () => {
const renderWithWebhook = async (
state: AlertState,
viewOverrides: Parameters<typeof makeSearchView>[0] = {},
) => {
const webhook = castWebhook({
_id: new mongoose.Types.ObjectId(),
team: new mongoose.Types.ObjectId(),
service: 'slack',
name: 'enriched-hook',
url: 'https://hooks.slack.com/services/x',
});
const { dispatcher, dispatched } = makeRecordingDispatcher();
const base = makeSearchView(viewOverrides);

const result = await renderAlertTemplate({
alertProvider,
clickhouseClient: mockClickhouseClient,
metadata: mockMetadata,
state,
template: null,
title: 'Test Alert Title',
view: {
...base,
alert: {
...base.alert,
channel: { type: 'webhook', webhookId: webhook._id.toString() },
channels: [{ type: 'webhook', webhookId: webhook._id.toString() }],
},
},
teamId: TEST_TEAM_ID,
teamWebhooksById: new Map([[webhook._id.toString(), webhook]]),
dispatcher,
});
return { dispatched, result };
};

it('carries the alert identity and condition onto the dispatched job', async () => {
const { dispatched, result } = await renderWithWebhook(AlertState.ALERT, {
group: 'http',
});

expect(result.failures).toEqual([]);
expect(dispatched[0].message).toMatchObject({

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 tests never exercise {{alertId}} or the render step the PR is actually about

The test is titled "carries the alert identity … onto the dispatched job" but asserts no alertId, and makeSearchView (line 96) builds an alert with no id, so alertId is '' in both cases — a regression that always emits '' for the PR's headline dedupe key passes. value, note and the ISO timestamps are likewise unasserted, and nothing renders a Message through a real Handlebars body, which is where the undefined-number defect above lives. Give the fixture an alert.id, assert it, and add a case that compiles a body such as {"id":"{{alertId}}","value":{{value}}} and JSON.parses the result.

status: 'firing',
alertType: 'search',
comparator: '>=',
threshold: 5,
groupKey: 'http',
sourceQuery: 'Body: "error"',
teamId: TEST_TEAM_ID,
});
});

it('reports a resolve as resolved', async () => {
const { dispatched } = await renderWithWebhook(AlertState.OK);

expect(dispatched[0].message).toMatchObject({ status: 'resolved' });
});
});

describe('buildAlertMessageTemplateTitle', () => {
describe('saved search alerts', () => {
describe('ALERT state', () => {
Expand Down
42 changes: 42 additions & 0 deletions packages/api/src/tasks/checkAlerts/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,37 @@ const describeThreshold = (alert: AlertInput): string => {
: `${alert.threshold}`;
};

// Mappings for the enriched webhook template variables. These turn internal
// enums into stable, consumer-friendly strings a receiver can branch on
// without knowing HyperDX's internals.
const ALERT_STATUS_BY_STATE: Record<AlertState, string> = {
[AlertState.ALERT]: 'firing',
[AlertState.OK]: 'resolved',
[AlertState.INSUFFICIENT_DATA]: 'no_data',
[AlertState.DISABLED]: 'no_data',
[AlertState.PENDING]: 'pending',
[AlertState.ERROR]: 'error',
};

const COMPARATOR_BY_THRESHOLD_TYPE: Record<AlertThresholdType, string> = {
[AlertThresholdType.ABOVE]: '>=',
[AlertThresholdType.ABOVE_EXCLUSIVE]: '>',
[AlertThresholdType.BELOW]: '<',
[AlertThresholdType.BELOW_OR_EQUAL]: '<=',
[AlertThresholdType.EQUAL]: '=',
[AlertThresholdType.NOT_EQUAL]: '!=',
[AlertThresholdType.BETWEEN]: 'between',
[AlertThresholdType.NOT_BETWEEN]: 'outside',
};

const ALERT_TYPE_BY_SOURCE: Record<AlertSource, string> = {
[AlertSource.SAVED_SEARCH]: 'search',
[AlertSource.TILE]: 'dashboard_chart',
// Detached alert: the chart config lives on the alert itself, so there is
// no saved search or tile behind it to open.
[AlertSource.INLINE]: 'inline_query',
};

const MAX_MESSAGE_LENGTH = 500;
const NOTIFY_FN_NAME = '__hdx_notify_channel__';
const IS_MATCH_FN_NAME = 'is_match';
Expand Down Expand Up @@ -519,6 +550,17 @@ export const renderAlertTemplate = async ({
startTime: view.startTime.getTime(),
endTime: view.endTime.getTime(),
eventId,
// Enriched fields, exposed to Generic/incident.io body templates.
alertId: alert.id ?? '',
status: ALERT_STATUS_BY_STATE[state],
alertType: alert.source ? ALERT_TYPE_BY_SOURCE[alert.source] : '',
comparator: COMPARATOR_BY_THRESHOLD_TYPE[alert.thresholdType],
threshold: alert.threshold,

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.

P1 Range conditions lose upper bound

When an alert uses between or outside, the message exposes only alert.threshold and omits thresholdMax, so receivers cannot reconstruct, route on, or accurately display the condition that fired or resolved.

Knowledge Base Used: Alerting and notifications

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

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 — thresholdMax is not exposed, so comparator: "between"/"outside" carries only one bound

For AlertThresholdType.BETWEEN/NOT_BETWEEN the condition needs both bounds — describeThreshold (template.ts:102) renders "{threshold} and {thresholdMax}" for exactly this reason — but a receiver gets comparator: "between", threshold: 5 and cannot reconstruct the range. Add thresholdMax: alert.thresholdMax to the message and a {{thresholdMax}} variable alongside {{threshold}}.

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 — {{comparator}} can be between/outside but no variable carries thresholdMax

For a BETWEEN/NOT_BETWEEN alert the receiver gets comparator: "between" and a single {{threshold}} (the lower bound); alert.thresholdMax is never exposed, so the condition can't be reconstructed. Add thresholdMax: alert.thresholdMax to the message (and to Message, buildWebhookTemplateVariables and the docs table) alongside threshold.

value,
groupKey: group ?? '',
sourceQuery: savedSearch?.where ?? '',

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.

P1 Inline source queries are dropped

When an inline-query alert is rendered, sourceQuery reads only savedSearch.where; inline alerts carry their query in chartConfig, so receivers get an empty value and cannot route or filter using the advertised query variable.

Knowledge Base Used: Alerting and notifications

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

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 — sourceQuery is always empty for inline alerts, whose query lives on the alert itself

savedSearch?.where ?? '' only covers AlertSource.SAVED_SEARCH, so an alert reported as alertType: "inline_query" has an empty {{sourceQuery}} even though alert.chartConfig (AlertChartConfigSchema, common-utils/src/types.ts:1812) holds the builder where or raw SQL. Fall back to the inline chart config's where/sql for AlertSource.INLINE, and note the tile/inline gaps in the docs table if you'd rather leave it empty.

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 — {{sourceQuery}} is empty for inline alerts even though the query is on the alert

savedSearch?.where ?? '' only covers AlertSource.SAVED_SEARCH. Inline alerts persist their own query in alert.chartConfig (zInlineAlert, packages/common-utils/src/types.ts:837), so the value is available, but the docs (docs/alert-webhook-template-variables.md:55-56) only excuse dashboard-tile alerts. Fall back to the inline chart config's where/SQL, or state in the docs that inline alerts also render an empty {{sourceQuery}}.

teamId,
note: alert.note ?? '',

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 — {{note}} is always empty — note is never copied onto the template view

renderAlertTemplate reads alert.note, but alert here is the hand-built AlertInput in packages/api/src/tasks/checkAlerts/index.ts:529-554, which enumerates fields explicitly (id, channel, channels, dashboardId, groupBy, interval, message, name, savedSearchId, silenced, source, threshold, thresholdMax, thresholdType, tileId, chartConfig) and omits note — note appears nowhere in that file. So for every real alert alert.note is undefined and {{note}} renders '', even though note is a real user-settable field (alertNoteSchema, packages/common-utils/src/types.ts:921) and the docs advertise it as the runbook-link variable. Add note: alert.note, to the templateView.alert literal in index.ts.

},
});
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ObjectId } from 'mongodb';

import { AlertState } from '@/models/alert';
import {
buildWebhookTemplateVariables,
getWebhookFetchTimeoutMs,
handleSendGenericWebhook,
} from '@/tasks/checkAlerts/transports/generic';
Expand Down Expand Up @@ -85,3 +86,48 @@ describe('handleSendGenericWebhook — per-attempt timeout', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
}, 2000); // short test-level timeout: a regression here should fail fast, not stall on Jest's default 30s
});

describe('buildWebhookTemplateVariables', () => {
it('exposes the enriched variables alongside the original set', () => {
const vars = buildWebhookTemplateVariables({
...message,
startTime: 1700000000000,
endTime: 1700000300000,
alertId: 'alert-1',
status: 'firing',
alertType: 'search',
comparator: '>=',
threshold: 5,
value: 42,
groupKey: 'checkout',
sourceQuery: 'Body: "error"',
teamId: 'team-1',
note: 'Runbook: https://wiki.example/runbook',
});

expect(vars).toMatchObject({
eventId: 'evt-1',
state: AlertState.ALERT,
alertId: 'alert-1',
status: 'firing',
alertType: 'search',
comparator: '>=',
threshold: 5,
value: 42,
groupKey: 'checkout',
teamId: 'team-1',
startTimeISO: new Date(1700000000000).toISOString(),
endTimeISO: new Date(1700000300000).toISOString(),
});
// Strings destined for JSON template slots are escaped.
expect(vars.sourceQuery).toBe('Body: \\"error\\"');
});

it('renders enriched fields as empty strings (never "undefined") when absent', () => {
const vars = buildWebhookTemplateVariables(message);
expect(vars.alertId).toBe('');
expect(vars.status).toBe('');
expect(vars.note).toBe('');
expect(vars.startTimeISO).toBe(new Date(0).toISOString());
});
});
47 changes: 38 additions & 9 deletions packages/api/src/tasks/checkAlerts/transports/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,43 @@ export const logBlockedWebhookDelivery = (
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
'{"text": "{{title}} | {{body}} | {{link}} | {{state}} | {{startTime}} | {{endTime}} | {{eventId}}"}';

// Renders a Unix-ms timestamp as an ISO-8601 string, or '' if it isn't a valid
// time (so a template slot never becomes the literal "Invalid Date").
const toIsoTimestamp = (t: number): string => {
const d = new Date(t);
return Number.isNaN(d.getTime()) ? '' : d.toISOString();
};

/**
* The variable set exposed to user-editable webhook body templates. Strings
* are JSON-escaped; numbers are emitted raw for unquoted JSON slots.
* `startTime`/`endTime` are raw Unix ms; `startTimeISO`/`endTimeISO` are ISO
* strings for human-readable time ranges. The enriched variables (status,
* comparator, sourceQuery, …) enable routing/dedup in receivers without
* parsing the message body.
*/
export const buildWebhookTemplateVariables = (message: Message) => ({
body: escapeJsonString(message.body),
endTime: message.endTime,
endTimeISO: escapeJsonString(toIsoTimestamp(message.endTime)),
eventId: message.eventId,
link: escapeJsonString(message.hdxLink),
startTime: message.startTime,
startTimeISO: escapeJsonString(toIsoTimestamp(message.startTime)),
state: message.state,
title: escapeJsonString(message.title),
alertId: escapeJsonString(message.alertId ?? ''),
alertType: escapeJsonString(message.alertType ?? ''),
comparator: escapeJsonString(message.comparator ?? ''),
groupKey: escapeJsonString(message.groupKey ?? ''),
note: escapeJsonString(message.note ?? ''),
sourceQuery: escapeJsonString(message.sourceQuery ?? ''),
status: escapeJsonString(message.status ?? ''),
teamId: escapeJsonString(message.teamId ?? ''),
threshold: message.threshold,

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 — {{value}}/{{threshold}} render as empty on the "Test webhook" path, producing invalid JSON

threshold/value are passed through as number | undefined and emitted unquoted, but the other producer of a Message — the test-webhook route at packages/api/src/routers/api/webhooks.ts:463 — never sets them. Handlebars renders undefined as '', so a body built from the documented example (docs/alert-webhook-template-variables.md: "value": {{value}}, "threshold": {{threshold}}) posts {"value": , "threshold": } and the receiver rejects it, so a correct template fails exactly when the user clicks "Test Webhook". Either populate the enriched fields on testMessage in webhooks.ts, or default the numeric slots here (message.threshold ?? null) so the payload stays parseable JSON.

value: message.value,
});

/**
* Creates a Handlebars instance with common helpers registered.
* Use this to ensure consistent helper availability across all template rendering.
Expand Down Expand Up @@ -183,15 +220,7 @@ const sendGenericWebhook = async (

body = handlebars.compile(bodyTemplate, {
noEscape: true,
})({
body: escapeJsonString(message.body),
endTime: message.endTime,
eventId: message.eventId,
link: escapeJsonString(message.hdxLink),
startTime: message.startTime,
state: message.state,
title: escapeJsonString(message.title),
});
})(buildWebhookTemplateVariables(message));
} catch (e) {
logger.error(
{
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/tasks/checkAlerts/transports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ export interface Message {
startTime: number;
endTime: number;
eventId: string;
// Enriched fields exposed as Generic/incident.io template variables.
// Optional so existing callers and non-enriched templates are unaffected.
alertId?: string;
status?: string; // firing | resolved | no_data | pending

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 — Enriched fields typed as bare string with the allowed values only in comments

status, alertType and comparator are closed sets whose real source of truth is ALERT_STATUS_BY_STATE / ALERT_TYPE_BY_SOURCE / COMPARATOR_BY_THRESHOLD_TYPE in template.ts:111-137; typing them as string with // firing | resolved | ... comments means the comment and the map can diverge silently (the comment already omits error). Declare string-literal union types here and annotate the maps with them (Record<AlertState, AlertStatus>) so the compiler keeps the contract.

alertType?: string; // search | dashboard_chart
comparator?: string; // >=, >, <=, <, =, !=, between, outside
threshold?: number;
value?: number; // the value that triggered/resolved the alert
groupKey?: string;
sourceQuery?: string; // the search expr / SQL that defines the alert
teamId?: string;
note?: string; // freeform alert note (markdown); commonly holds a runbook link
}

export type WebhookChannel = Extract<
Expand Down
Loading