diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 3975eb09f..dd5088a6c 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -42,17 +42,23 @@ export async function sendAlertWebhook( webhookConfig: WebhookAlertConfig, context: AlertContext ): Promise { - const payload = { - event: context.eventType, - timestamp: new Date().toISOString(), - status: deriveStatus(context.eventType, context.data), - data: { - orgId: context.orgId, - ...context.data - } - }; + const eventType = context.eventType; + const timestamp = new Date().toISOString(); + const status = deriveStatus(eventType, context.data); + const data = { orgId: context.orgId, ...context.data }; + + let body: string; + if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) { + body = renderTemplate(webhookConfig.bodyTemplate, { + event: eventType, + timestamp, + status, + data + }); + } else { + body = JSON.stringify({ event: eventType, timestamp, status, data }); + } - const body = JSON.stringify(payload); const headers = buildHeaders(webhookConfig); let lastError: Error | undefined; @@ -217,3 +223,52 @@ function buildHeaders( return headers; } + +// --------------------------------------------------------------------------- +// Body template rendering +// --------------------------------------------------------------------------- + +interface TemplateContext { + event: string; + timestamp: string; + status: string; + data: Record; +} + +/** + * Render a body template with {{event}}, {{timestamp}}, {{status}}, and + * {{data}} placeholders, mirroring the logic in HttpLogDestination. + * + * {{data}} is replaced first (as raw JSON) so that any literal "{{…}}" + * strings inside data values are not re-expanded. + */ +function renderTemplate(template: string, ctx: TemplateContext): string { + const rendered = template + .replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)) + .replace(/\{\{event\}\}/g, escapeJsonString(ctx.event)) + .replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp)) + .replace(/\{\{status\}\}/g, escapeJsonString(ctx.status)); + + // Validate the rendered result is valid JSON; if not, log a warning and + // fall back to the default payload so the webhook still fires. + try { + JSON.parse(rendered); + return rendered; + } catch { + logger.warn( + `sendAlertWebhook: body template produced invalid JSON for event ` + + `"${ctx.event}" destined for a webhook. Falling back to default ` + + `payload. Check that {{data}} is NOT wrapped in quotes in your template.` + ); + return JSON.stringify({ + event: ctx.event, + timestamp: ctx.timestamp, + status: ctx.status, + data: ctx.data + }); + } +} + +function escapeJsonString(value: string): string { + return JSON.stringify(value).slice(1, -1); +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index e79db2ef5..36a71026d 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -45,6 +45,10 @@ export interface WebhookAlertConfig { headers?: Array<{ key: string; value: string }>; /** HTTP method (default POST) */ method?: string; + /** Whether to use a custom body template */ + useBodyTemplate?: boolean; + /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */ + bodyTemplate?: string; } // --------------------------------------------------------------------------- @@ -60,4 +64,4 @@ export interface AlertContext { healthCheckId?: number; /** Human-readable context data included in emails and webhook payloads */ data: Record; -} \ No newline at end of file +} diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index e3f92591d..ebffd3c5b 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -80,6 +80,10 @@ export interface WebhookAlertConfig { headers?: Array<{ key: string; value: string }>; /** HTTP method (default POST) */ method?: string; + /** Whether to use a custom body template */ + useBodyTemplate?: boolean; + /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */ + bodyTemplate?: string; } // --------------------------------------------------------------------------- diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 887fbaa5a..afa47fb58 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -12,12 +12,15 @@ import { } from "@app/components/ui/command"; import { FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { Switch } from "@app/components/ui/switch"; +import { Textarea } from "@app/components/ui/textarea"; import { Popover, PopoverContent, @@ -962,6 +965,69 @@ function WebhookActionFields({ /> + {/* Body Template */} +
+
+ +

+ {t("httpDestBodyTemplateDescription")} +

+
+ ( + +
+ + + + +
+
+ )} + /> + {useWatch({ + control, + name: `actions.${index}.useBodyTemplate` + }) && ( + ( + + + {t("httpDestBodyTemplateLabel")} + + +