Skip to content

Commit 807cd9a

Browse files
fix(app): show webhook body template compile errors in the editor (#3350)
## Summary The webhook form now compiles the body template as you edit it and shows the Handlebars error under the editor, with its line and column. A template that doesn't compile used to save without any feedback, and the problem only surfaced when an alert failed to deliver (visible in server logs). The common case is JSON-escaping quotes inside a Handlebars expression: ``` "status": "{{#if (eq state \"ALERT\")}}firing{{else}}resolved{{/if}}" ``` That's valid JSON but not a valid template. The form now shows `Template error on line 1, column 29` with Handlebars' parse message. The intended template, `{{#if (eq state "ALERT")}}`, is invalid JSON as written but compiles, so it shows no error. - The check runs 300 ms after typing stops, so the error doesn't flash while a `{{ … }}` is half-typed. Empty bodies are skipped, since they fall back to the service's default template. Slack bodies are skipped too; Slack never renders the body. - `getWebhookTemplateError` in common-utils uses `Handlebars.precompile`. Delivery's extra `eq` helper only matters at render time, so stock Handlebars reports the same compile errors delivery would hit. - The docs example in `alert-webhook-template-variables.md` used the escaped-quote pattern itself and would not have compiled; it's fixed, and the page now explains how to quote strings inside `{{ … }}`. ### Screenshots or video | Before | After | | :----- | :---- | | <img width="1296" height="341" alt="Screenshot 2026-10-09 at 3 32 34 PM" src="https://github.com/user-attachments/assets/974209f9-9aff-479d-a91f-8342afe7e013" /> | <img width="1266" height="551" alt="Screenshot 2026-10-09 at 3 32 02 PM" src="https://github.com/user-attachments/assets/f089a38f-7d7e-483c-af85-f26483b36a1f" /> | ### How to test on Vercel preview **Preview routes:** /team **Steps:** 1. Click the button with `data-testid="add-webhook-section-button"` to open the webhook form. 2. Select the "incident.io" service type. 3. In the body editor, enter `{"status": "{{#if (eq state \"ALERT\")}}firing{{/if}}"}` and wait a moment. 4. Verify an error titled "Template error on line 1, column 29" appears under the editor. 5. Replace the body with `{"status": "{{#if (eq state "ALERT")}}firing{{/if}}"}` and verify the error disappears. ### References - Linear Issue: [HDX-5681](https://linear.app/clickhouse/issue/HDX-5681/validate-webhook-body-templates-when-they-are-saved-not-at-delivery) - Related PRs:
1 parent ab563d2 commit 807cd9a

7 files changed

Lines changed: 238 additions & 5 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@hyperdx/common-utils': patch
3+
'@hyperdx/app': patch
4+
---
5+
6+
fix: the webhook form now compiles the body template as you edit it and shows
7+
the Handlebars error with its line and column. A broken template used to give
8+
no feedback until an alert failed to deliver.

‎docs/alert-webhook-template-variables.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ number that may be absent. Compare against `undefined` rather than using
6767
Keep a newline or space between `{{/unless}}` and a closing `}` — Handlebars
6868
reads `}}}` as a triple-stache and fails to compile the template.
6969

70+
The template is Handlebars, not JSON: it only has to be JSON once rendered.
71+
Write string arguments inside `{{ … }}` with plain or single quotes —
72+
`{{#if (eq state "ALERT")}}` or `{{#if (eq state 'ALERT')}}` — never JSON-escaped
73+
as `\"ALERT\"`, which Handlebars cannot parse. The webhook form shows where a
74+
body fails to compile.
75+
7076
The **Send test** button on the webhook form fills every variable with a sample
7177
value, so a template that uses them can be checked before an alert fires. The
7278
sample is a `between` alert, so `{{thresholdMax}}` is populated there — a test
@@ -93,13 +99,13 @@ instead if every group of a grouped alert should collapse into one incident.
9399

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

96-
```json
102+
```
97103
{
98104
"alert_id": "{{alertId}}",
99105
"dedup_key": "{{alertId}}-{{groupKey}}",
100106
"status": "{{status}}",
101107
"summary": "{{title}}",
102-
"urgency": "{{#if (eq alertType \"dashboard_chart\")}}low{{else}}high{{/if}}",
108+
"urgency": "{{#if (eq alertType 'dashboard_chart')}}low{{else}}high{{/if}}",
103109
"value": {{value}},
104110
"threshold": {{threshold}},
105111
"window": { "start": "{{startTimeISO}}", "end": "{{endTimeISO}}" },
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { WebhookTemplateError } from '@hyperdx/common-utils/dist/core/handlebarsEnv';
2+
import { Alert, Code } from '@mantine/core';
3+
import { IconAlertTriangle } from '@tabler/icons-react';
4+
5+
const errorTitle = ({ line, column }: WebhookTemplateError) => {
6+
if (line == null) return 'Template error';
7+
return column == null
8+
? `Template error on line ${line}`
9+
: `Template error on line ${line}, column ${column}`;
10+
};
11+
12+
export function WebhookBodyValidationAlert({
13+
error,
14+
}: {
15+
error: WebhookTemplateError;
16+
}) {
17+
return (
18+
<Alert
19+
variant="danger"
20+
icon={<IconAlertTriangle size={16} />}
21+
title={errorTitle(error)}
22+
data-testid="webhook-body-error"
23+
>
24+
<Code block>{error.message}</Code>
25+
</Alert>
26+
);
27+
}

‎packages/app/src/components/TeamSettings/WebhookForm.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useEffect } from 'react';
1+
import { useEffect, useMemo } from 'react';
22
import { HTTPError } from 'ky';
33
import { Controller, SubmitHandler, useForm, useWatch } from 'react-hook-form';
44
import { ZodIssue } from 'zod';
55
import { json, jsonParseLinter } from '@codemirror/lang-json';
66
import { linter } from '@codemirror/lint';
7+
import { getWebhookTemplateError } from '@hyperdx/common-utils/dist/core/handlebarsEnv';
78
import {
89
getDefaultWebhookBody,
910
WEBHOOK_TEMPLATE_VARIABLES,
@@ -23,6 +24,7 @@ import {
2324
Text,
2425
TextInput,
2526
} from '@mantine/core';
27+
import { useDebouncedValue } from '@mantine/hooks';
2628
import { notifications } from '@mantine/notifications';
2729
import { IconInfoCircleFilled } from '@tabler/icons-react';
2830
import ReactCodeMirror, {
@@ -31,6 +33,7 @@ import ReactCodeMirror, {
3133
} from '@uiw/react-codemirror';
3234

3335
import api from '@/api';
36+
import { WebhookBodyValidationAlert } from '@/components/TeamSettings/WebhookBodyValidation';
3437
import { useBrandDisplayName } from '@/theme/ThemeProvider';
3538
import { isValidUrl } from '@/utils';
3639

@@ -133,6 +136,19 @@ export function WebhookForm({
133136
}
134137
}, [webhook, form]);
135138

139+
const service = useWatch({ control: form.control, name: 'service' });
140+
const bodyText = useWatch({ control: form.control, name: 'body' });
141+
// Debounced so the error doesn't flash while a {{ … }} is half-typed.
142+
const [debouncedBody] = useDebouncedValue(bodyText, 300);
143+
const bodyError = useMemo(
144+
// Slack sends a fixed payload and never renders the body.
145+
() =>
146+
service === WebhookService.Slack
147+
? null
148+
: getWebhookTemplateError(debouncedBody),
149+
[service, debouncedBody],
150+
);
151+
136152
const handleTestWebhook = async (values: WebhookForm) => {
137153
const { service, url, body, headers } = values;
138154

@@ -288,7 +304,6 @@ export function WebhookForm({
288304
}
289305
};
290306

291-
const service = useWatch({ control: form.control, name: 'service' });
292307
// A body left at the old service's default would still be sent verbatim, and
293308
// each service's default is invalid to the others' receivers. A hand-edited
294309
// body is the user's, so it survives the switch.
@@ -418,6 +433,9 @@ export function WebhookForm({
418433
)}
419434
/>
420435
</div>,
436+
bodyError && (
437+
<WebhookBodyValidationAlert key="4a" error={bodyError} />
438+
),
421439
<Alert
422440
icon={<IconInfoCircleFilled size={16} />}
423441
key="5"

‎packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import {
55
} from '@hyperdx/common-utils/dist/types';
66
import { MantineProvider } from '@mantine/core';
77
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
8-
import { render, screen } from '@testing-library/react';
8+
import {
9+
act,
10+
render,
11+
screen,
12+
waitForElementToBeRemoved,
13+
} from '@testing-library/react';
914
import userEvent from '@testing-library/user-event';
1015

1116
import {
@@ -132,4 +137,65 @@ describe('WebhookForm', () => {
132137
screen.queryByTestId('webhook-template-variables'),
133138
).not.toBeInTheDocument();
134139
});
140+
describe('body template validation', () => {
141+
const VALID_BODY =
142+
'{"status": "{{#if (eq state "ALERT")}}firing{{else}}resolved{{/if}}"}';
143+
const ESCAPED_QUOTES_BODY =
144+
'{"status": "{{#if (eq state \\"ALERT\\")}}firing{{else}}resolved{{/if}}"}';
145+
146+
async function fillIncidentIoWebhook(
147+
user: ReturnType<typeof userEvent.setup>,
148+
body: string,
149+
) {
150+
await user.click(screen.getByRole('radio', { name: 'incident.io' }));
151+
await user.type(screen.getByTestId('webhook-name-input'), 'oncall');
152+
await user.type(
153+
screen.getByTestId('webhook-url-input'),
154+
'https://api.incident.io/v2/alert_events/http/abc?token=xyz',
155+
);
156+
// incident.io has no headers editor, so the only editor is the body.
157+
await user.click(screen.getByTestId('codemirror'));
158+
await user.paste(body);
159+
}
160+
161+
it('shows a compile error inline', async () => {
162+
const user = userEvent.setup();
163+
renderForm();
164+
await fillIncidentIoWebhook(user, ESCAPED_QUOTES_BODY);
165+
166+
const error = await screen.findByTestId('webhook-body-error');
167+
expect(error).toHaveTextContent('Template error on line 1, column 29');
168+
expect(error).toHaveTextContent('Parse error on line 1');
169+
});
170+
171+
it('clears the error once the template is fixed', async () => {
172+
const user = userEvent.setup();
173+
renderForm();
174+
await fillIncidentIoWebhook(user, ESCAPED_QUOTES_BODY);
175+
await screen.findByTestId('webhook-body-error');
176+
177+
await user.clear(screen.getByTestId('codemirror'));
178+
await user.paste(VALID_BODY);
179+
180+
await waitForElementToBeRemoved(() =>
181+
screen.queryByTestId('webhook-body-error'),
182+
);
183+
});
184+
185+
it('saves a template that is only valid JSON once rendered', async () => {
186+
const user = userEvent.setup();
187+
renderForm();
188+
await fillIncidentIoWebhook(user, VALID_BODY);
189+
// Outlast the debounce so a false positive would have rendered.
190+
await act(() => new Promise(resolve => setTimeout(resolve, 400)));
191+
expect(
192+
screen.queryByTestId('webhook-body-error'),
193+
).not.toBeInTheDocument();
194+
195+
await user.click(screen.getByTestId('add-webhook-button'));
196+
expect(mockSaveWebhook).toHaveBeenCalledWith(
197+
expect.objectContaining({ body: VALID_BODY }),
198+
);
199+
});
200+
});
135201
});

‎packages/common-utils/src/core/__tests__/handlebarsEnv.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ import {
22
clearTemplateCache,
33
compileLenient,
44
compileStrict,
5+
getWebhookTemplateError,
56
UnknownTemplateHelperError,
67
validateTemplate,
78
} from '@/core/handlebarsEnv';
9+
import {
10+
DEFAULT_GENERIC_WEBHOOK_BODY,
11+
DEFAULT_INCIDENT_IO_WEBHOOK_BODY,
12+
} from '@/types';
813

914
describe.each([
1015
['compileStrict', compileStrict],
@@ -128,3 +133,48 @@ describe('validateTemplate', () => {
128133
expect(() => validateTemplate('{{missing}}')).not.toThrow();
129134
});
130135
});
136+
137+
describe('getWebhookTemplateError', () => {
138+
it('accepts a template that is invalid JSON before rendering', () => {
139+
const body =
140+
'{"status": "{{#if (eq state "ALERT")}}firing{{else}}resolved{{/if}}"}';
141+
expect(() => JSON.parse(body)).toThrow();
142+
expect(getWebhookTemplateError(body)).toBe(null);
143+
});
144+
145+
it.each([DEFAULT_GENERIC_WEBHOOK_BODY, DEFAULT_INCIDENT_IO_WEBHOOK_BODY])(
146+
'accepts the default body %#',
147+
body => {
148+
expect(getWebhookTemplateError(body)).toBe(null);
149+
},
150+
);
151+
152+
it('rejects JSON-escaped quotes inside a helper call', () => {
153+
const body =
154+
'{"status": "{{#if (eq state \\"ALERT\\")}}firing{{else}}resolved{{/if}}"}';
155+
expect(() => JSON.parse(body)).not.toThrow();
156+
expect(getWebhookTemplateError(body)).toEqual({
157+
message: expect.stringContaining('Parse error on line 1'),
158+
line: 1,
159+
column: 29,
160+
});
161+
});
162+
163+
it('reports the line and column of a parse error on a later line', () => {
164+
expect(getWebhookTemplateError('{\n "a": "{{title}"\n}')).toMatchObject({
165+
line: 2,
166+
column: 16,
167+
});
168+
});
169+
170+
it('reports the location of a mismatched block', () => {
171+
expect(getWebhookTemplateError('{{#if x}}{{/each}}')).toMatchObject({
172+
message: expect.stringContaining("if doesn't match each"),
173+
line: 1,
174+
});
175+
});
176+
177+
it.each(['', ' \n', undefined])('accepts an empty body (%p)', body => {
178+
expect(getWebhookTemplateError(body)).toBe(null);
179+
});
180+
});

‎packages/common-utils/src/core/handlebarsEnv.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,61 @@ export const clearTemplateCache = () => {
134134
strictCache.clear();
135135
lenientCache.clear();
136136
};
137+
138+
export type WebhookTemplateError = {
139+
message: string;
140+
line?: number;
141+
column?: number;
142+
};
143+
144+
const toFiniteNumber = (value: unknown): number | undefined =>
145+
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
146+
147+
// Reads a property off a value of unknown shape without a type assertion.
148+
const read = (value: unknown, key: string): unknown =>
149+
value != null && typeof value === 'object'
150+
? Reflect.get(value, key)
151+
: undefined;
152+
153+
// Handlebars reports 0-based columns.
154+
const toLocation = (line: unknown, column: unknown) => {
155+
const col = toFiniteNumber(column);
156+
return {
157+
line: toFiniteNumber(line),
158+
column: col == null ? undefined : col + 1,
159+
};
160+
};
161+
162+
/**
163+
* Compiles a webhook body template and returns the error, with its line and
164+
* column where Handlebars knows them, or null when it compiles. Delivery
165+
* renders with stock Handlebars plus an `eq` helper; helpers don't affect
166+
* compilation, so stock Handlebars catches the same errors. An empty body
167+
* compiles: it falls back to the default template.
168+
*/
169+
export function getWebhookTemplateError(
170+
template: string | undefined,
171+
): WebhookTemplateError | null {
172+
if (!template?.trim()) return null;
173+
try {
174+
// compile() is lazy; precompile() parses and compiles up front.
175+
Handlebars.precompile(template, { noEscape: true });
176+
return null;
177+
} catch (e) {
178+
const message = e instanceof Error ? e.message : String(e);
179+
if (read(e, 'lineNumber') != null) {
180+
return {
181+
message,
182+
...toLocation(read(e, 'lineNumber'), read(e, 'column')),
183+
};
184+
}
185+
// Parse errors carry only the line, in the message. The lexer that just
186+
// failed still holds the offending token's position. Parsing is
187+
// synchronous, so nothing has run since.
188+
const loc = read(read(read(Handlebars, 'Parser'), 'lexer'), 'yylloc');
189+
return {
190+
message,
191+
...toLocation(read(loc, 'first_line'), read(loc, 'first_column')),
192+
};
193+
}
194+
}

0 commit comments

Comments
 (0)