Compare commits

...

1 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
403dddab85 feat: alert manager template to template title and notification body 2026-03-11 21:55:09 +05:30
4 changed files with 510 additions and 3 deletions

View File

@@ -10,6 +10,7 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
commoncfg "github.com/prometheus/common/config"
@@ -97,6 +98,18 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
tmplText = notify.TmplText(n.tmpl, data, &err)
)
titleTmpl, bodyTmpl := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
expanded, expandErr := alertmanagertemplate.ExpandAlertTemplates(ctx, n.tmpl, alertmanagertemplate.TemplateInput{
TitleTemplate: titleTmpl,
BodyTemplate: bodyTmpl,
DefaultTitleTemplate: n.conf.Title,
DefaultBodyTemplate: n.conf.Text,
}, as, logger)
if expandErr != nil {
return false, expandErr
}
var markdownIn []string
if len(n.conf.MrkdwnIn) == 0 {
@@ -105,15 +118,15 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
markdownIn = n.conf.MrkdwnIn
}
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
expandedTitle, truncated := notify.TruncateInRunes(expanded.Title, maxTitleLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated title", "max_runes", maxTitleLenRunes)
}
att := &attachment{
Title: title,
Title: expandedTitle,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: tmplText(n.conf.Text),
Text: expanded.Body,
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),

View File

@@ -0,0 +1,336 @@
package alertmanagertemplate
import (
"context"
"log/slog"
"strings"
"time"
"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"
)
// 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 ExpandAlertTemplates.
type ExpandedTemplates struct {
Title string
Body string
}
// AlertData holds per-alert data used when expanding body templates.
type AlertData struct {
Status string `json:"status"`
Labels template.KV `json:"labels"`
Annotations template.KV `json:"annotations"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
GeneratorURL string `json:"generator_url"`
Fingerprint string `json:"fingerprint"`
// Convenience fields extracted from well-known labels/annotations.
AlertName string `json:"rule_name"`
RuleID string `json:"rule_id"`
RuleLink string `json:"rule_link"`
Severity string `json:"severity"`
// Link annotations added by the rule evaluator.
LogLink string `json:"log_link"`
TraceLink string `json:"trace_link"`
// Status booleans for easy conditional templating.
IsFiring bool `json:"is_firing"`
IsResolved bool `json:"is_resolved"`
IsMissingData bool `json:"is_missing_data"`
IsRecovering bool `json:"is_recovering"`
}
// NotificationTemplateData is the top-level data struct provided to custom templates.
type NotificationTemplateData struct {
Receiver string `json:"receiver"`
Status string `json:"status"`
// Convenience fields for title templates.
AlertName string `json:"rule_name"`
RuleID string `json:"rule_id"`
RuleLink string `json:"rule_link"`
TotalFiring int `json:"total_firing"`
TotalResolved int `json:"total_resolved"`
// Per-alert data, also available as filtered sub-slices.
Alerts []AlertData `json:"alerts"`
// Cross-alert aggregates, computed as intersection across all alerts.
GroupLabels template.KV `json:"group_labels"`
CommonLabels template.KV `json:"common_labels"`
CommonAnnotations template.KV `json:"common_annotations"`
ExternalURL string `json:"external_url"`
}
// ExtractTemplatesFromAnnotations computes the common annotations across all alerts
// and returns the values for the title_template and body_template annotation keys.
func ExtractTemplatesFromAnnotations(alerts []*types.Alert) (titleTemplate, bodyTemplate string) {
if len(alerts) == 0 {
return "", ""
}
commonAnnotations := computeCommonAnnotations(alerts)
return commonAnnotations[ruletypes.AnnotationTitleTemplate], commonAnnotations[ruletypes.AnnotationBodyTemplate]
}
// ExpandAlertTemplates expands the title and body templates from input
// against the provided alerts and returns the expanded templates.
func ExpandAlertTemplates(
ctx context.Context,
tmpl *template.Template,
input TemplateInput,
alerts []*types.Alert,
logger *slog.Logger,
) (*ExpandedTemplates, error) {
ntd := buildNotificationTemplateData(ctx, tmpl, alerts, logger)
title, err := expandTitle(ctx, tmpl, input, alerts, ntd, logger)
if err != nil {
return nil, err
}
body, err := expandBody(ctx, tmpl, input, alerts, ntd, logger)
if err != nil {
return nil, err
}
return &ExpandedTemplates{Title: title, Body: body}, nil
}
// expandTitle expands the title template. Falls back to the default if the custom template
// result in empty string.
func expandTitle(
ctx context.Context,
tmpl *template.Template,
input TemplateInput,
alerts []*types.Alert,
ntd *NotificationTemplateData,
logger *slog.Logger,
) (string, error) {
if input.TitleTemplate != "" {
result, err := tmpl.ExecuteTextString(input.TitleTemplate, ntd)
if err != nil {
return "", err
}
if strings.TrimSpace(result) != "" {
return result, nil
}
}
// Fall back to the notifier's default title template using standard template.Data.
if input.DefaultTitleTemplate == "" {
return "", nil
}
data := notify.GetTemplateData(ctx, tmpl, alerts, logger)
return tmpl.ExecuteTextString(input.DefaultTitleTemplate, data)
}
// expandBody expands the body template once per alert, concatenates the results
// and falls back to the default if the custom template result in empty string.
func expandBody(
ctx context.Context,
tmpl *template.Template,
input TemplateInput,
alerts []*types.Alert,
ntd *NotificationTemplateData,
logger *slog.Logger,
) (string, error) {
if input.BodyTemplate != "" && len(ntd.Alerts) > 0 {
var parts []string
for i := range ntd.Alerts {
part, err := tmpl.ExecuteTextString(input.BodyTemplate, &ntd.Alerts[i])
if err != nil {
return "", err
}
parts = append(parts, part)
}
result := strings.Join(parts, "<br><br>") // markdown uses html for line breaks
if strings.TrimSpace(result) != "" {
return result, nil
}
}
// Fall back to the notifier's default body template using standard template.Data.
if input.DefaultBodyTemplate == "" {
return "", nil
}
data := notify.GetTemplateData(ctx, tmpl, alerts, logger)
return tmpl.ExecuteTextString(input.DefaultBodyTemplate, data)
}
// buildNotificationTemplateData derives a NotificationTemplateData from
// the context, template, and the raw alerts.
func buildNotificationTemplateData(
ctx context.Context,
tmpl *template.Template,
alerts []*types.Alert,
logger *slog.Logger,
) *NotificationTemplateData {
// extract the required data from the context
receiver, ok := notify.ReceiverName(ctx)
if !ok {
logger.WarnContext(ctx, "missing receiver name in context")
}
groupLabels, ok := notify.GroupLabels(ctx)
if !ok {
logger.WarnContext(ctx, "missing group labels in context")
}
// extract the external URL from the template
externalURL := ""
if tmpl.ExternalURL != nil {
externalURL = tmpl.ExternalURL.String()
}
commonAnnotations := computeCommonAnnotations(alerts)
commonLabels := computeCommonLabels(alerts)
// build the alert data slice
alertDataSlice := make([]AlertData, 0, len(alerts))
for _, a := range alerts {
ad := buildAlertData(a)
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,
}
}
// buildAlertData converts a single *types.Alert into an AlertData.
func buildAlertData(a *types.Alert) 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)
}
status := string(a.Status())
isFiring := a.Status() == model.AlertFiring
isResolved := a.Status() == model.AlertResolved
isMissingData := labels[ruletypes.LabelNoData] == "true"
return AlertData{
Status: 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],
IsFiring: isFiring,
IsResolved: isResolved,
IsMissingData: isMissingData,
}
}
// computeCommonAnnotations returns the intersection of annotations across all alerts as a template.KV.
// An annotation key/value pair is included only if it appears identically on every alert.
func computeCommonAnnotations(alerts []*types.Alert) template.KV {
if len(alerts) == 0 {
return template.KV{}
}
common := make(template.KV, len(alerts[0].Annotations))
for k, v := range alerts[0].Annotations {
common[string(k)] = string(v)
}
for _, a := range alerts[1:] {
for k := range common {
if string(a.Annotations[model.LabelName(k)]) != common[k] {
delete(common, k)
}
}
if len(common) == 0 {
break
}
}
return common
}
// computeCommonLabels returns the intersection of labels across all alerts as a template.KV.
func computeCommonLabels(alerts []*types.Alert) template.KV {
if len(alerts) == 0 {
return template.KV{}
}
common := make(template.KV, len(alerts[0].Labels))
for k, v := range alerts[0].Labels {
common[string(k)] = string(v)
}
for _, a := range alerts[1:] {
for k := range common {
if string(a.Labels[model.LabelName(k)]) != common[k] {
delete(common, k)
}
}
if len(common) == 0 {
break
}
}
return common
}

View File

@@ -0,0 +1,147 @@
package alertmanagertemplate
import (
"context"
"io"
"log/slog"
"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/template"
"github.com/prometheus/alertmanager/types"
)
// testSetup returns template, context, and logger for tests.
func testSetup(t *testing.T) (*template.Template, context.Context, *slog.Logger) {
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": "HighCPU", "severity": "critical"})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return tmpl, ctx, logger
}
// firingAlert returns a firing alert with the given labels/annotations.
func firingAlert(labels, annotations map[string]string) *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)
}
now := time.Now()
return &types.Alert{
Alert: model.Alert{
Labels: ls,
Annotations: ann,
StartsAt: now,
EndsAt: now.Add(time.Hour),
},
}
}
// TestExpandAlertTemplates_BothCustomTitleAndBody verifies that when both custom
// title and body templates are provided, both expand correctly (main happy path).
func TestExpandAlertTemplates_BothCustomTitleAndBody(t *testing.T) {
tmpl, ctx, logger := testSetup(t)
alerts := []*types.Alert{
firingAlert(
map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelRuleId: "rule-1", ruletypes.LabelSeverityName: "critical"},
nil,
),
}
input := TemplateInput{
TitleTemplate: "[{{.Status}}] {{.AlertName}}",
BodyTemplate: "Alert {{.AlertName}} ({{.Status}})",
DefaultTitleTemplate: "{{ .CommonLabels.alertname }}",
DefaultBodyTemplate: "{{ .Status }}",
}
got, err := ExpandAlertTemplates(ctx, tmpl, input, alerts, logger)
require.NoError(t, err)
require.Equal(t, "[firing] HighCPU", got.Title)
require.Equal(t, "Alert HighCPU (firing)", got.Body)
}
// TestExpandAlertTemplates_CustomTitleExpands verifies that a custom title
// template expands against NotificationTemplateData (rule-level fields).
func TestExpandAlertTemplates_CustomTitleExpands(t *testing.T) {
tmpl, ctx, logger := testSetup(t)
alerts := []*types.Alert{
firingAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelRuleId: "r1"}, nil),
firingAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelRuleId: "r1"}, nil),
}
input := TemplateInput{
TitleTemplate: "{{.AlertName}}: {{.TotalFiring}} firing",
DefaultTitleTemplate: "fallback",
}
got, err := ExpandAlertTemplates(ctx, tmpl, input, alerts, logger)
require.NoError(t, err)
require.Equal(t, "HighCPU: 2 firing", got.Title)
require.Equal(t, "", got.Body)
}
// TestExpandAlertTemplates_CustomBodySingleAlert verifies that a custom body
// template is expanded once per alert; with one alert, body is that expansion.
func TestExpandAlertTemplates_CustomBodySingleAlert(t *testing.T) {
tmpl, ctx, logger := testSetup(t)
alerts := []*types.Alert{
firingAlert(
map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"},
map[string]string{"description": "CPU > 80%"},
),
}
input := TemplateInput{
BodyTemplate: "{{.AlertName}} ({{.Severity}}): {{.Annotations.description}}",
}
got, err := ExpandAlertTemplates(ctx, tmpl, input, alerts, logger)
require.NoError(t, err)
require.Equal(t, "", got.Title)
require.Equal(t, "HighCPU (critical): CPU > 80%", got.Body)
}
// TestExpandAlertTemplates_CustomBodyMultipleAlerts verifies that body template
// is expanded per alert and results are concatenated with "<br><br>".
func TestExpandAlertTemplates_CustomBodyMultipleAlerts(t *testing.T) {
tmpl, ctx, logger := testSetup(t)
alerts := []*types.Alert{
firingAlert(map[string]string{ruletypes.LabelAlertName: "A"}, nil),
firingAlert(map[string]string{ruletypes.LabelAlertName: "B"}, nil),
firingAlert(map[string]string{ruletypes.LabelAlertName: "C"}, nil),
}
input := TemplateInput{
BodyTemplate: "{{.AlertName}}",
}
got, err := ExpandAlertTemplates(ctx, tmpl, input, alerts, logger)
require.NoError(t, err)
require.Equal(t, "A<br><br>B<br><br>C", got.Body)
}
// TestExpandAlertTemplates_BothDefaultTitleAndBody verifies that when no custom
// templates are set, both title and body fall back to default templates
// (executed against Prometheus template.Data).
func TestExpandAlertTemplates_BothDefaultTitleAndBody(t *testing.T) {
tmpl, ctx, logger := testSetup(t)
alerts := []*types.Alert{
firingAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil),
}
input := TemplateInput{
TitleTemplate: "",
BodyTemplate: "",
DefaultTitleTemplate: "{{ .CommonLabels.alertname }}",
DefaultBodyTemplate: "{{ .Status }}: {{ .CommonLabels.alertname }}",
}
got, err := ExpandAlertTemplates(ctx, tmpl, input, alerts, logger)
require.NoError(t, err)
require.Equal(t, "HighCPU", got.Title)
require.Equal(t, "firing: HighCPU", got.Body)
}

View File

@@ -9,4 +9,15 @@ const (
LabelSeverityName = "severity"
LabelLastSeen = "lastSeen"
LabelRuleId = "ruleId"
LabelRuleSource = "ruleSource"
LabelNoData = "nodata"
LabelTestAlert = "testalert"
LabelAlertName = "alertname"
)
const (
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationTitleTemplate = "title_template"
AnnotationBodyTemplate = "body_template"
)