Compare commits

...

42 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
c46c0e105a chore: removed notifier test files 2026-04-16 17:29:34 +05:30
Abhishek Kumar Singh
cc5a0b93ae Merge branch 'main' into feat/alert_manager_template 2026-04-16 16:46:44 +05:30
Abhishek Kumar Singh
c9f3e1ae26 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-04-14 17:50:45 +05:30
Abhishek Kumar Singh
41ded342a1 Merge branch 'main' into chore/am_custom_notifiers 2026-04-14 17:50:27 +05:30
Abhishek Kumar Singh
64ce90e418 fix: variables with symbols in template 2026-04-14 17:27:41 +05:30
Abhishek Kumar Singh
2fcffb7cdc feat: return single templating result from with flag for template type 2026-04-14 17:24:40 +05:30
Abhishek Kumar Singh
abc0d71c16 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-04-13 22:13:25 +05:30
Abhishek Kumar Singh
2e2dd4c42b Merge branch 'main' into chore/am_custom_notifiers 2026-04-13 22:04:08 +05:30
Abhishek Kumar Singh
0ce76a94d6 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-03-31 17:57:02 +05:30
Abhishek Kumar Singh
46ae74ced5 chore: updated email notifier from upstream 2026-03-31 11:52:08 +05:30
Abhishek Kumar Singh
2d8c1b7c86 chore: updated licenses for notifiers 2026-03-31 11:51:45 +05:30
Abhishek Kumar Singh
6602c8c523 refactor: lint fixes 2026-03-31 10:46:52 +05:30
Abhishek Kumar Singh
c22dbcbf74 refactor: review comments 2026-03-31 10:44:37 +05:30
Abhishek Kumar Singh
250bd9abeb Merge branch 'main' into chore/am_custom_notifiers 2026-03-31 10:15:39 +05:30
Abhishek Kumar Singh
f132dc28c3 chore: updated br with new line in test and logs added 2026-03-23 17:02:30 +05:30
Abhishek Kumar Singh
9ffc1203da chore: updated newline to markdown format 2026-03-19 22:50:24 +05:30
Abhishek Kumar Singh
79518b6823 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-03-17 20:15:00 +05:30
Abhishek Kumar Singh
e6a9f49cec Merge branch 'main' into chore/am_custom_notifiers 2026-03-17 20:14:30 +05:30
Abhishek Kumar Singh
fd5fc40823 chore: updated comments 2026-03-16 18:19:03 +05:30
Abhishek Kumar Singh
db2e2a4617 chore: lint fix 2026-03-16 15:54:22 +05:30
Abhishek Kumar Singh
9368d3f393 refactor: comments and test improvements 2026-03-16 15:47:45 +05:30
Abhishek Kumar Singh
0c97ba36d6 refactor: test case and sb related changed 2026-03-16 15:12:23 +05:30
Abhishek Kumar Singh
2e1bdbc2fd chore: added test for missing function 2026-03-16 14:49:52 +05:30
Abhishek Kumar Singh
330737f779 chore: renamed the interface 2026-03-13 19:20:49 +05:30
Abhishek Kumar Singh
f0c531ae2b chore: lint fix 2026-03-13 19:11:34 +05:30
Abhishek Kumar Singh
54477ee786 feat: added support for and in templating 2026-03-13 19:09:39 +05:30
Abhishek Kumar Singh
d281f7b6a2 test: fix preprocessor test case 2026-03-13 17:31:28 +05:30
Abhishek Kumar Singh
378dc350ef refactor: added extractCommonKV instead of 2 different functions 2026-03-13 17:10:13 +05:30
Abhishek Kumar Singh
89c38ed9bc feat: converted alerttemplater to interface and updated tests 2026-03-13 17:02:26 +05:30
Abhishek Kumar Singh
04c4869b12 chore: added handling for missing variable used in template 2026-03-13 14:10:13 +05:30
Abhishek Kumar Singh
388a1184ca chore: fix lint issues 2026-03-12 21:51:13 +05:30
Abhishek Kumar Singh
03901b353b chore: hooked preProcess function in expandTitle and body, added labels and annotations in alertdata 2026-03-12 21:47:43 +05:30
Abhishek Kumar Singh
74441c74a8 feat: added preprocessor for alert templater 2026-03-12 20:54:28 +05:30
Abhishek Kumar Singh
93d332bef2 chore: exposed templates for alertmanager types 2026-03-12 18:52:40 +05:30
Abhishek Kumar Singh
1e730cae8c chore: added utils for using variables with $ notation 2026-03-12 16:34:43 +05:30
Abhishek Kumar Singh
01a09cf6d2 chore: updated test name + code for timeout errors 2026-03-12 10:22:42 +05:30
Abhishek Kumar Singh
403dddab85 feat: alert manager template to template title and notification body 2026-03-11 21:55:09 +05:30
Abhishek Kumar Singh
d07a833574 chore: added tracing to msteamsv2 notifier 2026-03-11 16:05:00 +05:30
Abhishek Kumar Singh
b39bec7245 Merge branch 'main' into chore/am_custom_notifiers 2026-03-10 22:37:24 +05:30
Abhishek Kumar Singh
6ff55c48be chore: fix email linter 2026-03-10 22:19:05 +05:30
Abhishek Kumar Singh
b15fa0f88f chore: lint fixs 2026-03-10 21:57:53 +05:30
Abhishek Kumar Singh
19fe4f860e chore: custom notifiers in alert manager 2026-03-10 13:20:04 +05:30
9 changed files with 1870 additions and 10 deletions

View File

@@ -0,0 +1,292 @@
package alertmanagertemplate
import (
"context"
"log/slog"
"sort"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// AlertManagerTemplater processes alert notification templates.
type AlertManagerTemplater interface {
// ProcessTemplates expands the title and body templates from input
// against the provided alerts and returns the expanded templates.
ProcessTemplates(ctx context.Context, input TemplateInput, alerts []*types.Alert) (*ExpandedTemplates, error)
// BuildNotificationTemplateData builds the NotificationTemplateData from context and alerts.
// This exposes the structured alert data that gets used in the notification templates.
BuildNotificationTemplateData(ctx context.Context, alerts []*types.Alert) *NotificationTemplateData
}
type alertManagerTemplater struct {
tmpl *template.Template
logger *slog.Logger
}
func New(tmpl *template.Template, logger *slog.Logger) AlertManagerTemplater {
return &alertManagerTemplater{tmpl: tmpl, logger: logger}
}
// ProcessTemplates expands the title and body templates from input
// against the provided alerts and returns the expanded templates.
func (at *alertManagerTemplater) ProcessTemplates(
ctx context.Context,
input TemplateInput,
alerts []*types.Alert,
) (*ExpandedTemplates, error) {
ntd := at.buildNotificationTemplateData(ctx, alerts)
missingVars := make(map[string]bool)
title, titleMissingVars, err := at.expandTitle(input.TitleTemplate, ntd)
if err != nil {
return nil, err
}
// if title template results in empty string, use default template
// this happens for rules where custom title annotation was not set
if title == "" && input.DefaultTitleTemplate != "" {
title, err = at.expandDefaultTemplate(ctx, input.DefaultTitleTemplate, alerts)
if err != nil {
return nil, err
}
} else {
mergeMissingVars(missingVars, titleMissingVars)
}
// isDefaultTemplated tracks whether the body is templated using default templates
isDefaultTemplated := false
body, bodyMissingVars, err := at.expandBody(input.BodyTemplate, ntd)
if err != nil {
return nil, err
}
// if body template results in nil, use default template
// this happens for rules where custom body annotation was not set
if body == nil {
isDefaultTemplated = true
defaultBody, err := at.expandDefaultTemplate(ctx, input.DefaultBodyTemplate, alerts)
if err != nil {
return nil, err
}
body = []string{defaultBody} // default template combines all alerts message into a single body
} else {
mergeMissingVars(missingVars, bodyMissingVars)
}
// convert the internal map to a sorted slice for returning missing variables
missingVarsList := make([]string, 0, len(missingVars))
for k := range missingVars {
missingVarsList = append(missingVarsList, k)
}
sort.Strings(missingVarsList)
return &ExpandedTemplates{
Title: title,
Body: body,
MissingVars: missingVarsList,
IsDefaultTemplatedBody: isDefaultTemplated,
}, nil
}
// BuildNotificationTemplateData builds the NotificationTemplateData from context and alerts.
func (at *alertManagerTemplater) BuildNotificationTemplateData(
ctx context.Context,
alerts []*types.Alert,
) *NotificationTemplateData {
return at.buildNotificationTemplateData(ctx, alerts)
}
// expandDefaultTemplate uses go-template to expand the default template.
func (at *alertManagerTemplater) expandDefaultTemplate(
ctx context.Context,
tmplStr string,
alerts []*types.Alert,
) (string, error) {
// if even the default template is empty, return empty string
// this is possible if user added channel with blank template
if tmplStr == "" {
at.logger.WarnContext(ctx, "default template is empty")
return "", nil
}
data := notify.GetTemplateData(ctx, at.tmpl, alerts, at.logger)
result, err := at.tmpl.ExecuteTextString(tmplStr, data)
if err != nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute default template: %s", err.Error())
}
return result, nil
}
// mergeMissingVars adds all keys from src into dst.
func mergeMissingVars(dst, src map[string]bool) {
for k := range src {
dst[k] = true
}
}
// expandTitle expands the title template. Returns empty string if the template is empty.
func (at *alertManagerTemplater) expandTitle(
titleTemplate string,
ntd *NotificationTemplateData,
) (string, map[string]bool, error) {
if titleTemplate == "" {
return "", nil, nil
}
processRes, err := PreProcessTemplateAndData(titleTemplate, ntd)
if err != nil {
return "", nil, err
}
result, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
if err != nil {
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom title template: %s", err.Error())
}
return strings.TrimSpace(result), processRes.UnknownVars, nil
}
// expandBody expands the body template for each individual alert. Returns nil if the template is empty.
func (at *alertManagerTemplater) expandBody(
bodyTemplate string,
ntd *NotificationTemplateData,
) ([]string, map[string]bool, error) {
if bodyTemplate == "" {
return nil, nil, nil
}
var sb []string
missingVars := make(map[string]bool)
for i := range ntd.Alerts {
processRes, err := PreProcessTemplateAndData(bodyTemplate, &ntd.Alerts[i])
if err != nil {
return nil, nil, err
}
part, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
if err != nil {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom body template: %s", err.Error())
}
// add unknown variables and templated text to the result
for k := range processRes.UnknownVars {
missingVars[k] = true
}
if strings.TrimSpace(part) != "" {
sb = append(sb, strings.TrimSpace(part))
}
}
return sb, missingVars, nil
}
// buildNotificationTemplateData creates the NotificationTemplateData using
// info from context and the raw alerts.
func (at *alertManagerTemplater) buildNotificationTemplateData(
ctx context.Context,
alerts []*types.Alert,
) *NotificationTemplateData {
// extract the required data from the context
receiver, ok := notify.ReceiverName(ctx)
if !ok {
at.logger.WarnContext(ctx, "missing receiver name in context")
}
groupLabels, ok := notify.GroupLabels(ctx)
if !ok {
at.logger.WarnContext(ctx, "missing group labels in context")
}
// extract the external URL from the template
externalURL := ""
if at.tmpl.ExternalURL != nil {
externalURL = at.tmpl.ExternalURL.String()
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
commonLabels := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
// aggregate labels and annotations from all alerts
labels := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
annotations := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// build the alert data slice
alertDataSlice := make([]AlertData, 0, len(alerts))
for _, a := range alerts {
ad := buildAlertData(a, receiver)
alertDataSlice = append(alertDataSlice, ad)
}
// count the number of firing and resolved alerts
var firing, resolved int
for _, ad := range alertDataSlice {
if ad.IsFiring {
firing++
} else if ad.IsResolved {
resolved++
}
}
// extract the rule-level convenience fields from common labels
alertName := commonLabels[ruletypes.LabelAlertName]
ruleID := commonLabels[ruletypes.LabelRuleID]
ruleLink := commonLabels[ruletypes.LabelRuleSource]
// build the group labels
gl := make(template.KV, len(groupLabels))
for k, v := range groupLabels {
gl[string(k)] = string(v)
}
// build the notification template data
return &NotificationTemplateData{
Receiver: receiver,
Status: string(types.Alerts(alerts...).Status()),
AlertName: alertName,
RuleID: ruleID,
RuleLink: ruleLink,
TotalFiring: firing,
TotalResolved: resolved,
Alerts: alertDataSlice,
GroupLabels: gl,
CommonLabels: commonLabels,
CommonAnnotations: commonAnnotations,
ExternalURL: externalURL,
Labels: labels,
Annotations: annotations,
}
}
// buildAlertData converts a single *types.Alert into an AlertData.
func buildAlertData(a *types.Alert, receiver string) AlertData {
labels := make(template.KV, len(a.Labels))
for k, v := range a.Labels {
labels[string(k)] = string(v)
}
annotations := make(template.KV, len(a.Annotations))
for k, v := range a.Annotations {
annotations[string(k)] = string(v)
}
return AlertData{
Receiver: receiver,
Status: string(a.Status()),
Labels: labels,
Annotations: annotations,
StartsAt: a.StartsAt,
EndsAt: a.EndsAt,
GeneratorURL: a.GeneratorURL,
Fingerprint: a.Fingerprint().String(),
AlertName: labels[ruletypes.LabelAlertName],
RuleID: labels[ruletypes.LabelRuleID],
RuleLink: labels[ruletypes.LabelRuleSource],
Severity: labels[ruletypes.LabelSeverityName],
LogLink: annotations[ruletypes.AnnotationRelatedLogs],
TraceLink: annotations[ruletypes.AnnotationRelatedTraces],
Value: annotations[ruletypes.AnnotationValue],
Threshold: annotations[ruletypes.AnnotationThresholdValue],
CompareOp: annotations[ruletypes.AnnotationCompareOp],
MatchType: annotations[ruletypes.AnnotationMatchType],
IsFiring: a.Status() == model.AlertFiring,
IsResolved: a.Status() == model.AlertResolved,
IsMissingData: labels[ruletypes.LabelNoData] == "true",
IsRecovering: labels[ruletypes.LabelIsRecovering] == "true",
}
}

View File

@@ -0,0 +1,283 @@
package alertmanagertemplate
import (
"context"
"log/slog"
"sort"
"testing"
"time"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/types"
)
// testSetup returns an AlertTemplater and a context pre-populated with group key,
// receiver name, and group labels for use in tests.
func testSetup(t *testing.T) (AlertManagerTemplater, context.Context) {
t.Helper()
tmpl := test.CreateTmpl(t)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group")
ctx = notify.WithReceiverName(ctx, "slack")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "TestAlert", "severity": "critical"})
logger := slog.New(slog.DiscardHandler)
return New(tmpl, logger), ctx
}
func createAlert(labels, annotations map[string]string, isFiring bool) *types.Alert {
ls := model.LabelSet{}
for k, v := range labels {
ls[model.LabelName(k)] = model.LabelValue(v)
}
ann := model.LabelSet{}
for k, v := range annotations {
ann[model.LabelName(k)] = model.LabelValue(v)
}
startsAt := time.Now()
var endsAt time.Time
if isFiring {
endsAt = startsAt.Add(time.Hour)
} else {
startsAt = startsAt.Add(-2 * time.Hour)
endsAt = startsAt.Add(-time.Hour)
}
return &types.Alert{Alert: model.Alert{Labels: ls, Annotations: ann, StartsAt: startsAt, EndsAt: endsAt}}
}
func TestExpandTemplates(t *testing.T) {
at, ctx := testSetup(t)
tests := []struct {
name string
alerts []*types.Alert
input TemplateInput
wantTitle string
wantBody []string
wantMissingVars []string
errorContains string
wantIsDefaultBody bool
}{
{
// High request throughput on a service — service is a custom label.
// $labels.service extracts the label value; $annotations.description pulls the annotation.
name: "new template: high request throughput for a service",
alerts: []*types.Alert{
createAlert(
map[string]string{
ruletypes.LabelAlertName: "HighRequestThroughput",
ruletypes.LabelSeverityName: "warning",
"service": "payment-service",
},
map[string]string{"description": "Request rate exceeded 10k/s"},
true,
),
},
input: TemplateInput{
TitleTemplate: "High request throughput for $service",
BodyTemplate: `The service $service is getting high request. Please investigate.
Severity: $severity
Status: $status
Service: $service
Description: $description`,
},
wantTitle: "High request throughput for payment-service",
wantBody: []string{`The service payment-service is getting high request. Please investigate.
Severity: warning
Status: firing
Service: payment-service
Description: Request rate exceeded 10k/s`},
wantIsDefaultBody: false,
},
{
// Disk usage alert using old Go template syntax throughout.
// No custom templates — both title and body use the default fallback path.
name: "old template: disk usage high on database host",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "DiskUsageHigh",
ruletypes.LabelSeverityName: "critical",
"instance": "db-primary-01",
},
map[string]string{
"summary": "Disk usage high on database host",
"description": "Disk usage is high on the database host",
"related_logs": "https://logs.example.com/search?q=DiskUsageHigh",
"related_traces": "https://traces.example.com/search?q=DiskUsageHigh",
},
true,
),
},
input: TemplateInput{
DefaultTitleTemplate: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
DefaultBodyTemplate: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
},
wantTitle: "[FIRING:1] DiskUsageHigh for (instance=\"db-primary-01\")",
wantBody: []string{`*Alert:* DiskUsageHigh - critical
*Summary:* Disk usage high on database host
*Description:* Disk usage is high on the database host
*RelatedLogs:* View in <https://logs.example.com/search?q=DiskUsageHigh|logs explorer>
*RelatedTraces:* View in <https://traces.example.com/search?q=DiskUsageHigh|traces explorer>
*Details:*
• *alertname:* DiskUsageHigh
• *instance:* db-primary-01
• *severity:* critical
`},
wantIsDefaultBody: true,
},
{
// Pod crash loop on multiple pods — body is expanded once per alert
// and joined with "\n\n", with the pod name pulled from labels.
name: "new template: pod crash loop on multiple pods, body per-alert",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-1"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-2"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-3"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "$rule_name: $total_firing pods affected",
BodyTemplate: "$labels.pod is crash looping",
},
wantTitle: "PodCrashLoop: 3 pods affected",
wantBody: []string{"api-worker-1 is crash looping", "api-worker-2 is crash looping", "api-worker-3 is crash looping"},
wantIsDefaultBody: false,
},
{
// Incident partially resolved — one service still down, one recovered.
// Title shows the aggregate counts; body shows per-service status.
name: "new template: service degradation with mixed firing and resolved alerts",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "auth-service"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "payment-service"}, nil, false),
},
input: TemplateInput{
TitleTemplate: "$total_firing firing, $total_resolved resolved",
BodyTemplate: "$labels.service ($status)",
},
wantTitle: "1 firing, 1 resolved",
wantBody: []string{"auth-service (firing)", "payment-service (resolved)"},
wantIsDefaultBody: false,
},
{
// $environment is not a known AlertData or NotificationTemplateData field,
// so it lands in MissingVars and renders as "<no value>" in the output.
name: "missing vars: unknown $environment variable in title",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "[$environment] $rule_name",
},
wantTitle: "[<no value>] HighCPU",
wantMissingVars: []string{"environment"},
wantIsDefaultBody: true,
},
{
// $runbook_url is not a known field — someone tried to embed a runbook link
// directly as a variable instead of via annotations.
name: "missing vars: unknown $runbook_url variable in body",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "PodOOMKilled", ruletypes.LabelSeverityName: "warning"}, nil, true),
},
input: TemplateInput{
BodyTemplate: "$rule_name: see runbook at $runbook_url",
},
wantBody: []string{"PodOOMKilled: see runbook at <no value>"},
wantMissingVars: []string{"runbook_url"},
},
{
// Both title and body use unknown variables; MissingVars is the union of both.
name: "missing vars: unknown variables in both title and body",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighMemory", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "[$environment] $rule_name and [{{ $service }}]",
BodyTemplate: "$rule_name: see runbook at $runbook_url",
},
wantTitle: "[<no value>] HighMemory and [<no value>]",
wantBody: []string{"HighMemory: see runbook at <no value>"},
wantMissingVars: []string{"environment", "runbook_url", "service"},
},
{
// Custom title template that expands to only whitespace triggers the fallback,
// so the default title template is used instead.
name: "fallback: whitespace-only custom title falls back to default",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, false),
},
input: TemplateInput{
TitleTemplate: " ",
DefaultTitleTemplate: "{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})",
DefaultBodyTemplate: "Runbook: https://runbook.example.com",
},
wantTitle: "HighCPU (RESOLVED)",
wantBody: []string{"Runbook: https://runbook.example.com"},
wantIsDefaultBody: true,
},
{
name: "using non-existing function in template",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "$rule_name ({{$severity | toUpperAndTrim}}) for $alertname",
},
errorContains: "function \"toUpperAndTrim\" not defined",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := at.ProcessTemplates(ctx, tc.input, tc.alerts)
if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains)
return
}
require.NoError(t, err)
if tc.wantTitle != "" {
require.Equal(t, tc.wantTitle, got.Title)
}
if tc.wantBody != nil {
require.Equal(t, tc.wantBody, got.Body)
}
require.Equal(t, tc.wantIsDefaultBody, got.IsDefaultTemplatedBody)
if len(tc.wantMissingVars) == 0 {
require.Empty(t, got.MissingVars)
} else {
sort.Strings(tc.wantMissingVars)
require.Equal(t, tc.wantMissingVars, got.MissingVars)
}
})
}
}

View File

@@ -0,0 +1,271 @@
package alertmanagertemplate
import (
"fmt"
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/go-viper/mapstructure/v2"
)
// fieldMapping represents a mapping from a JSON tag name to its struct field name.
type fieldMapping struct {
VarName string // JSON tag name (e.g., "receiver", "rule_name")
FieldName string // Struct field name (e.g., "Receiver", "AlertName")
}
// extractFieldMappings uses reflection to extract field mappings from a struct.
func extractFieldMappings(data any) []fieldMapping {
val := reflect.ValueOf(data)
// Handle pointer types
if val.Kind() == reflect.Ptr {
if val.IsNil() {
return nil
}
val = val.Elem()
}
// return nil if the given data is not a struct
if val.Kind() != reflect.Struct {
return nil
}
typ := val.Type()
var mappings []fieldMapping
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
// Get JSON tag name
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// Extract the name part (before any comma options like omitempty)
varName := strings.Split(jsonTag, ",")[0]
if varName == "" {
continue
}
varFieldName := field.Tag.Get("mapstructure")
if varFieldName == "" {
varFieldName = field.Name
}
// Skip complex types: slices and interfaces
kind := field.Type.Kind()
if kind == reflect.Slice || kind == reflect.Interface {
continue
}
// For struct types, we skip all but with few exceptions like time.Time
if kind == reflect.Struct {
// Allow time.Time which is commonly used
if field.Type.String() != "time.Time" {
continue
}
}
mappings = append(mappings, fieldMapping{
VarName: varName,
FieldName: varFieldName,
})
}
return mappings
}
// prepareVariableName prepares the variable name to be used in go-text-template
// it replaces every unwanted character like dots, spaces, etc. with an underscore
// for example, "http.request.method" becomes "http_request_method".
func prepareVariableName(key string) string {
var b strings.Builder
b.Grow(len(key))
for i, r := range key {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_': // valid variable name characters
b.WriteRune(r)
case r >= '0' && r <= '9':
if b.Len() == 0 {
// leading digit — replace with underscore
b.WriteByte('_')
} else {
b.WriteRune(r)
}
default:
// dots, hyphens, spaces, etc. → underscore
b.WriteByte('_')
}
_ = i
}
return b.String()
}
// extractNestedFieldsDefinitions adds the labels and annotations keys from the data struct to the template variable definitions
// it takes the known data struct and extracts the labels and annotations maps and adds their keys to template variable definitions to be used in the template.
func extractNestedFieldsDefinitions(data any) map[string]string {
variables := make(map[string]string)
addLabelsAndAnnotations := func(labels, annotations map[string]string) {
for k := range annotations {
variables[prepareVariableName(k)] = fmt.Sprintf("index .annotations \"%s\"", k)
}
for k := range labels {
variables[prepareVariableName(k)] = fmt.Sprintf("index .labels \"%s\"", k)
}
}
switch data := data.(type) {
case *NotificationTemplateData:
addLabelsAndAnnotations(data.Labels, data.Annotations)
case *AlertData:
addLabelsAndAnnotations(data.Labels, data.Annotations)
default:
return variables
}
return variables
}
// prepareDataForTemplating prepares the data for templating by adding the labels and annotations values to the resulting map
// so they can be accessed directly from root level, the predefined values take precedence over the labels and annotations values
// for example, if labels have a value called rule_name, which collides with the rule_name field in the data struct, the value from the data struct will take precedence.
func prepareDataForTemplating(data any) (map[string]interface{}, error) {
var result map[string]interface{}
if err := mapstructure.Decode(data, &result); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare data for templating")
}
addLabelsAndAnnotationsValues := func(labels, annotations map[string]string) {
for k, v := range labels {
k = prepareVariableName(k)
if _, ok := result[k]; !ok {
result[k] = v
}
}
for k, v := range annotations {
k = prepareVariableName(k)
if _, ok := result[k]; !ok {
result[k] = v
}
}
}
switch data := data.(type) {
case *NotificationTemplateData:
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
case *AlertData:
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
default:
return result, nil
}
return result, nil
}
// generateVariableDefinitions creates `{{ $varname := "" }}` declarations for each variable name.
func generateVariableDefinitions(varNames map[string]string) string {
if len(varNames) == 0 {
return ""
}
var sb strings.Builder
for name := range varNames {
fmt.Fprintf(&sb, `{{ $%s := %s }}`, name, varNames[name])
}
return sb.String()
}
// buildVariableDefinitions constructs the full variable definition preamble for a template.
// containing all known and unknown variables, the reason to include unknown variables is to
// populate them with "<no value>" in template so go-text-template don't throw errors
// when these variables are used in the template.
func buildVariableDefinitions(tmpl string, data any) (string, map[string]bool, error) {
// Extract the initial fields from the data struct and add to the definitions
mappings := extractFieldMappings(data)
// Add variables from struct root level fields to the definitions
variables := make(map[string]string)
for _, m := range mappings {
variables[m.VarName] = fmt.Sprintf(".%s", m.FieldName)
}
// Extract the nested fields definitions from the data struct, like labels, annotations, etc.
// once extracted we add them to the variables map along with the field address
nestedVariables := extractNestedFieldsDefinitions(data)
for k, v := range nestedVariables {
variables[k] = v
}
// variables that are used throughout the template
usedVars, err := ExtractUsedVariables(tmpl)
if err != nil {
return "", nil, err
}
// Compute unknown variables: used in template but not covered by a field mapping
probableUnknownVars := make(map[string]bool)
for name := range usedVars {
_, ok := variables[name]
if !ok {
probableUnknownVars[name] = true
}
}
// Add missing variables to the definitions with "<no value>"
// missingkey=zero is used to replace the missing value with "<no value>"
// but it only works when getting map values like {{ .keyfrommap }} from map and in struct this breaks
// with missing variable errors, we add missing variables in map so when directly variables
// are accessed directly in template block like {{ $variable }} it's handled and doesn't throw errors.
for name := range probableUnknownVars {
variables[name] = `"<no value>"`
}
return generateVariableDefinitions(variables), probableUnknownVars, nil
}
type ProcessingResult struct {
Template string
Data map[string]interface{}
// UnknownVars is the set of possible unknown variables exptracted using regex
UnknownVars map[string]bool
}
// PreProcessTemplateAndData prepares a template string and struct data for Go template execution.
//
// Input: "$receiver has $rule_name in $status state"
// Output: "{{ $receiver := .Receiver }}...{{ $receiver }} has {{ $rule_name }} in {{ $status }} state"
func PreProcessTemplateAndData(tmpl string, data any) (*ProcessingResult, error) {
// Handle empty template
unknownVars := make(map[string]bool)
if tmpl == "" {
result, err := prepareDataForTemplating(data)
if err != nil {
return nil, err
}
return &ProcessingResult{Data: result, UnknownVars: unknownVars}, nil
}
// Build variable definitions: known struct fields + fallback empty-string declarations
definitions, unknownVars, err := buildVariableDefinitions(tmpl, data)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template definitions")
}
// Attach definitions prefix so WrapDollarVariables can parse the AST without "undefined variable" errors.
finalTmpl := definitions + tmpl
// Call WrapDollarVariables to transform bare $variable references to go-text-template format
// with {{ $variable }} syntax from $variable syntax
wrappedTmpl, err := WrapDollarVariables(finalTmpl)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare template for templating")
}
// Convert struct to map using mapstructure to be used for template execution
result, err := prepareDataForTemplating(data)
if err != nil {
return nil, err
}
return &ProcessingResult{Template: wrappedTmpl, Data: result, UnknownVars: unknownVars}, nil
}

View File

@@ -0,0 +1,327 @@
package alertmanagertemplate
import (
"testing"
"time"
"github.com/prometheus/alertmanager/template"
"github.com/stretchr/testify/require"
)
func TestExtractFieldMappings(t *testing.T) {
// Struct with various field types to test extraction logic
type TestStruct struct {
Name string `json:"name"`
Status string `json:"status"`
ActiveUserCount int `json:"user_count" mapstructure:"active_user_count"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` // time.Time allowed
Items []string `json:"items"` // slice skipped
unexported string // unexported skipped (no tag needed)
NoTag string // no json tag skipped
SkippedTag string `json:"-"` // json:"-" skipped
}
testCases := []struct {
name string
data any
expected []fieldMapping
}{
{
name: "struct with mixed field types",
data: TestStruct{Name: "test", ActiveUserCount: 5, unexported: ""},
expected: []fieldMapping{
{VarName: "name", FieldName: "Name"},
{VarName: "status", FieldName: "Status"},
{VarName: "user_count", FieldName: "active_user_count"},
{VarName: "is_active", FieldName: "IsActive"},
{VarName: "created_at", FieldName: "CreatedAt"},
},
},
{
name: "nil data",
data: nil,
expected: nil,
},
{
name: "non-struct type",
data: "string",
expected: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := extractFieldMappings(tc.data)
require.Equal(t, tc.expected, result)
})
}
}
func TestBuildVariableDefinitions(t *testing.T) {
testCases := []struct {
name string
tmpl string
data any
expectedVars []string // substrings that must appear in result
expectError bool
}{
{
name: "empty template still returns struct field definitions",
tmpl: "",
data: &NotificationTemplateData{Receiver: "test"},
expectedVars: []string{
"{{ $receiver := .receiver }}",
"{{ $status := .status }}",
},
},
{
name: "mix of known and unknown vars",
tmpl: "$rule_name: $custom_label",
data: &AlertData{AlertName: "test", Status: "ok", Severity: "critical"},
expectedVars: []string{
"{{ $rule_name := .rule_name }}",
"{{ $status := .status }}",
"{{ $severity := .severity }}",
`{{ $custom_label := "<no value>" }}`,
},
},
{
name: "nested fields definitions coming from NotificationTemplateData",
tmpl: "$severity for $service",
data: &NotificationTemplateData{Labels: template.KV{
"severity": "critical",
"service": "test",
"cloud.region.instance": "ap-south-1",
}},
expectedVars: []string{
"{{ $severity := index .labels \"severity\" }}",
"{{ $service := index .labels \"service\" }}",
"{{ $cloud_region_instance := index .labels \"cloud.region.instance\" }}",
},
},
{
name: "nested fields definitions coming from AlertData",
tmpl: "$severity for $service",
data: &AlertData{Labels: template.KV{
"severity": "critical",
"service": "test",
}},
expectedVars: []string{
"{{ $severity := index .labels \"severity\" }}",
"{{ $service := index .labels \"service\" }}",
},
},
{
name: "invalid template syntax returns error",
tmpl: "{{invalid",
data: &NotificationTemplateData{},
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, _, err := buildVariableDefinitions(tc.tmpl, tc.data)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
if len(tc.expectedVars) == 0 {
require.Empty(t, result)
return
}
for _, expected := range tc.expectedVars {
require.Contains(t, result, expected)
}
})
}
}
func TestPreProcessTemplateAndData(t *testing.T) {
testCases := []struct {
name string
tmpl string
data any
expectedTemplateContains []string
expectedData map[string]any
expectedUnknownVars map[string]bool
expectError bool
}{
{
name: "NotificationTemplateData with dollar variables and labels with dots and hyphens",
tmpl: "[$status] $rule_name (ID: $rule_id) - Firing: $total_firing, Resolved: $total_resolved, Severity: $severity\nHTTP method is: $http_request_method\nRequest path is: $http_request_path",
data: &NotificationTemplateData{
Receiver: "pagerduty",
Status: "firing",
AlertName: "HighMemory",
RuleID: "rule-123",
Labels: template.KV{
"severity": "critical",
"http.request.method": "GET",
"http-request-path": "/api/v1/metrics",
},
TotalFiring: 3,
TotalResolved: 1,
},
expectedTemplateContains: []string{
"{{$status := .status}}",
"{{$rule_name := .rule_name}}",
"{{$rule_id := .rule_id}}",
"{{$total_firing := .total_firing}}",
"{{$total_resolved := .total_resolved}}",
"{{$severity := index .labels \"severity\"}}",
"[{{ .status }}] {{ .rule_name }} (ID: {{ .rule_id }}) - Firing: {{ .total_firing }}, Resolved: {{ .total_resolved }}",
"{{$http_request_method := index .labels \"http.request.method\"}}",
"{{$http_request_path := index .labels \"http-request-path\"}}",
},
expectedData: map[string]any{
"status": "firing",
"rule_name": "HighMemory",
"rule_id": "rule-123",
"total_firing": 3,
"total_resolved": 1,
"severity": "critical",
"http_request_method": "GET",
"http_request_path": "/api/v1/metrics",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "AlertData with dollar variables",
tmpl: "$rule_name: Value $value exceeded $threshold (Status: $status, Severity: $severity, Description: $description)",
data: &AlertData{
Receiver: "webhook",
Status: "resolved",
AlertName: "DiskFull",
RuleID: "disk-001",
Severity: "warning",
Annotations: template.KV{
"description": "Disk full and cannot be written to",
},
Value: "85%",
Threshold: "80%",
IsFiring: false,
IsResolved: true,
},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
"{{$value := .value}}",
"{{$threshold := .threshold}}",
"{{$status := .status}}",
"{{$severity := .severity}}",
"{{$description := index .annotations \"description\"}}",
"{{ .rule_name }}: Value {{ .value }} exceeded {{ .threshold }} (Status: {{ .status }}, Severity: {{ .severity }}, Description: {{ .description }})",
},
expectedData: map[string]any{
"status": "resolved",
"rule_name": "DiskFull",
"rule_id": "disk-001",
"severity": "warning",
"value": "85%",
"threshold": "80%",
"description": "Disk full and cannot be written to",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "mixed dollar and dot notation with both labels and annotations",
tmpl: "Alert $rule_name has {{.total_firing}} firing alerts",
data: &NotificationTemplateData{
AlertName: "HighCPU",
TotalFiring: 5,
Labels: template.KV{
"value": "<MASKED VALUE>",
"cpu.number": "10",
},
Annotations: template.KV{
"value": "85%",
},
},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
"{{$value := index .labels \"value\"}}",
"Alert {{ .rule_name }} has {{.total_firing}} firing alerts",
"{{$cpu_number := index .labels \"cpu.number\"}}",
},
expectedData: map[string]any{
"rule_name": "HighCPU",
"total_firing": 5,
"value": "<MASKED VALUE>",
"cpu_number": "10",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "empty template",
tmpl: "",
data: &NotificationTemplateData{Receiver: "slack"},
},
{
name: "invalid template syntax",
tmpl: "{{invalid",
data: &NotificationTemplateData{},
expectError: true,
},
{
name: "unknown dollar var in text renders empty",
tmpl: "alert $custom_note fired",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
`{{$custom_note := "<no value>"}}`,
"alert {{ .custom_note }} fired",
},
expectedUnknownVars: map[string]bool{"custom_note": true},
},
{
name: "unknown dollar var in action block renders empty",
tmpl: "alert {{ $custom_note }} fired",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
`{{$custom_note := "<no value>"}}`,
`alert {{$custom_note}} fired`,
},
expectedUnknownVars: map[string]bool{"custom_note": true},
},
{
name: "mix of known and unknown vars",
tmpl: "$rule_name: $custom_label",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
`{{$custom_label := "<no value>"}}`,
"{{ .rule_name }}: {{ .custom_label }}",
},
expectedData: map[string]any{"rule_name": "HighCPU"},
expectedUnknownVars: map[string]bool{"custom_label": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := PreProcessTemplateAndData(tc.tmpl, tc.data)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
if tc.tmpl == "" {
require.Equal(t, "", result.Template)
return
}
for _, substr := range tc.expectedTemplateContains {
require.Contains(t, result.Template, substr)
}
for k, v := range tc.expectedData {
require.Equal(t, v, result.Data[k])
}
if tc.expectedUnknownVars != nil {
require.Equal(t, tc.expectedUnknownVars, result.UnknownVars)
}
})
}
}

View File

@@ -0,0 +1,90 @@
package alertmanagertemplate
import (
"time"
"github.com/prometheus/alertmanager/template"
)
// TemplateInput carries the title/body templates
// and their defaults to apply in case the custom templates
// are result in empty strings.
type TemplateInput struct {
TitleTemplate string
BodyTemplate string
DefaultTitleTemplate string
DefaultBodyTemplate string
}
// ExpandedTemplates is the result of ExpandTemplates.
type ExpandedTemplates struct {
Title string
// Body is notification array of body for each alert
Body []string
// IsDefaultTemplatedBody is true if the body templates are templated using
// default templates, false when custom templates were used for templating.
IsDefaultTemplatedBody bool
MissingVars []string // union of unknown vars from title + body templates
}
// AlertData holds per-alert data used when expanding body templates.
type AlertData struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
StartsAt time.Time `json:"starts_at" mapstructure:"starts_at"`
EndsAt time.Time `json:"ends_at" mapstructure:"ends_at"`
GeneratorURL string `json:"generator_url" mapstructure:"generator_url"`
Fingerprint string `json:"fingerprint" mapstructure:"fingerprint"`
// Convenience fields extracted from well-known labels/annotations.
AlertName string `json:"rule_name" mapstructure:"rule_name"`
RuleID string `json:"rule_id" mapstructure:"rule_id"`
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
Severity string `json:"severity" mapstructure:"severity"`
// Alert internal data fields
Value string `json:"value" mapstructure:"value"`
Threshold string `json:"threshold" mapstructure:"threshold"`
CompareOp string `json:"compare_op" mapstructure:"compare_op"`
MatchType string `json:"match_type" mapstructure:"match_type"`
// Link annotations added by the rule evaluator.
LogLink string `json:"log_link" mapstructure:"log_link"`
TraceLink string `json:"trace_link" mapstructure:"trace_link"`
// Status booleans for easy conditional templating.
IsFiring bool `json:"is_firing" mapstructure:"is_firing"`
IsResolved bool `json:"is_resolved" mapstructure:"is_resolved"`
IsMissingData bool `json:"is_missing_data" mapstructure:"is_missing_data"`
IsRecovering bool `json:"is_recovering" mapstructure:"is_recovering"`
}
// NotificationTemplateData is the top-level data struct provided to custom templates.
type NotificationTemplateData struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
// Convenience fields for title templates.
AlertName string `json:"rule_name" mapstructure:"rule_name"`
RuleID string `json:"rule_id" mapstructure:"rule_id"`
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
TotalFiring int `json:"total_firing" mapstructure:"total_firing"`
TotalResolved int `json:"total_resolved" mapstructure:"total_resolved"`
// Per-alert data, also available as filtered sub-slices.
Alerts []AlertData `json:"-" mapstructure:"-"`
// Cross-alert aggregates, computed as intersection across all alerts.
GroupLabels template.KV `json:"group_labels" mapstructure:"group_labels"`
CommonLabels template.KV `json:"common_labels" mapstructure:"common_labels"`
CommonAnnotations template.KV `json:"common_annotations" mapstructure:"common_annotations"`
ExternalURL string `json:"external_url" mapstructure:"external_url"`
// Labels and Annotations that are collection of labels
// and annotations from all alerts, it includes only the common labels and annotations
// and for non-common labels and annotations, it picks some first few labels/annotations
// and joins them with ", " to avoid blank values in the template
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
}

View File

@@ -0,0 +1,230 @@
package alertmanagertemplate
import (
"fmt"
"reflect"
"regexp"
"strings"
"text/template/parse"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// maxAggregatedValues is the maximum number of unique values to include
// when aggregating non-common label/annotation values across alerts.
const maxAggregatedValues = 5
// bareVariableRegex matches bare $variable references including dotted paths like $service.name.
var bareVariableRegex = regexp.MustCompile(`\$(\w+(?:\.\w+)*)`)
// bareVariableRegexFirstSeg matches only the base $variable name, stopping before any dotted path.
// e.g. "$labels.severity" matches "$labels", "$name" matches "$name".
var bareVariableRegexFirstSeg = regexp.MustCompile(`\$\w+`)
// ExtractTemplatesFromAnnotations computes the common annotations across all alerts
// and returns the values for the title_template and body_template annotation keys as title and body templates.
func ExtractTemplatesFromAnnotations(alerts []*types.Alert) (titleTemplate, bodyTemplate string) {
if len(alerts) == 0 {
return "", ""
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
return commonAnnotations[ruletypes.AnnotationTitleTemplate], commonAnnotations[ruletypes.AnnotationBodyTemplate]
}
// WrapDollarVariables wraps bare $variable references in Go template syntax.
// Example transformations:
// - "$name is $status" -> "{{ $name }} is {{ $status }}"
// - "$labels.severity" -> "{{ index .labels \"severity\" }}"
// - "$labels.http.status" -> "{{ index .labels \"http.status\" }}"
// - "$annotations.summary" -> "{{ index .annotations \"summary\" }}"
// - "$service.name" -> "{{ index . \"service.name\" }}"
// - "$name is {{ .Status }}" -> "{{ $name }} is {{ .Status }}"
func WrapDollarVariables(src string) (string, error) {
if src == "" {
return src, nil
}
// Create a new parse.Tree directly
tree := parse.New("template")
tree.Mode = parse.SkipFuncCheck
// Parse the template
_, err := tree.Parse(src, "{{", "}}", make(map[string]*parse.Tree), nil)
if err != nil {
return "", err
}
// Walk the AST and transform TextNodes
walkAndWrapTextNodes(tree.Root)
// Return the reassembled template
return tree.Root.String(), nil
}
// walkAndWrapTextNodes recursively walks the parse tree trying to find a text node.
// Once a text node is found, it wraps the bare $variable and changes it to index-based
// element access from the data map, like '.key' or '.key.subkey'.
func walkAndWrapTextNodes(node parse.Node) {
if reflect.ValueOf(node).IsNil() {
return
}
switch n := node.(type) {
// `$name is {{.Status}}` is a list node with one text and one action node
case *parse.ListNode:
// Recurse into all child nodes
if n.Nodes != nil {
for _, child := range n.Nodes {
walkAndWrapTextNodes(child)
}
}
// `$name is ` is a text node with plain text in root
// we try to find the $name variable and wrap it with template block
// like `{{ .name }}`, for labels and annotations we use the index to access the value
// so `$labels.service` becomes `{{ index .labels "service" }}`
case *parse.TextNode:
// Transform $variable based on its pattern
n.Text = bareVariableRegex.ReplaceAllFunc(n.Text, func(match []byte) []byte {
// Extract variable name without the $
varName := string(match[1:])
// Check if variable contains dots
if strings.Contains(varName, ".") {
// Check for reserved prefixes: labels.* or annotations.*
if strings.HasPrefix(varName, "labels.") {
key := strings.TrimPrefix(varName, "labels.")
return []byte(fmt.Sprintf(`{{ index .labels "%s" }}`, key))
}
if strings.HasPrefix(varName, "annotations.") {
key := strings.TrimPrefix(varName, "annotations.")
return []byte(fmt.Sprintf(`{{ index .annotations "%s" }}`, key))
}
// Other dotted variables: index into root context
return []byte(fmt.Sprintf(`{{ index . "%s" }}`, varName))
}
// Simple variables: use dot notation to directly access the field
// without raising any error due to missing variables
return []byte(fmt.Sprintf("{{ .%s }}", varName))
})
// `{{if pipeline}} T1 {{else}} T0 {{end}}` is a if node with T1 part of List and T0 part of ElseList
case *parse.IfNode:
// Recurse into both branches
walkAndWrapTextNodes(n.List)
walkAndWrapTextNodes(n.ElseList)
// `{{range pipeline}} T1 {{else}} T0 {{end}}` is a range node with T1 part of List and T0 part of ElseList
case *parse.RangeNode:
// Recurse into both branches
walkAndWrapTextNodes(n.List)
walkAndWrapTextNodes(n.ElseList)
// All other node types (ActionNode, PipeNode, VariableNode, etc.) are already
// inside {{ }} action blocks and don't need transformation
// Support for `with` can be added later when we start supporting it in editor block
}
}
// ExtractUsedVariables returns the set of all $variable referenced in template
// — text nodes, action blocks, branch conditions, and loop declarations — regardless of scope.
// After finding all variables, we find those that are not part of our alert data and handle them so the
// text/template parser does not reject undefined $variables.
func ExtractUsedVariables(src string) (map[string]bool, error) {
if src == "" {
return map[string]bool{}, nil
}
// Regex-scan raw template string to collect all $var base names.
// bareVariableRegexFirstSeg stops before dots, so "$labels.severity" yields "$labels".
used := make(map[string]bool)
for _, m := range bareVariableRegexFirstSeg.FindAll([]byte(src), -1) {
used[string(m[1:])] = true // strip leading "$"
}
// Build a preamble that pre-declares every found variable.
// This prevents "undefined variable" parse errors for $vars used in action
// blocks while still letting genuine syntax errors propagate.
var preamble strings.Builder
for name := range used {
fmt.Fprintf(&preamble, `{{$%s := ""}}`, name)
}
// Validate template syntax.
tree := parse.New("template")
tree.Mode = parse.SkipFuncCheck
if _, err := tree.Parse(preamble.String()+src, "{{", "}}", make(map[string]*parse.Tree), nil); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInternal, "failed to extract used variables")
}
return used, nil
}
// aggregateKV aggregates key-value pairs (labels or annotations) from all alerts into a single template.KV.
// The result is used to populate the labels and annotations in the notification template data.
func aggregateKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
// track unique values per key in order of first appearance
valuesPerKey := make(map[string][]string)
// track which values have been seen for deduplication
seenValues := make(map[string]map[string]bool)
for _, alert := range alerts {
kvPairs := extractFn(alert)
for k, v := range kvPairs {
key := string(k)
value := string(v)
if seenValues[key] == nil {
seenValues[key] = make(map[string]bool)
}
// only add if not already seen and under the limit of maxAggregatedValues
if !seenValues[key][value] && len(valuesPerKey[key]) < maxAggregatedValues {
seenValues[key][value] = true
valuesPerKey[key] = append(valuesPerKey[key], value)
}
}
}
// build the result by joining values
result := make(template.KV, len(valuesPerKey))
for key, values := range valuesPerKey {
result[key] = strings.Join(values, ", ")
}
return result
}
// extractCommonKV returns the intersection of key-value pairs across all alerts.
// A key/value pair is included only if it appears identically on every alert.
func extractCommonKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
if len(alerts) == 0 {
return template.KV{}
}
common := make(template.KV, len(extractFn(alerts[0])))
for k, v := range extractFn(alerts[0]) {
common[string(k)] = string(v)
}
for _, a := range alerts[1:] {
kv := extractFn(a)
for k := range common {
if string(kv[model.LabelName(k)]) != common[k] {
delete(common, k)
}
}
if len(common) == 0 {
break
}
}
return common
}

View File

@@ -0,0 +1,348 @@
package alertmanagertemplate
import (
"testing"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func TestWrapBareVars(t *testing.T) {
testCases := []struct {
name string
input string
expected string
expectError bool
}{
{
name: "mixed variables with actions",
input: "$name is {{.Status}}",
expected: "{{ .name }} is {{.Status}}",
},
{
name: "nested variables in range",
input: `{{range .items}}
$title
{{end}}`,
expected: `{{range .items}}
{{ .title }}
{{end}}`,
},
{
name: "nested variables in if else",
input: "{{if .ok}}$a{{else}}$b{{end}}",
expected: "{{if .ok}}{{ .a }}{{else}}{{ .b }}{{end}}",
},
// Labels prefix: index into .labels map
{
name: "labels variables prefix simple",
input: "$labels.service",
expected: `{{ index .labels "service" }}`,
},
{
name: "labels variables prefix nested with multiple dots",
input: "$labels.http.status",
expected: `{{ index .labels "http.status" }}`,
},
{
name: "multiple labels variables simple and nested",
input: "$labels.service and $labels.instance.id",
expected: `{{ index .labels "service" }} and {{ index .labels "instance.id" }}`,
},
// Annotations prefix: index into .annotations map
{
name: "annotations variables prefix simple",
input: "$annotations.summary",
expected: `{{ index .annotations "summary" }}`,
},
{
name: "annotations variables prefix nested with multiple dots",
input: "$annotations.alert.url",
expected: `{{ index .annotations "alert.url" }}`,
},
// Other dotted paths: index into root context
{
name: "other variables with multiple dots",
input: "$service.name",
expected: `{{ index . "service.name" }}`,
},
{
name: "other variables with multiple dots nested",
input: "$http.status.code",
expected: `{{ index . "http.status.code" }}`,
},
// Hybrid: all types combined
{
name: "hybrid - all variables types",
input: "Alert: $alert_name Labels: $labels.severity Annotations: $annotations.desc Service: $service.name Count: $error_count",
expected: `Alert: {{ .alert_name }} Labels: {{ index .labels "severity" }} Annotations: {{ index .annotations "desc" }} Service: {{ index . "service.name" }} Count: {{ .error_count }}`,
},
{
name: "already wrapped should not be changed",
input: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
expected: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
},
{
name: "no variables should not be changed",
input: "Hello world",
expected: "Hello world",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "deeply nested",
input: "{{range .items}}{{if .ok}}$deep{{end}}{{end}}",
expected: "{{range .items}}{{if .ok}}{{ .deep }}{{end}}{{end}}",
},
{
name: "complex example",
input: `Hello $name, your score is $score.
{{if .isAdmin}}
Welcome back $name, you have {{.unreadCount}} messages.
{{end}}`,
expected: `Hello {{ .name }}, your score is {{ .score }}.
{{if .isAdmin}}
Welcome back {{ .name }}, you have {{.unreadCount}} messages.
{{end}}`,
},
{
name: "with custom function",
input: "$name triggered at {{urlescape .url}}",
expected: "{{ .name }} triggered at {{urlescape .url}}",
},
{
name: "invalid template",
input: "{{invalid",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := WrapDollarVariables(tc.input)
if tc.expectError {
require.Error(t, err, "should error on invalid template syntax")
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, result)
}
})
}
}
func TestExtractUsedVariables(t *testing.T) {
testCases := []struct {
name string
input string
expected map[string]bool
expectError bool
}{
{
name: "simple usage in text",
input: "$name is $status",
expected: map[string]bool{"name": true, "status": true},
},
{
name: "declared in action block",
input: "{{ $name := .name }}",
expected: map[string]bool{"name": true},
},
{
name: "range loop vars",
input: "{{ range $i, $v := .items }}{{ end }}",
expected: map[string]bool{"i": true, "v": true},
},
{
name: "mixed text and action",
input: "$x and {{ $y }}",
expected: map[string]bool{"x": true, "y": true},
},
{
name: "dotted path in text extracts base only",
input: "$labels.severity",
expected: map[string]bool{"labels": true},
},
{
name: "nested if else",
input: "{{ if .ok }}{{ $a }}{{ else }}{{ $b }}{{ end }}",
expected: map[string]bool{"a": true, "b": true},
},
{
name: "empty string",
input: "",
expected: map[string]bool{},
},
{
name: "no variables",
input: "Hello world",
expected: map[string]bool{},
},
{
name: "invalid template returns error",
input: "{{invalid",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := ExtractUsedVariables(tc.input)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, result)
}
})
}
}
func TestAggregateKV(t *testing.T) {
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
testCases := []struct {
name string
alerts []*types.Alert
extractFn func(*types.Alert) model.LabelSet
expected template.KV
}{
{
name: "empty alerts slice",
alerts: []*types.Alert{},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "single alert",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"env": "production",
"service": "backend",
},
},
},
},
extractFn: extractLabels,
expected: template.KV{
"env": "production",
"service": "backend",
},
},
{
name: "varying values with duplicates deduped",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "backend"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "frontend"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
},
extractFn: extractLabels,
expected: template.KV{
"env": "production",
"service": "backend, api, frontend",
},
},
{
name: "more than 5 unique values truncates to 5",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc1"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc2"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc3"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc4"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc5"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc6"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc7"}}},
},
extractFn: extractLabels,
expected: template.KV{
"service": "svc1, svc2, svc3, svc4, svc5",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := aggregateKV(tc.alerts, tc.extractFn)
require.Equal(t, tc.expected, result)
})
}
}
func TestExtractCommonKV(t *testing.T) {
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
extractAnnotations := func(a *types.Alert) model.LabelSet { return a.Annotations }
testCases := []struct {
name string
alerts []*types.Alert
extractFn func(*types.Alert) model.LabelSet
expected template.KV
}{
{
name: "empty alerts slice",
alerts: []*types.Alert{},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "single alert returns all labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod", "service": "api"},
},
{
name: "multiple alerts with fully common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod", "region": "us-east"},
},
{
name: "multiple alerts with partially common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "worker"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod"},
},
{
name: "multiple alerts with no common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "worker"}}},
},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "annotations extract common annotations",
alerts: []*types.Alert{
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://x"}}},
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://y"}}},
},
extractFn: extractAnnotations,
expected: template.KV{"summary": "high cpu"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := extractCommonKV(tc.alerts, tc.extractFn)
require.Equal(t, tc.expected, result)
})
}
}

View File

@@ -11,19 +11,22 @@ import (
alertmanagertemplate "github.com/prometheus/alertmanager/template"
)
func AdditionalFuncMap() tmpltext.FuncMap {
return tmpltext.FuncMap{
// urlescape escapes the string for use in a URL query parameter.
// It returns tmplhtml.HTML to prevent the template engine from escaping the already escaped string.
// url.QueryEscape escapes spaces as "+", and html/template escapes "+" as "&#43;" if tmplhtml.HTML is not used.
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
}
}
// customTemplateOption returns an Option that adds custom functions to the template.
func customTemplateOption() alertmanagertemplate.Option {
return func(text *tmpltext.Template, html *tmplhtml.Template) {
funcs := tmpltext.FuncMap{
// urlescape escapes the string for use in a URL query parameter.
// It returns tmplhtml.HTML to prevent the template engine from escaping the already escaped string.
// url.QueryEscape escapes spaces as "+", and html/template escapes "+" as "&#43;" if tmplhtml.HTML is not used.
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
}
text.Funcs(funcs)
html.Funcs(funcs)
text.Funcs(AdditionalFuncMap())
html.Funcs(AdditionalFuncMap())
}
}

View File

@@ -9,4 +9,20 @@ const (
LabelSeverityName = "severity"
LabelLastSeen = "lastSeen"
LabelRuleID = "ruleId"
LabelRuleSource = "ruleSource"
LabelNoData = "nodata"
LabelTestAlert = "testalert"
LabelAlertName = "alertname"
LabelIsRecovering = "is_recovering"
)
const (
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationTitleTemplate = "title_template"
AnnotationBodyTemplate = "body_template"
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"
AnnotationMatchType = "match_type"
)