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
19 changes: 19 additions & 0 deletions .changeset/alert-webhook-source-query-threshold-max.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@hyperdx/api': patch
---

Fix `{{sourceQuery}}` returning empty for inline-query and dashboard-tile
alerts. It read only the saved search's filter, so alerts backed by a chart
config — where the query lives on the alert or the tile — advertised a variable
that never rendered. It now resolves the query from whichever config backs the
alert: the builder `where` or the raw `sqlTemplate`.

Add `{{thresholdMax}}`, the upper bound of a `between` / `outside` condition.
Receivers previously saw only the lower bound and could not reconstruct the
range that fired. It renders empty for every other comparator.

Test Webhook now sends a sample value for every template variable. It carried
only the original seven, so a body using an enriched variable rendered it empty
— and because `threshold`, `thresholdMax` and `value` are emitted unquoted, a
body like `{"value": {{value}}}` was sent as `{"value": }` and rejected,
failing the test for a template that works on a real firing.
11 changes: 11 additions & 0 deletions .changeset/webhook-form-template-variables.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@hyperdx/app': patch
---

List every supported template variable in the webhook form, including the
enriched set added to Generic and incident.io bodies (`{{alertId}}`,
`{{status}}`, `{{alertType}}`, `{{comparator}}`, `{{threshold}}`,
`{{thresholdMax}}`, `{{value}}`, `{{groupKey}}`, `{{sourceQuery}}`,
`{{teamId}}`, `{{note}}` and ISO-8601 `{{startTimeISO}}` / `{{endTimeISO}}`).
Each variable now carries a one-line description, so a webhook body can be
written without leaving the form.
14 changes: 9 additions & 5 deletions docs/alert-webhook-template-variables.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,17 +18,21 @@ a HyperDX alert without parsing the human-readable message body.
| `{{alertType}}` | string | `search`, `dashboard_chart` or `inline_query`. |
| `{{comparator}}` | string | `>=`, `>`, `<`, `<=`, `=`, `!=`, `between`, `outside`. |
| `{{threshold}}` | number | The configured threshold. |
| `{{thresholdMax}}` | number | Upper bound of a `between` / `outside` range; empty for every other comparator. |
| `{{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. |
| `{{sourceQuery}}` | string | The query behind the alert: the saved search's filter, the chart's `where`, or the raw SQL. |
| `{{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}}`.
Numbers (`startTime`, `endTime`, `threshold`, `thresholdMax`, `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 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}}.

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).


## Example

Expand Down
31 changes: 31 additions & 0 deletions packages/api/src/routers/api/__tests__/webhooks.int.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@ import { getLoggedInAgent, getServer } from '@/fixtures';
import Alert from '@/models/alert';
import Webhook, { WebhookService } from '@/models/webhook';
import * as transports from '@/tasks/checkAlerts/transports';
import { buildWebhookTemplateVariables } from '@/tasks/checkAlerts/transports/generic';
import type { Message } from '@/tasks/checkAlerts/transports/types';

const MOCK_WEBHOOK = {
name: 'Test Webhook',
Expand DownExpand Up@@ -1296,6 +1298,35 @@ describe('webhooks router', () => {
});
});

// The webhook form documents every one of these variables directly above
// the Test Webhook button, so a test send has to exercise the same set —
// an unset raw number renders `"value": ` and the receiver rejects a
// template that works on a real firing.
it('sends a sample value for every template variable', async () => {
const { agent, team } = await getLoggedInAgent(server);

await agent
.post('/webhooks/test')
.send({
service: WebhookService.Generic,
url: 'https://example.com/webhook',
body: '{"text": "test"}',
})
.expect(200);

const sent: Message = genericSpy.mock.calls[0][1];
const rendered = buildWebhookTemplateVariables(sent);

for (const [name, value] of Object.entries(rendered)) {
expect(`${name}=${value}`).not.toMatch(/=(undefined|null)?$/);
}
// Emitted unquoted, so these are what break a body when left unset.
for (const name of ['threshold', 'thresholdMax', 'value'] as const) {
expect(typeof rendered[name]).toBe('number');
}
expect(sent.teamId).toBe(team._id.toString());
});

it('returns 404 when webhookId does not exist', async () => {
const { agent } = await getLoggedInAgent(server);

Expand Down
30 changes: 25 additions & 5 deletions packages/api/src/routers/api/webhooks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ import type {
import express from 'express';
import { ObjectId } from 'mongodb';
import mongoose from 'mongoose';
import ms from 'ms';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';

Expand All@@ -18,6 +19,7 @@ import {
handleSendGenericWebhook,
handleSendSlackWebhook,
} from '@/tasks/checkAlerts/transports';
import type { Message } from '@/tasks/checkAlerts/transports/types';
import { isDuplicateKeyError } from '@/utils/errors';
import {
validateWebhookUrl,
Expand DownExpand Up@@ -459,15 +461,33 @@ router.post(
body,
});

// Send test message
const testMessage = {
// Every field a real firing sends, so a body written against the
// documented variables renders here exactly as it will in production.
// The enriched variables especially: `threshold`, `thresholdMax` and
// `value` are emitted raw, so leaving them unset renders `"value": `
// and the receiver rejects a template that would have worked.
// A range comparator is the useful sample — it is the one case where
// `thresholdMax` is populated.
const now = Date.now();
const testMessage: Message = {
hdxLink: 'https://hyperdx.io',
title: 'Test Webhook from HyperDX',
body: 'This is a test message to verify your webhook configuration is working correctly.',
startTime: Date.now(),
endTime: Date.now(),
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.

eventId: 'test-event-id',
alertId: 'test-alert-id',
status: 'firing',
alertType: 'search',
comparator: 'between',
threshold: 5,
thresholdMax: 10,
value: 7,
groupKey: 'test-group',
sourceQuery: 'SeverityText: "error"',
teamId: teamId.toString(),
note: 'Test webhook — no runbook',
};

const testChannel = { type: 'webhook' as const, channel: testWebhook };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
import {
AlertChartConfig,
AlertState,
AlertThresholdType,
DisplayType,
SourceKind,
Tile,
} from '@hyperdx/common-utils/dist/types';
import mongoose from 'mongoose';

Expand DownExpand Up@@ -142,6 +145,7 @@ const makeTileView = (
thresholdMax?: number;
value?: number;
group?: string;
tile?: Tile;
} = {},
): AlertMessageTemplateDefaultView => ({
alert: {
Expand All@@ -151,13 +155,13 @@ const makeTileView = (
source: AlertSource.TILE,
channel: { type: null },
interval: '1m',
tileId: 'test-tile-id',
tileId: (overrides.tile ?? testTile).id,
},
dashboard: {
_id: new mongoose.Types.ObjectId(),
id: 'id-123',
name: 'My Dashboard',
tiles: [testTile],
tiles: [overrides.tile ?? testTile],
team: 'team-123' as any,
tags: ['test'],
createdAt: new Date(),
Expand All@@ -172,6 +176,54 @@ const makeTileView = (
value: overrides.value ?? 10,
});

// An inline alert carries its own chart config, either builder (`where`) or
// raw SQL (`sqlTemplate`) — the two places its query can live.
const makeInlineChartConfig = (
query: { where: string } | { sqlTemplate: string },
): AlertChartConfig =>
'sqlTemplate' in query
? {
name: 'Inline SQL',
configType: 'sql',
connection: 'connection-123',
displayType: DisplayType.Line,
sqlTemplate: query.sqlTemplate,
}
: {
name: 'Inline Chart',
source: 'fake-source-id',
displayType: DisplayType.Line,
select: [
{
aggFn: 'count',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: '',
},
],
where: query.where,
whereLanguage: 'lucene',
};

const makeInlineView = (
query: { where: string } | { sqlTemplate: string },
): AlertMessageTemplateDefaultView => ({
alert: {
thresholdType: AlertThresholdType.ABOVE,
threshold: 5,
source: AlertSource.INLINE,
channel: { type: null },
interval: '1m',
chartConfig: makeInlineChartConfig(query),
},
attributes: {},
granularity: '5 minute',
isGroupedAlert: false,
startTime,
endTime,
value: 10,
});

const render = async (
view: AlertMessageTemplateDefaultView,
state: AlertState,
Expand DownExpand Up@@ -433,9 +485,9 @@ 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 (
const renderView = async (
state: AlertState,
viewOverrides: Parameters<typeof makeSearchView>[0] = {},
base: AlertMessageTemplateDefaultView,
) => {
const webhook = castWebhook({
_id: new mongoose.Types.ObjectId(),
Expand All@@ -445,7 +497,6 @@ describe('enriched message fields', () => {
url: 'https://hooks.slack.com/services/x',
});
const { dispatcher, dispatched } = makeRecordingDispatcher();
const base = makeSearchView(viewOverrides);

const result = await renderAlertTemplate({
alertProvider,
Expand All@@ -469,6 +520,11 @@ describe('enriched message fields', () => {
return { dispatched, result };
};

const renderWithWebhook = async (
state: AlertState,
viewOverrides: Parameters<typeof makeSearchView>[0] = {},
) => renderView(state, makeSearchView(viewOverrides));

it('carries the alert identity and condition onto the dispatched job', async () => {
const { dispatched, result } = await renderWithWebhook(AlertState.ALERT, {
group: 'http',
Expand All@@ -491,6 +547,70 @@ describe('enriched message fields', () => {

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

it('carries both bounds of a range condition', async () => {
const { dispatched } = await renderWithWebhook(AlertState.ALERT, {
thresholdType: AlertThresholdType.BETWEEN,
threshold: 5,
thresholdMax: 7,
value: 6,
});

expect(dispatched[0].message).toMatchObject({
comparator: 'between',
threshold: 5,
thresholdMax: 7,
});
});

it('omits the upper bound when the condition is not a range', async () => {
const { dispatched } = await renderWithWebhook(AlertState.ALERT, {
thresholdType: AlertThresholdType.ABOVE,
threshold: 5,
// Set but irrelevant to this comparator — it must not reach the receiver.
thresholdMax: 7,
});

expect(dispatched[0].message.thresholdMax).toBeUndefined();
});

it('reads sourceQuery from the tile a dashboard alert points at', async () => {
const tile = makeTile({ id: 'queried-tile' });
tile.config = makeInlineChartConfig({ where: 'ServiceName: "checkout"' });

const { dispatched } = await renderView(
AlertState.ALERT,
makeTileView({ tile }),
);

expect(dispatched[0].message).toMatchObject({
alertType: 'dashboard_chart',
sourceQuery: 'ServiceName: "checkout"',
});
});

it('reads sourceQuery from an inline builder alert', async () => {
const { dispatched } = await renderView(
AlertState.ALERT,
makeInlineView({ where: 'SeverityText: "error"' }),
);

expect(dispatched[0].message).toMatchObject({
alertType: 'inline_query',
sourceQuery: 'SeverityText: "error"',
});
});

it('reads sourceQuery from an inline raw SQL alert', async () => {
const { dispatched } = await renderView(
AlertState.ALERT,
makeInlineView({ sqlTemplate: 'SELECT count() FROM otel_logs' }),
);

expect(dispatched[0].message).toMatchObject({
sourceQuery: 'SELECT count() FROM otel_logs',
});
});
});

describe('buildAlertMessageTemplateTitle', () => {
Expand Down
Loading
Loading