Allow configuring the webhook body

This commit is contained in:
Owen
2026-05-02 13:26:35 -07:00
parent 96c450fd08
commit e1afbc226c
6 changed files with 158 additions and 15 deletions

View File

@@ -42,17 +42,23 @@ export async function sendAlertWebhook(
webhookConfig: WebhookAlertConfig, webhookConfig: WebhookAlertConfig,
context: AlertContext context: AlertContext
): Promise<void> { ): Promise<void> {
const payload = { const eventType = context.eventType;
event: context.eventType, const timestamp = new Date().toISOString();
timestamp: new Date().toISOString(), const status = deriveStatus(eventType, context.data);
status: deriveStatus(context.eventType, context.data), const data = { orgId: context.orgId, ...context.data };
data: {
orgId: context.orgId, let body: string;
...context.data 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); const headers = buildHeaders(webhookConfig);
let lastError: Error | undefined; let lastError: Error | undefined;
@@ -217,3 +223,52 @@ function buildHeaders(
return headers; return headers;
} }
// ---------------------------------------------------------------------------
// Body template rendering
// ---------------------------------------------------------------------------
interface TemplateContext {
event: string;
timestamp: string;
status: string;
data: Record<string, unknown>;
}
/**
* 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);
}

View File

@@ -45,6 +45,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>; headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */ /** HTTP method (default POST) */
method?: string; 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; healthCheckId?: number;
/** Human-readable context data included in emails and webhook payloads */ /** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>; data: Record<string, unknown>;
} }

View File

@@ -80,6 +80,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>; headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */ /** HTTP method (default POST) */
method?: string; method?: string;
/** Whether to use a custom body template */
useBodyTemplate?: boolean;
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
bodyTemplate?: string;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -12,12 +12,15 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { import {
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch";
import { Textarea } from "@app/components/ui/textarea";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -962,6 +965,69 @@ function WebhookActionFields({
/> />
</div> </div>
<WebhookHeadersField index={index} control={control} form={form} /> <WebhookHeadersField index={index} control={control} form={form} />
{/* Body Template */}
<div className="space-y-3">
<div>
<label className="font-medium text-sm block">
{t("httpDestBodyTemplateTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestBodyTemplateDescription")}
</p>
</div>
<FormField
control={control}
name={`actions.${index}.useBodyTemplate`}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-3">
<FormControl>
<Switch
id={`body-template-${index}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<Label
htmlFor={`body-template-${index}`}
className="cursor-pointer"
>
{t("httpDestEnableBodyTemplate")}
</Label>
</div>
</FormItem>
)}
/>
{useWatch({
control,
name: `actions.${index}.useBodyTemplate`
}) && (
<FormField
control={control}
name={`actions.${index}.bodyTemplate`}
render={({ field }) => (
<FormItem>
<FormLabel>
{t("httpDestBodyTemplateLabel")}
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={
'{{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "status": "{{status}}",\n "data": {{data}}\n}}'
}
className="font-mono text-xs min-h-45 resize-y"
/>
</FormControl>
<FormDescription>
{t("httpDestBodyTemplateHint")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div> </div>
); );
} }

View File

@@ -368,7 +368,9 @@ export default function AlertRuleGraphEditor({
customHeaderName: customHeaderName:
"", "",
customHeaderValue: customHeaderValue:
"" "",
useBodyTemplate: false,
bodyTemplate: ""
}); });
} }
}} }}

View File

@@ -45,6 +45,8 @@ export type AlertRuleFormAction =
basicCredentials: string; basicCredentials: string;
customHeaderName: string; customHeaderName: string;
customHeaderValue: string; customHeaderValue: string;
useBodyTemplate: boolean;
bodyTemplate: string;
}; };
export type AlertRuleFormValues = { export type AlertRuleFormValues = {
@@ -130,6 +132,8 @@ export type AlertRuleApiResponse = {
customHeaderValue?: string; customHeaderValue?: string;
headers?: { key: string; value: string }[]; headers?: { key: string; value: string }[];
method?: string; method?: string;
useBodyTemplate?: boolean;
bodyTemplate?: string;
} | null; } | null;
}[]; }[];
}; };
@@ -187,7 +191,9 @@ export function buildFormSchema(t: (k: string) => string) {
bearerToken: z.string(), bearerToken: z.string(),
basicCredentials: z.string(), basicCredentials: z.string(),
customHeaderName: z.string(), customHeaderName: z.string(),
customHeaderValue: z.string() customHeaderValue: z.string(),
useBodyTemplate: z.boolean().default(false),
bodyTemplate: z.string().default("")
}) })
]) ])
) )
@@ -415,7 +421,9 @@ export function apiResponseToFormValues(
bearerToken: cfg?.bearerToken ?? "", bearerToken: cfg?.bearerToken ?? "",
basicCredentials: cfg?.basicCredentials ?? "", basicCredentials: cfg?.basicCredentials ?? "",
customHeaderName: cfg?.customHeaderName ?? "", customHeaderName: cfg?.customHeaderName ?? "",
customHeaderValue: cfg?.customHeaderValue ?? "" customHeaderValue: cfg?.customHeaderValue ?? "",
useBodyTemplate: cfg?.useBodyTemplate ?? false,
bodyTemplate: cfg?.bodyTemplate ?? ""
}); });
} }
@@ -479,7 +487,11 @@ export function formValuesToApiPayload(
customHeaderName: action.customHeaderName || undefined, customHeaderName: action.customHeaderName || undefined,
customHeaderValue: action.customHeaderValue || undefined, customHeaderValue: action.customHeaderValue || undefined,
headers: action.headers.filter((h) => h.key.trim()), headers: action.headers.filter((h) => h.key.trim()),
method: action.method method: action.method,
useBodyTemplate: action.useBodyTemplate || undefined,
bodyTemplate: action.useBodyTemplate
? action.bodyTemplate || undefined
: undefined
}) })
}); });
} }