Compare commits

...

1 Commits

Author SHA1 Message Date
srikanthccv
fb1b993c14 feat(alertmanager): render related links centrally and label AI traces
Decide the related-links label in one place: a RelatedLink helper for
the Go notifiers and two template defines for the channel defaults. A
link that points into /ai-observability/ gets the AI label.

Migration 135 upgrades stored channel templates that byte-match an old
seeded default, so untouched channels follow the current default.

Assisted-by: Claude Fable 5
2026-10-13 00:02:02 +05:30
24 changed files with 753 additions and 63 deletions

View File

@@ -19,8 +19,7 @@ export const SlackInitialConfig: Partial<SlackChannel> = {
*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}}
{{ template "signoz.relatedLinks.slack" . }}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
@@ -135,11 +134,7 @@ export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
{{ end }}{{ template "signoz.relatedLinks.markdown" . }}{{ end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: ['signoz-alert'],
@@ -161,11 +156,7 @@ export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
{{ end }}{{ template "signoz.relatedLinks.markdown" . }}{{ end }}`,
};
export const EmailInitialConfig: Partial<EmailChannel> = {

View File

@@ -25,7 +25,7 @@ export const editAlertChannelInitialValue = {
export const slackTitleDefaultValue = `[{{ .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 }}`;
export const slackDescriptionDefaultValue = `{{ 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 }}`;
export const slackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} {{ template "signoz.relatedLinks.slack" . }} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
export const googleChatTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`;

View File

@@ -29,7 +29,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -489,11 +488,8 @@ func appendRelatedLinkButtons(alerts []*types.Alert, bodies []string) {
if i >= len(alerts) || bodies[i] == "" {
continue
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
bodies[i] += htmlButton("View Related Logs", string(link))
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
bodies[i] += htmlButton("View Related Traces", string(link))
for _, link := range alertmanagertypes.RelatedLinks(alerts[i].Annotations) {
bodies[i] += htmlButton(link.Label, link.URL)
}
}
}

View File

@@ -243,13 +243,9 @@ func statusLine(alerts []*types.Alert) string {
// the annotations the ruler attaches to each alert. Empty links are skipped.
func relatedButtons(alert *types.Alert) []button {
var buttons []button
add := func(text, u string) {
if u != "" {
buttons = append(buttons, button{Text: text, OnClick: onClick{OpenLink: openLink{URL: u}}})
}
for _, link := range alertmanagertypes.RelatedLinks(alert.Annotations) {
buttons = append(buttons, button{Text: link.Label, OnClick: onClick{OpenLink: openLink{URL: link.URL}}})
}
add("View Related Logs", string(alert.Annotations[ruletypes.AnnotationRelatedLogs]))
add("View Related Traces", string(alert.Annotations[ruletypes.AnnotationRelatedTraces]))
return buttons
}

View File

@@ -343,6 +343,16 @@ func TestGoogleChatLinkButtons(t *testing.T) {
"View Related Traces": "https://signoz.example/traces",
},
},
{
name: "ai traces link",
labels: model.LabelSet{"alertname": "X"},
annotations: model.LabelSet{
ruletypes.AnnotationRelatedTraces: "https://signoz.example/ai-observability/explorer?q=1",
},
wantButtons: map[string]string{
"View Related AI Traces": "https://signoz.example/ai-observability/explorer?q=1",
},
},
{
name: "no links → no buttons",
labels: model.LabelSet{"alertname": "X"},

View File

@@ -11,10 +11,10 @@ import (
"testing"
"time"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/types"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"

View File

@@ -259,8 +259,9 @@ func deepLinks(alerts []*types.Alert) map[string]any {
})
}
add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource]))
add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs]))
add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces]))
for _, link := range alertmanagertypes.RelatedLinks(a.Annotations) {
add(link.Label, link.URL)
}
if len(parts) == 0 {
return nil
}

View File

@@ -18,7 +18,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -269,11 +268,8 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert, tm
// buttons for an alert, or nil when no related-link annotations are present.
func buildRelatedLinkActions(alert *types.Alert) []config.SlackAction {
var actions []config.SlackAction
if link := alert.Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Logs", URL: string(link)})
}
if link := alert.Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Traces", URL: string(link)})
for _, link := range alertmanagertypes.RelatedLinks(alert.Annotations) {
actions = append(actions, config.SlackAction{Type: "button", Text: link.Label, URL: link.URL})
}
return actions
}

View File

@@ -157,6 +157,108 @@ Description: Request rate exceeded 10k/s`},
" "},
wantIsDefaultBody: true,
},
{
// The current seeded default renders the related-links block via the
// server-side define; for a plain traces link the output is byte-equal
// to the old inline template above.
name: "current default: related links via define, same bytes as old default",
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: alertmanagertypes.ExpandRequest{
DefaultTitleTemplate: `{{ .CommonLabels.alertname }}`,
DefaultBodyTemplate: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
{{ template "signoz.relatedLinks.slack" . }}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
},
wantTitle: "DiskUsageHigh",
wantBody: []string{"*Alert:* DiskUsageHigh - critical\n" +
"\n" +
" *Summary:* Disk usage high on database host\n" +
" *Description:* Disk usage is high on the database host\n" +
" *RelatedLogs:* View in <https://logs.example.com/search?q=DiskUsageHigh|logs explorer>\n" +
" *RelatedTraces:* View in <https://traces.example.com/search?q=DiskUsageHigh|traces explorer>\n" +
"\n" +
" *Details:*\n" +
" • *alertname:* DiskUsageHigh\n" +
" • *instance:* db-primary-01\n" +
" • *severity:* critical\n" +
" \n" +
" "},
wantIsDefaultBody: true,
},
{
// An AI trace alert links to the ai-observability explorer and the
// define labels it accordingly.
name: "current default: ai traces link labelled ai traces explorer",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighLLMLatency", ruletypes.LabelSeverityName: "warning"},
map[string]string{
"summary": "LLM latency is high",
"description": "p99 latency of LLM calls is above threshold",
"related_traces": "https://signoz.example.com/ai-observability/explorer?startTime=1&endTime=2",
},
true,
),
},
input: alertmanagertypes.ExpandRequest{
DefaultTitleTemplate: `{{ .CommonLabels.alertname }}`,
DefaultBodyTemplate: `{{ range .Alerts -}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|{{ if isAITracesLink .Annotations.related_traces }}ai traces explorer{{ else }}traces explorer{{ end }}> {{- end}}
{{ end }}`,
},
wantTitle: "HighLLMLatency",
wantBody: []string{"*RelatedTraces:* View in <https://signoz.example.com/ai-observability/explorer?startTime=1&endTime=2|ai traces explorer>\n "},
wantIsDefaultBody: true,
},
{
// The markdown define used by the incident.io / JSM Ops defaults,
// exercised through the shipped JSM Ops description template.
name: "jsmops default: ai traces link labelled view related ai traces",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighLLMLatency", ruletypes.LabelSeverityName: "warning"},
map[string]string{
"summary": "LLM latency is high",
"description": "p99 latency of LLM calls is above threshold",
"related_traces": "https://signoz.example.com/ai-observability/explorer?startTime=1&endTime=2",
},
true,
),
},
input: alertmanagertypes.ExpandRequest{
DefaultTitleTemplate: alertmanagertypes.DefaultJSMOpsMessageTemplate,
DefaultBodyTemplate: alertmanagertypes.DefaultJSMOpsDescriptionTemplate,
},
wantTitle: "[FIRING:1] HighLLMLatency",
wantBody: []string{"**Alert:** HighLLMLatency (warning)\n" +
"\n" +
"**Summary:** LLM latency is high\n" +
"\n" +
"**Description:** p99 latency of LLM calls is above threshold\n" +
"\n" +
"[View related AI traces](https://signoz.example.com/ai-observability/explorer?startTime=1&endTime=2)\n" +
"\n"},
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.

View File

@@ -311,11 +311,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
params := r.prepareParamsForTraces(ctx, ts, smpl.Metric)
if len(params) > 0 {
explorerPath := "traces-explorer"
if r.typ == ruletypes.AlertTypeAITraces {
explorerPath = "ai-observability/explorer"
}
link := r.ExternalURL(explorerPath, params)
link := r.ExternalURL(r.typ.TracesExplorerPath(), params)
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: link})
}

View File

@@ -261,6 +261,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddUserTuplesFactory(sqlstore),
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
sqlmigration.NewUpdateChannelDefaultTemplatesFactory(),
)
}

View File

@@ -0,0 +1,272 @@
package sqlmigration
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// The seeded default templates this migration replaces, byte-exact as the
// frontend (slack / msteams) and the backend consts (incidentio / jsmops)
// shipped them over time. A channel whose template matches none of these was
// customized and is never touched.
const (
slackDefaultTextPre2024 = `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`
slackDefaultText2024 = `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ .Annotations.related_logs }}
*RelatedTraces:* {{ .Annotations.related_traces }}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`
slackDefaultText2026 = `{{ 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 }}`
slackDefaultTextCurrent = `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
{{ template "signoz.relatedLinks.slack" . }}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`
markdownDefaultDescription2026 = `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`
markdownDefaultDescriptionCurrent = `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ template "signoz.relatedLinks.markdown" . }}{{ end }}`
)
var channelDefaultTextUpgrades = map[string]string{
slackDefaultTextPre2024: slackDefaultTextCurrent,
slackDefaultText2024: slackDefaultTextCurrent,
slackDefaultText2026: slackDefaultTextCurrent,
}
var channelDefaultDescriptionUpgrades = map[string]string{
markdownDefaultDescription2026: markdownDefaultDescriptionCurrent,
}
// receiver configs lists and the template field each one carries.
var channelTemplateFields = map[string]string{
"slack_configs": "text",
"msteams_configs": "text",
"msteamsv2_configs": "text",
"incidentio_configs": "description",
"jsmops_configs": "description",
}
type updateChannelDefaultTemplates struct {
logger *slog.Logger
}
func NewUpdateChannelDefaultTemplatesFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("update_channel_templates"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &updateChannelDefaultTemplates{logger: ps.Logger}, nil
})
}
func (migration *updateChannelDefaultTemplates) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
type channelTemplateRow struct {
bun.BaseModel `bun:"table:notification_channel"`
ID string `bun:"id,pk"`
Type string `bun:"type"`
Data string `bun:"data"`
Spec string `bun:"spec,nullzero"`
}
// Up rewrites, in both the receiver (data) and the v2 spec, channel templates
// that are byte-equal to a seeded default of an earlier release, so untouched
// channels keep tracking the current default. A malformed row is logged and
// skipped, never fails the migration.
func (migration *updateChannelDefaultTemplates) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*channelTemplateRow, 0)
err = tx.NewSelect().
Model(&rows).
Where("type IN (?)", bun.In([]string{"slack", "msteams", "msteamsv2", "incidentio", "jsmops"})).
Scan(ctx)
if err != nil {
return err
}
for _, row := range rows {
data, dataChanged, err := upgradeReceiverDefaultTemplates(row.Data)
if err != nil {
migration.logger.WarnContext(ctx, "skipping channel with unreadable receiver", "channel_id", row.ID, "error", err)
continue
}
spec, specChanged, err := upgradeSpecDefaultTemplate(row.Type, row.Spec)
if err != nil {
migration.logger.WarnContext(ctx, "skipping channel with unreadable spec", "channel_id", row.ID, "error", err)
continue
}
if !dataChanged && !specChanged {
continue
}
row.Data, row.Spec = data, spec
if _, err := tx.NewUpdate().Model(row).Column("data", "spec").WherePK().Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *updateChannelDefaultTemplates) Down(context.Context, *bun.DB) error {
return nil
}
func upgradeReceiverDefaultTemplates(data string) (string, bool, error) {
receiver := map[string]any{}
if err := json.Unmarshal([]byte(data), &receiver); err != nil {
return "", false, err
}
changed := false
for configsKey, field := range channelTemplateFields {
configs, ok := receiver[configsKey].([]any)
if !ok {
continue
}
for _, c := range configs {
config, ok := c.(map[string]any)
if !ok {
continue
}
value, ok := config[field].(string)
if !ok {
continue
}
if upgraded, ok := upgradeChannelTemplate(field, value); ok {
config[field] = upgraded
changed = true
}
}
}
if !changed {
return data, false, nil
}
out, err := marshalJSONNoHTMLEscape(receiver)
return out, true, err
}
func upgradeSpecDefaultTemplate(rowType, spec string) (string, bool, error) {
if spec == "" {
return spec, false, nil
}
field := "text"
if rowType == "incidentio" || rowType == "jsmops" {
field = "description"
}
parsed := map[string]any{}
if err := json.Unmarshal([]byte(spec), &parsed); err != nil {
return "", false, err
}
value, ok := parsed[field].(string)
if !ok {
return spec, false, nil
}
upgraded, ok := upgradeChannelTemplate(field, value)
if !ok {
return spec, false, nil
}
parsed[field] = upgraded
out, err := marshalJSONNoHTMLEscape(parsed)
return out, true, err
}
func upgradeChannelTemplate(field, value string) (string, bool) {
upgrades := channelDefaultTextUpgrades
if field == "description" {
upgrades = channelDefaultDescriptionUpgrades
}
upgraded, ok := upgrades[value]
return upgraded, ok
}
// marshalJSONNoHTMLEscape keeps < and > literal: slack templates contain
// <url|label> links and the stored JSON stays readable.
func marshalJSONNoHTMLEscape(v any) (string, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "", err
}
return strings.TrimSuffix(buf.String(), "\n"), nil
}

View File

@@ -0,0 +1,150 @@
package sqlmigration
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The migration duplicates the current defaults (migrations stay import-free);
// this pins the copies to the shipped consts.
func TestChannelDefaultTemplatesMatchShippedDefaults(t *testing.T) {
assert.Equal(t, alertmanagertypes.DefaultJSMOpsDescriptionTemplate, markdownDefaultDescriptionCurrent)
assert.Equal(t, alertmanagertypes.DefaultIncidentIODescriptionTemplate, markdownDefaultDescriptionCurrent)
}
func TestUpgradeReceiverDefaultTemplates(t *testing.T) {
testCases := []struct {
name string
receiver map[string]any
wantChanged bool
wantText string
}{
{
name: "SlackSeededDefault2026_Upgraded",
receiver: map[string]any{"name": "slack-prod", "slack_configs": []any{map[string]any{"api_url": "https://hooks.slack.com/services/x", "text": slackDefaultText2026}}},
wantChanged: true,
wantText: slackDefaultTextCurrent,
},
{
name: "SlackSeededDefault2024_Upgraded",
receiver: map[string]any{"name": "slack-staging", "slack_configs": []any{map[string]any{"text": slackDefaultText2024}}},
wantChanged: true,
wantText: slackDefaultTextCurrent,
},
{
name: "SlackSeededDefaultPre2024_Upgraded",
receiver: map[string]any{"name": "slack-oldest", "slack_configs": []any{map[string]any{"text": slackDefaultTextPre2024}}},
wantChanged: true,
wantText: slackDefaultTextCurrent,
},
{
name: "CustomizedText_Untouched",
receiver: map[string]any{"name": "slack-custom", "slack_configs": []any{map[string]any{"text": "my own template {{ .Annotations.summary }}"}}},
wantChanged: false,
wantText: "my own template {{ .Annotations.summary }}",
},
{
name: "MSTeamsSharesSlackDefault_Upgraded",
receiver: map[string]any{"name": "teams-prod", "msteamsv2_configs": []any{map[string]any{"text": slackDefaultText2026}}},
wantChanged: true,
wantText: slackDefaultTextCurrent,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
data, err := marshalJSONNoHTMLEscape(testCase.receiver)
require.NoError(t, err)
upgraded, changed, err := upgradeReceiverDefaultTemplates(data)
require.NoError(t, err)
assert.Equal(t, testCase.wantChanged, changed)
parsed := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(upgraded), &parsed))
for _, key := range []string{"slack_configs", "msteamsv2_configs"} {
configs, ok := parsed[key].([]any)
if !ok {
continue
}
assert.Equal(t, testCase.wantText, configs[0].(map[string]any)["text"])
}
assert.Equal(t, testCase.receiver["name"], parsed["name"])
})
}
}
func TestUpgradeReceiverDefaultTemplates_MarkdownDescription(t *testing.T) {
data, err := marshalJSONNoHTMLEscape(map[string]any{"name": "incidentio-prod", "incidentio_configs": []any{map[string]any{"url": "https://api.incident.io/v2/alert_events/http/x", "description": markdownDefaultDescription2026}}})
require.NoError(t, err)
upgraded, changed, err := upgradeReceiverDefaultTemplates(data)
require.NoError(t, err)
assert.True(t, changed)
parsed := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(upgraded), &parsed))
config := parsed["incidentio_configs"].([]any)[0].(map[string]any)
assert.Equal(t, markdownDefaultDescriptionCurrent, config["description"])
assert.Equal(t, "https://api.incident.io/v2/alert_events/http/x", config["url"])
}
func TestUpgradeSpecDefaultTemplate(t *testing.T) {
testCases := []struct {
name string
rowType string
spec map[string]any
wantChanged bool
wantField string
wantValue string
}{
{
name: "SlackSpecSeededDefault_Upgraded",
rowType: "slack",
spec: map[string]any{"send_resolved": true, "text": slackDefaultText2026},
wantChanged: true,
wantField: "text",
wantValue: slackDefaultTextCurrent,
},
{
name: "JSMOpsSpecSeededDefault_Upgraded",
rowType: "jsmops",
spec: map[string]any{"description": markdownDefaultDescription2026},
wantChanged: true,
wantField: "description",
wantValue: markdownDefaultDescriptionCurrent,
},
{
name: "CustomizedSpec_Untouched",
rowType: "slack",
spec: map[string]any{"text": "mine"},
wantChanged: false,
wantField: "text",
wantValue: "mine",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
spec, err := marshalJSONNoHTMLEscape(testCase.spec)
require.NoError(t, err)
upgraded, changed, err := upgradeSpecDefaultTemplate(testCase.rowType, spec)
require.NoError(t, err)
assert.Equal(t, testCase.wantChanged, changed)
parsed := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(upgraded), &parsed))
assert.Equal(t, testCase.wantValue, parsed[testCase.wantField])
})
}
}
func TestUpgradeSpecDefaultTemplate_EmptySpec(t *testing.T) {
upgraded, changed, err := upgradeSpecDefaultTemplate("slack", "")
require.NoError(t, err)
assert.False(t, changed)
assert.Empty(t, upgraded)
}

View File

@@ -89,11 +89,7 @@ const (
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`
{{ end }}{{ template "signoz.relatedLinks.markdown" . }}{{ end }}`
)
// IncidentIOReceiverConfig is the SigNoz incident.io receiver, backed by an

View File

@@ -94,11 +94,7 @@ const (
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`
{{ end }}{{ template "signoz.relatedLinks.markdown" . }}{{ end }}`
)
// JSMOpsReceiverConfig is the SigNoz Jira Service Management Ops receiver. It is

View File

@@ -0,0 +1,47 @@
package alertmanagertypes
import (
"net/url"
"strings"
"github.com/prometheus/common/model"
)
const (
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AIObservabilityPathPrefix = "ai-observability"
)
// RelatedLink is one deep link a notification renders for an alert.
type RelatedLink struct {
Label string
URL string
}
// RelatedLinks returns the deep links the ruler attached to an alert, in the
// order notifiers render them. Labels are decided here so every notifier shows
// the same text for the same link.
func RelatedLinks(annotations model.LabelSet) []RelatedLink {
links := make([]RelatedLink, 0, 2)
if link := string(annotations[AnnotationRelatedLogs]); link != "" {
links = append(links, RelatedLink{Label: "View Related Logs", URL: link})
}
if link := string(annotations[AnnotationRelatedTraces]); link != "" {
label := "View Related Traces"
if IsAITracesLink(link) {
label = "View Related AI Traces"
}
links = append(links, RelatedLink{Label: label, URL: link})
}
return links
}
// IsAITracesLink reports whether a related-traces link opens the AI
// observability section of the UI. Matched on the URL path so installs served
// under a path prefix still count. Channel templates call it as isAITracesLink.
func IsAITracesLink(link string) bool {
u, err := url.Parse(link)
return err == nil && strings.Contains(u.Path, "/"+AIObservabilityPathPrefix+"/")
}

View File

@@ -0,0 +1,63 @@
package alertmanagertypes
import (
"testing"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
)
func TestIsAITracesLink(t *testing.T) {
testCases := []struct {
name string
link string
want bool
}{
{name: "TracesExplorer", link: "https://signoz.example/traces-explorer?q=1", want: false},
{name: "AITracesExplorer", link: "https://signoz.example/ai-observability/explorer?q=1", want: true},
{name: "PrefixedInstall", link: "https://acme.com/signoz/ai-observability/explorer?q=1", want: true},
{name: "Empty", link: "", want: false},
{name: "AIObservabilityHost", link: "https://ai-observability.acme.com/traces-explorer", want: false},
{name: "AIObservabilityPathLookalike", link: "https://acme.com/ai-observability-team/explorer", want: false},
{name: "AIObservabilityInQuery", link: "https://acme.com/traces-explorer?q=/ai-observability/", want: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, IsAITracesLink(testCase.link))
})
}
}
func TestRelatedLinks(t *testing.T) {
testCases := []struct {
name string
annotations model.LabelSet
want []RelatedLink
}{
{
name: "NoAnnotations",
annotations: model.LabelSet{"summary": "cpu is high"},
want: []RelatedLink{},
},
{
name: "LogsAndTraces_LogsFirst",
annotations: model.LabelSet{AnnotationRelatedLogs: "https://signoz.example/logs/logs-explorer?a=1", AnnotationRelatedTraces: "https://signoz.example/traces-explorer?a=1"},
want: []RelatedLink{
{Label: "View Related Logs", URL: "https://signoz.example/logs/logs-explorer?a=1"},
{Label: "View Related Traces", URL: "https://signoz.example/traces-explorer?a=1"},
},
},
{
name: "AITraces",
annotations: model.LabelSet{AnnotationRelatedTraces: "https://signoz.example/ai-observability/explorer?a=1"},
want: []RelatedLink{
{Label: "View Related AI Traces", URL: "https://signoz.example/ai-observability/explorer?a=1"},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, RelatedLinks(testCase.annotations))
})
}
}

View File

@@ -19,6 +19,7 @@ func AdditionalFuncMap() tmpltext.FuncMap {
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
"isAITracesLink": IsAITracesLink,
}
}
@@ -45,6 +46,13 @@ func FromGlobs(paths []string) (*alertmanagertemplate.Template, error) {
{{ define "__ruleIdPath" }}{{- $isTestAlert := "" -}}{{- range .CommonLabels.SortedPairs -}}{{- if eq .Name "testalert" -}}{{- if eq .Value "true" -}}{{- $isTestAlert = "true" -}}{{- end -}}{{- end -}}{{- end -}}{{- range .CommonLabels.SortedPairs -}}{{- if eq .Name "ruleId" -}}{{- if ne .Value "" -}}/edit?ruleId={{ .Value | urlescape }}{{- if $isTestAlert -}}&isTestAlert=true{{- end -}}{{- end -}}{{- end -}}{{- end -}}{{- end }}
{{ define "__alertmanagerURL" }}{{ .ExternalURL }}/alerts{{ template "__ruleIdPath" . }}{{ end }}
{{ define "msteamsv2.default.titleLink" }}{{ template "__alertmanagerURL" . }}{{ end }}
{{ define "signoz.relatedLinks.slack" }}*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 }}|{{ if isAITracesLink .Annotations.related_traces }}ai traces explorer{{ else }}traces explorer{{ end }}> {{- end}}{{ end }}
{{ define "signoz.relatedLinks.markdown" }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[{{ if isAITracesLink .Annotations.related_traces }}View related AI traces{{ else }}View related traces{{ end }}]({{ .Annotations.related_traces }})
{{ end }}{{ end }}
`))); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error parsing alertmanager templates")
}

View File

@@ -1,5 +1,7 @@
package ruletypes
import "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
const (
CriticalThresholdName = "critical"
ErrorThresholdName = "error"
@@ -32,8 +34,8 @@ const (
const (
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationRelatedLogs = alertmanagertypes.AnnotationRelatedLogs
AnnotationRelatedTraces = alertmanagertypes.AnnotationRelatedTraces
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"

View File

@@ -0,0 +1,16 @@
package ruletypes
import "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
const (
DefaultTracesExplorerPath = "traces-explorer"
AITracesExplorerPath = alertmanagertypes.AIObservabilityPathPrefix + "/explorer"
)
// TracesExplorerPath is the UI path the alert's related-traces link opens.
func (t AlertType) TracesExplorerPath() string {
if t == AlertTypeAITraces {
return AITracesExplorerPath
}
return DefaultTracesExplorerPath
}

View File

@@ -0,0 +1,26 @@
package ruletypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTracesExplorerPath(t *testing.T) {
testCases := []struct {
name string
alertType AlertType
want string
}{
{name: "Metric", alertType: AlertTypeMetric, want: "traces-explorer"},
{name: "Traces", alertType: AlertTypeTraces, want: "traces-explorer"},
{name: "Logs", alertType: AlertTypeLogs, want: "traces-explorer"},
{name: "Exceptions", alertType: AlertTypeExceptions, want: "traces-explorer"},
{name: "AITraces", alertType: AlertTypeAITraces, want: "ai-observability/explorer"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, testCase.alertType.TracesExplorerPath())
})
}
}

View File

@@ -101,7 +101,7 @@ slack_default_config = {
{
"api_url": "services/TEAM_ID/BOT_ID/TOKEN_ID", # base_url configured on runtime
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n {{ template "signoz.relatedLinks.slack" . }}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
}
],
}
@@ -111,7 +111,7 @@ msteams_default_config = {
{
"webhook_url": "msteams/webhook_url", # base_url configured on runtime
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n {{ template "signoz.relatedLinks.slack" . }}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
}
],
}

View File

@@ -62,6 +62,39 @@ NOTIFIERS_TEST = [
],
),
),
types.AlertManagerNotificationTestCase(
name="slack_notifier_ai_traces_default_templating",
rule_path="ruler/test_scenarios/rule_state_history_ai_traces/rule.json",
alert_data=[
types.AlertData(
type="traces",
data_path="ruler/test_scenarios/rule_state_history_ai_traces/alert_data.jsonl",
),
],
channel_config=slack_default_config,
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=120,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/services/TEAM_ID/BOT_ID/TOKEN_ID",
"json_body": {
"username": "Alertmanager",
"attachments": [
{
"color": "danger",
"mrkdwn_in": ["fallback", "pretext", "text"],
"text": re.compile(r"\*RelatedTraces:\* View in <[^>]*/ai-observability/explorer\?[^>]*\|ai traces explorer>"),
}
],
},
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="msteams_notifier_default_templating",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",

View File

@@ -419,11 +419,7 @@ def test_create_channel( # pylint: disable=too-many-arguments,too-many-position
"\n"
"{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})\n"
"\n"
"{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})\n"
"\n"
"{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})\n"
"\n"
"{{ end }}{{ end }}"
'{{ end }}{{ template "signoz.relatedLinks.markdown" . }}{{ end }}'
),
"tags": "signoz",
},
@@ -450,11 +446,7 @@ def test_create_channel( # pylint: disable=too-many-arguments,too-many-position
"\n"
"{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})\n"
"\n"
"{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})\n"
"\n"
"{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})\n"
"\n"
"{{ end }}{{ end }}"
'{{ end }}{{ template "signoz.relatedLinks.markdown" . }}{{ end }}'
),
},
[],