Repository navigation
feat: Enrich Generic and incident.io webhook template variables #3057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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}}", | ||
| "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 |
|---|---|---|
|
|
@@ -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({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The tests never exercise The test is titled "carries the alert identity … onto the dispatched job" but asserts no |
||
| 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', () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an alert uses Knowledge Base Used: Alerting and notifications
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — For
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — For a |
||
| value, | ||
| groupKey: group ?? '', | ||
| sourceQuery: savedSearch?.where ?? '', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an inline-query alert is rendered, Knowledge Base Used: Alerting and notifications
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor —
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor —
|
||
| teamId, | ||
| note: alert.note ?? '', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major —
|
||
| }, | ||
| }); | ||
| return true; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major —
|
||
| value: message.value, | ||
| }); | ||
|
|
||
| /** | ||
| * Creates a Handlebars instance with common helpers registered. | ||
| * Use this to ensure consistent helper availability across all template rendering. | ||
|
|
@@ -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( | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Enriched fields typed as bare
|
||
| 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< | ||
|
|
||
There was a problem hiding this comment.
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 emitNotifications are only dispatched from
trySendNotificationwithAlertState.ALERTorAlertState.OK(packages/api/src/tasks/checkAlerts/index.ts:1425,1462,1681), so{{status}}is alwaysfiringorresolved;no_data,pendinganderrorare unreachable, and theINSUFFICIENT_DATA/DISABLED/PENDING/ERRORrows ofALERT_STATUS_BY_STATE(template.ts:114-117) are dead —DISABLED → 'no_data'is also semantically wrong. Document onlyfiringandresolved, and either drop the extra mappings or note in the table that they are reserved.