Compare commits

...

20 Commits

Author SHA1 Message Date
nikhilmantri0902
929de9172a refactor(alertmanagertypes): resolve related links only from the two shapes templates pass 2026-10-30 16:53:25 +05:30
nikhilmantri0902
bf02f20e9a refactor(alertmanagertypes): rename the relatedLinks template func after its callers 2026-10-30 16:32:42 +05:30
nikhilmantri0902
46951100c7 test(alertmanager): move related links tests into the notifier test files 2026-10-30 16:20:03 +05:30
nikhilmantri0902
a616e7a91e Merge branch 'main' into refactor/alertmanager-related-links-template
# Conflicts:
#	pkg/signoz/provider.go
2026-10-30 16:08:42 +05:30
nikhilmantri0902
a86e5da693 fix(sqlmigration): rewrite every related links section and match the annotation access exactly 2026-10-26 19:47:49 +05:30
nikhilmantri0902
b3d3b8d5ae fix(alertmanager): resolve related links from rule template data as well 2026-10-26 19:37:40 +05:30
nikhilmantri0902
22e60f4e0c Merge branch 'main' into refactor/alertmanager-related-links-template
# Conflicts:
#	pkg/signoz/provider.go
2026-10-26 18:36:59 +05:30
Nikhil Mantri
d5c6cfbb25 Merge branch 'main' into refactor/alertmanager-related-links-template 2026-10-22 16:01:19 +05:30
nikhilmantri0902
c137fcc15e Merge remote-tracking branch 'origin/main' into refactor/alertmanager-related-links-template
# Conflicts:
#	pkg/signoz/provider.go
2026-10-21 18:36:23 +05:30
nikhilmantri0902
975ac8926a chore(sqlmigration): log receiver names plainly and keep rewrites from sibling configs 2026-10-21 18:32:10 +05:30
nikhilmantri0902
abbfd006d5 feat(alertmanager): render telegram related links from the html template block 2026-10-21 18:08:01 +05:30
nikhilmantri0902
4c4ce990fb Merge branch 'main' into refactor/alertmanager-related-links-template
# Conflicts:
#	frontend/src/container/CreateAlertChannels/defaults.ts
#	pkg/signoz/provider.go
2026-10-21 17:13:10 +05:30
nikhilmantri0902
43a0f5ab5f refactor(ruletypes): drop related-links annotation aliases, use alertmanagertypes directly 2026-10-19 13:50:29 +05:30
nikhilmantri0902
ed9488b7e4 chore(sqlmigration): drop the msteamsv2 channel type that migration 131 already renamed 2026-10-19 13:23:10 +05:30
nikhilmantri0902
0e7af3c785 refactor(alertmanager): type BuildRelatedLinks and keep the loose shape on the template adapter 2026-10-19 13:16:41 +05:30
nikhilmantri0902
8667678c96 fix(sqlmigration): log related links sections the migration cannot rewrite 2026-10-19 13:05:25 +05:30
nikhilmantri0902
316a75787b feat(sqlmigration): move stored related links snippets to template blocks 2026-10-17 16:42:53 +05:30
nikhilmantri0902
c2f5f86583 refactor(alertmanager): render related links from template blocks in defaults 2026-10-17 16:16:03 +05:30
nikhilmantri0902
c622e13a4f refactor(alertmanager): build related link buttons from shared helper 2026-10-17 16:07:52 +05:30
nikhilmantri0902
7a96011b1e refactor(alertmanager): add related links helper and template blocks 2026-10-17 15:57:35 +05:30
31 changed files with 1041 additions and 82 deletions

View File

@@ -20,8 +20,7 @@ export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO>
*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 }}
@@ -141,11 +140,7 @@ export const JsmOpsInitialConfig: Partial<AlertmanagertypesChannelJSMOpsConfigDT
{{ 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',
@@ -168,11 +163,7 @@ export const IncidentIOInitialConfig: Partial<AlertmanagertypesChannelIncidentIO
{{ 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<AlertmanagertypesChannelEmailConfigDTO> =

View File

@@ -13,7 +13,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.BuildRelatedLinks(alerts[i].Annotations) {
bodies[i] += htmlButton(link.Label, link.URL)
}
}
}

View File

@@ -1156,3 +1156,58 @@ func TestPrepareContent(t *testing.T) {
func ptrTo(b bool) *bool {
return &b
}
func TestAppendRelatedLinkButtons(t *testing.T) {
testCases := []struct {
name string
annotations model.LabelSet
body string
expectedOrder []string
}{
{
name: "BothLinks_LogsBeforeTraces",
annotations: model.LabelSet{"related_traces": "https://signoz.example/traces?a=1", "related_logs": "https://signoz.example/logs?a=1"},
body: "<p>body</p>",
expectedOrder: []string{"https://signoz.example/logs?a=1", "View Related Logs", "https://signoz.example/traces?a=1", "View Related Traces"},
},
{
name: "TracesOnly_SingleButton",
annotations: model.LabelSet{"related_traces": "https://signoz.example/traces?b=2"},
body: "<p>body</p>",
expectedOrder: []string{"https://signoz.example/traces?b=2", "View Related Traces"},
},
{
name: "NoLinks_BodyUnchanged",
annotations: model.LabelSet{"summary": "s"},
body: "<p>body</p>",
expectedOrder: nil,
},
{
name: "EmptyBody_NoButtons",
annotations: model.LabelSet{"related_logs": "https://signoz.example/logs?c=3"},
body: "",
expectedOrder: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
alerts := []*types.Alert{{Alert: model.Alert{Annotations: testCase.annotations}}}
bodies := []string{testCase.body}
appendRelatedLinkButtons(alerts, bodies)
if testCase.expectedOrder == nil {
assert.Equal(t, testCase.body, bodies[0])
return
}
assert.True(t, strings.HasPrefix(bodies[0], testCase.body))
last := -1
for _, needle := range testCase.expectedOrder {
idx := strings.Index(bodies[0], needle)
assert.Greater(t, idx, last, needle)
last = idx
}
})
}
}

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.BuildRelatedLinks(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

@@ -334,8 +334,8 @@ func TestGoogleChatLinkButtons(t *testing.T) {
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
},
annotations: model.LabelSet{
ruletypes.AnnotationRelatedLogs: "https://signoz.example/logs",
ruletypes.AnnotationRelatedTraces: "https://signoz.example/traces",
alertmanagertypes.AnnotationRelatedLogs: "https://signoz.example/logs",
alertmanagertypes.AnnotationRelatedTraces: "https://signoz.example/traces",
},
wantButtons: map[string]string{
"Open in SigNoz": "https://signoz.example/alerts/1",
@@ -416,8 +416,8 @@ func TestGoogleChatMultiAlertSections(t *testing.T) {
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
},
Annotations: model.LabelSet{
ruletypes.AnnotationBodyTemplate: "an alert fired",
ruletypes.AnnotationRelatedLogs: model.LabelValue(logs),
ruletypes.AnnotationBodyTemplate: "an alert fired",
alertmanagertypes.AnnotationRelatedLogs: model.LabelValue(logs),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),
@@ -464,7 +464,7 @@ func TestGoogleChatDefaultBodyGrouped(t *testing.T) {
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
},
Annotations: model.LabelSet{
ruletypes.AnnotationRelatedLogs: model.LabelValue("https://signoz.example/logs?pod=" + pod),
alertmanagertypes.AnnotationRelatedLogs: model.LabelValue("https://signoz.example/logs?pod=" + pod),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Minute),

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"
@@ -111,7 +111,7 @@ func TestNotifyFiringEvent(t *testing.T) {
assert.Contains(t, ev.Description, "**Alert:** HighCPU (critical)")
assert.Contains(t, ev.Description, "**Summary:** cpu high")
assert.Contains(t, ev.Description, "[View in SigNoz](https://signoz.example/alerts/edit?ruleId=1)")
assert.Contains(t, ev.Description, "[View related logs](https://signoz.example/logs?q=1)")
assert.Contains(t, ev.Description, "[View Related Logs](https://signoz.example/logs?q=1)")
assert.Equal(t, map[string]string{
"alertname": "HighCPU",
"severity": "critical",

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.BuildRelatedLinks(a.Annotations) {
add(link.Label, link.URL)
}
if len(parts) == 0 {
return nil
}

View File

@@ -275,7 +275,7 @@ func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) {
m := newMockJira(t)
a := alert(true)
a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1")
a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs")
a.Annotations[alertmanagertypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs")
_, err := newNotifier(t, m).Notify(ctx(), a)
require.NoError(t, err)
@@ -487,3 +487,52 @@ func TestResolveCloudID(t *testing.T) {
})
}
}
func TestDeepLinks(t *testing.T) {
testCases := []struct {
name string
labels model.LabelSet
annotations model.LabelSet
expectedTexts []string
}{
{
name: "RuleSourceAndBothLinks_DotSeparatedInOrder",
labels: model.LabelSet{"ruleSource": "https://signoz.example/alerts?ruleId=1"},
annotations: model.LabelSet{"related_traces": "https://signoz.example/traces?a=1", "related_logs": "https://signoz.example/logs?a=1"},
expectedTexts: []string{"Open in SigNoz", " · ", "View Related Logs", " · ", "View Related Traces"},
},
{
name: "TracesOnly_NoRuleSource",
labels: model.LabelSet{},
annotations: model.LabelSet{"related_traces": "https://signoz.example/traces?b=2"},
expectedTexts: []string{"View Related Traces"},
},
{
name: "NoLinks_Nil",
labels: model.LabelSet{},
annotations: model.LabelSet{"summary": "s"},
expectedTexts: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
alerts := []*types.Alert{{Alert: model.Alert{Labels: testCase.labels, Annotations: testCase.annotations}}}
paragraph := deepLinks(alerts)
if testCase.expectedTexts == nil {
assert.Nil(t, paragraph)
return
}
require.NotNil(t, paragraph)
content, ok := paragraph["content"].([]any)
require.True(t, ok)
texts := make([]string, 0, len(content))
for _, part := range content {
texts = append(texts, part.(map[string]any)["text"].(string))
}
assert.Equal(t, testCase.expectedTexts, texts)
})
}
}

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.BuildRelatedLinks(alert.Annotations) {
actions = append(actions, config.SlackAction{Type: "button", Text: link.Label, URL: link.URL})
}
return actions
}

View File

@@ -571,3 +571,37 @@ func TestSlackMessageField(t *testing.T) {
_, err = notifier.Notify(ctx)
require.NoError(t, err, "Notify failed")
}
func TestBuildRelatedLinkActions(t *testing.T) {
testCases := []struct {
name string
annotations model.LabelSet
expected []config.SlackAction
}{
{
name: "BothLinks_LogsBeforeTraces",
annotations: model.LabelSet{"related_traces": "https://signoz.example/traces?a=1", "related_logs": "https://signoz.example/logs?a=1"},
expected: []config.SlackAction{
{Type: "button", Text: "View Related Logs", URL: "https://signoz.example/logs?a=1"},
{Type: "button", Text: "View Related Traces", URL: "https://signoz.example/traces?a=1"},
},
},
{
name: "LogsOnly_SingleAction",
annotations: model.LabelSet{"related_logs": "https://signoz.example/logs?b=2"},
expected: []config.SlackAction{{Type: "button", Text: "View Related Logs", URL: "https://signoz.example/logs?b=2"}},
},
{
name: "NoLinks_Nil",
annotations: model.LabelSet{"summary": "s"},
expected: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
alert := &types.Alert{Alert: model.Alert{Annotations: testCase.annotations}}
assert.Equal(t, testCase.expected, buildRelatedLinkActions(alert))
})
}
}

View File

@@ -267,8 +267,8 @@ func buildAlertData(a *types.Alert, receiver string) alertmanagertypes.AlertData
IsRecovering: labels[ruletypes.LabelIsRecovering] == "true",
},
Rule: buildRuleInfo(labels, annotations),
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
Log: alertmanagertypes.LinkInfo{URL: annotations[alertmanagertypes.AnnotationRelatedLogs]},
Trace: alertmanagertypes.LinkInfo{URL: annotations[alertmanagertypes.AnnotationRelatedTraces]},
Labels: labels,
// Strip private annotations once the structured fields above have
// been populated from the raw map.

View File

@@ -190,6 +190,58 @@ Description: Request rate exceeded 10k/s`},
wantBody: []string{"auth-service (firing)", "payment-service (resolved)"},
wantIsDefaultBody: false,
},
{
// Rule templates hand the block the preprocessed per-alert map, so the
// related links must resolve from its lowercase annotations.
name: "related links block: rule body renders markdown links from preprocessed data",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrorRate"},
map[string]string{"related_logs": "https://signoz.example/logs?q=1", "related_traces": "https://signoz.example/traces?q=1"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: `$rule.name fired.{{ "\n\n" }}{{ template "signoz.relatedLinks.markdown" . }}`,
},
wantTitle: "HighErrorRate",
wantBody: []string{"HighErrorRate fired.\n\n[View Related Logs](https://signoz.example/logs?q=1)\n\n[View Related Traces](https://signoz.example/traces?q=1)"},
wantIsDefaultBody: false,
},
{
name: "related links block: rule body per alert, slack format, links absent on one alert",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-1"}, map[string]string{"related_logs": "https://signoz.example/logs?pod=api-1"}, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-2"}, nil, true),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: `$labels.pod: {{ template "signoz.relatedLinks.slack" . }}`,
},
wantTitle: "PodCrashLoop",
wantBody: []string{"api-1: <https://signoz.example/logs?pod=api-1|View Related Logs>", "api-2:"},
wantIsDefaultBody: false,
},
{
// Channel default templates range over upstream template.Alert values,
// the other shape the block has to accept.
name: "related links block: channel default body renders from template.Alert",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighLatency"},
map[string]string{"related_traces": "https://signoz.example/traces?q=2"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
DefaultTitleTemplate: "{{ .CommonLabels.alertname }}",
DefaultBodyTemplate: `{{ range .Alerts }}{{ template "signoz.relatedLinks.slack" . }}{{ end }}`,
},
wantTitle: "HighLatency",
wantBody: []string{"<https://signoz.example/traces?q=2|View Related Traces>"},
wantIsDefaultBody: true,
},
{
// $environment is not a known AlertData or NotificationTemplateData field,
// so it lands in MissingVars and renders as "<no value>" in the output.

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/contextlinks"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
@@ -304,14 +305,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
link := r.ExternalURL(explorerPath, 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})
annotations = append(annotations, ruletypes.Label{Name: alertmanagertypes.AnnotationRelatedTraces, Value: link})
}
case ruletypes.AlertTypeLogs:
params := r.prepareParamsForLogs(ctx, ts, smpl.Metric)
if len(params) > 0 {
link := r.ExternalURL("logs/logs-explorer", params)
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
annotations = append(annotations, ruletypes.Label{Name: alertmanagertypes.AnnotationRelatedLogs, Value: link})
}
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -906,7 +907,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
for name, value := range item.Annotations.Map() {
if name == ruletypes.AnnotationRelatedTraces {
if name == alertmanagertypes.AnnotationRelatedTraces {
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, value, "GET")
}
@@ -1006,7 +1007,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
link := item.Annotations.Map()[ruletypes.AnnotationRelatedTraces]
link := item.Annotations.Map()[alertmanagertypes.AnnotationRelatedTraces]
assert.True(t, strings.HasPrefix(link, "http://localhost:8080/ai-observability/explorer?"), "case %d: %s", idx, link)
assert.Contains(t, link, "builder_ai_query", "case %d", idx)
assert.Contains(t, link, "llm-gateway", "case %d", idx)
@@ -1122,7 +1123,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
for name, value := range item.Annotations.Map() {
if name == ruletypes.AnnotationRelatedLogs {
if name == alertmanagertypes.AnnotationRelatedLogs {
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, value, "testcontainer")
}

View File

@@ -267,6 +267,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddGCPConnectionMethodFactory(),
sqlmigration.NewDeleteOrphanSARolesFactory(),
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
sqlmigration.NewMigrateRelatedLinksTemplateFactory(),
)
}

View File

@@ -0,0 +1,535 @@
package sqlmigration
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"slices"
"strings"
"text/template/parse"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
const (
relatedLinksSlackHeader = "*RelatedLogs:*"
)
var (
relatedLinksActionPattern = regexp.MustCompile(`(?s)\{\{-?(.*?)-?\}\}`)
relatedLogsReferencePattern = regexp.MustCompile(`\.Annotations\.related_logs(?:[^A-Za-z0-9_]|$)`)
relatedTracesReferencePattern = regexp.MustCompile(`\.Annotations\.related_traces(?:[^A-Za-z0-9_]|$)`)
relatedLinksSlackKind = relatedLinksChannelKind{
configsKey: "slack_configs",
field: "text",
replacement: `{{ template "signoz.relatedLinks.slack" . }}`,
defaults: []string{
"*RelatedLogs:* {{ .Annotations.related_logs }}\n *RelatedTraces:* {{ .Annotations.related_traces }}",
"*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}",
},
}
relatedLinksMSTeamsKind = relatedLinksChannelKind{
configsKey: "msteamsv2_configs",
field: "text",
replacement: relatedLinksSlackKind.replacement,
defaults: relatedLinksSlackKind.defaults,
}
relatedLinksIncidentIOKind = relatedLinksChannelKind{
configsKey: "incidentio_configs",
field: "description",
replacement: `{{ template "signoz.relatedLinks.markdown" . }}`,
defaults: []string{
"{{ 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 }}",
},
}
relatedLinksJSMOpsKind = relatedLinksChannelKind{
configsKey: "jsmops_configs",
field: "description",
replacement: relatedLinksIncidentIOKind.replacement,
defaults: relatedLinksIncidentIOKind.defaults,
}
relatedLinksTelegramKind = relatedLinksChannelKind{
configsKey: "telegram_configs",
field: "message",
replacement: `{{ template "signoz.relatedLinks.html" . }}`,
defaults: []string{
"{{ if .Annotations.related_logs }}<a href=\"{{ .Annotations.related_logs | html }}\">View related logs</a>\n{{ end }}{{ if .Annotations.related_traces }}<a href=\"{{ .Annotations.related_traces | html }}\">View related traces</a>\n{{ end }}",
},
}
relatedLinksKindsByChannelType = map[string]relatedLinksChannelKind{
"slack": relatedLinksSlackKind,
"msteams": relatedLinksMSTeamsKind,
"incidentio": relatedLinksIncidentIOKind,
"jsmops": relatedLinksJSMOpsKind,
"telegram": relatedLinksTelegramKind,
}
errRelatedLinksCustomised = errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "related links section differs from the shipped defaults")
errRelatedLinksUnrecognised = errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "related links section not in the expected shape")
)
type migrateRelatedLinksTemplate struct {
logger *slog.Logger
}
func NewMigrateRelatedLinksTemplateFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_related_links_template"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRelatedLinksTemplate{logger: ps.Logger}, nil
},
)
}
func (migration *migrateRelatedLinksTemplate) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *migrateRelatedLinksTemplate) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := migration.migrateConfigs(ctx, tx); err != nil {
return err
}
if err := migration.migrateChannels(ctx, tx); err != nil {
return err
}
return tx.Commit()
}
func (migration *migrateRelatedLinksTemplate) Down(context.Context, *bun.DB) error {
return nil
}
func (migration *migrateRelatedLinksTemplate) migrateConfigs(ctx context.Context, tx bun.Tx) error {
rows := make([]*relatedLinksConfigRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
cfg := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable config", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
receiversRaw, ok := cfg["receivers"]
if !ok || string(receiversRaw) == "null" {
continue
}
receivers := make([]map[string]json.RawMessage, 0)
if err := json.Unmarshal(receiversRaw, &receivers); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable receivers", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
changed := false
for _, receiver := range receivers {
var receiverName string
_ = json.Unmarshal(receiver["name"], &receiverName)
for _, kind := range []relatedLinksChannelKind{relatedLinksSlackKind, relatedLinksMSTeamsKind, relatedLinksIncidentIOKind, relatedLinksJSMOpsKind, relatedLinksTelegramKind} {
rewritten, err := rewriteRelatedLinksInReceiver(receiver, kind)
if err != nil {
migration.logger.WarnContext(ctx, "leaving receiver related links template as is", slog.String("config_id", row.ID), slog.String("receiver", receiverName), slog.String("configs_key", kind.configsKey), errors.Attr(err))
}
changed = changed || rewritten
}
}
if !changed {
continue
}
newReceivers, err := json.Marshal(receivers)
if err != nil {
return err
}
cfg["receivers"] = newReceivers
newConfig, err := json.Marshal(cfg)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*relatedLinksConfigRow)(nil)).
Set("config = ?", string(newConfig)).
Set("hash = ?", fmt.Sprintf("%x", md5.Sum(newConfig))).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return nil
}
func (migration *migrateRelatedLinksTemplate) migrateChannels(ctx context.Context, tx bun.Tx) error {
channelTypes := make([]string, 0, len(relatedLinksKindsByChannelType))
for channelType := range relatedLinksKindsByChannelType {
channelTypes = append(channelTypes, channelType)
}
rows := make([]*relatedLinksChannelRow, 0)
if err := tx.NewSelect().Model(&rows).Where("type IN (?)", bun.In(channelTypes)).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
kind := relatedLinksKindsByChannelType[row.Type]
query := tx.NewUpdate().Model((*relatedLinksChannelRow)(nil)).Where("id = ?", row.ID)
changed := false
receiver := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Data), &receiver); err != nil {
migration.logger.WarnContext(ctx, "skipping notification channel with unreadable data", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
continue
}
dataChanged, err := rewriteRelatedLinksInReceiver(receiver, kind)
if err != nil {
migration.logger.WarnContext(ctx, "leaving notification channel data template as is", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
}
if dataChanged {
newData, err := json.Marshal(receiver)
if err != nil {
return err
}
query = query.Set("data = ?", string(newData))
changed = true
}
if row.Spec != "" {
spec := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Spec), &spec); err != nil {
migration.logger.WarnContext(ctx, "skipping notification channel spec with unreadable json", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
} else {
specChanged, err := rewriteRelatedLinksInField(spec, kind)
if err != nil {
migration.logger.WarnContext(ctx, "leaving notification channel spec template as is", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
}
if specChanged {
newSpec, err := json.Marshal(spec)
if err != nil {
return err
}
query = query.Set("spec = ?", string(newSpec))
changed = true
}
}
}
if !changed {
continue
}
if _, err := query.Exec(ctx); err != nil {
return err
}
}
return nil
}
type relatedLinksChannelKind struct {
configsKey string
field string
replacement string
defaults []string
}
type relatedLinksConfigRow struct {
bun.BaseModel `bun:"table:alertmanager_config"`
ID string `bun:"id"`
Config string `bun:"config"`
}
type relatedLinksChannelRow struct {
bun.BaseModel `bun:"table:notification_channel"`
ID string `bun:"id"`
OrgID string `bun:"org_id"`
Type string `bun:"type"`
Data string `bun:"data"`
Spec string `bun:"spec,nullzero"`
}
// relatedLinksChunk is one text run or one {{...}} action of a template, with
// its byte span in the source and the action's trimmed inner text.
type relatedLinksChunk struct {
start int
end int
action bool
inner string
}
func rewriteRelatedLinksInReceiver(receiver map[string]json.RawMessage, kind relatedLinksChannelKind) (bool, error) {
configsRaw, ok := receiver[kind.configsKey]
if !ok || string(configsRaw) == "null" {
return false, nil
}
configs := make([]map[string]json.RawMessage, 0)
if err := json.Unmarshal(configsRaw, &configs); err != nil {
return false, err
}
changed := false
var firstErr error
for _, config := range configs {
fieldChanged, err := rewriteRelatedLinksInField(config, kind)
if err != nil && firstErr == nil {
firstErr = err
}
changed = changed || fieldChanged
}
if !changed {
return false, firstErr
}
newConfigs, err := json.Marshal(configs)
if err != nil {
return false, err
}
receiver[kind.configsKey] = newConfigs
return true, firstErr
}
func rewriteRelatedLinksInField(object map[string]json.RawMessage, kind relatedLinksChannelKind) (bool, error) {
fieldRaw, ok := object[kind.field]
if !ok || string(fieldRaw) == "null" {
return false, nil
}
var tmpl string
if err := json.Unmarshal(fieldRaw, &tmpl); err != nil {
return false, err
}
rewritten, changed, err := rewriteRelatedLinks(tmpl, kind)
if err != nil || !changed {
return false, err
}
newField, err := json.Marshal(rewritten)
if err != nil {
return false, err
}
object[kind.field] = newField
return true, nil
}
// rewriteRelatedLinks swaps every related links section of tmpl for the kind's
// block call. It returns the input unchanged when tmpl never references the
// related links annotations, and an error when it does but a section was
// customised, is not in the expected shape or does not parse.
func rewriteRelatedLinks(tmpl string, kind relatedLinksChannelKind) (string, bool, error) {
shipped := make([]string, 0, len(kind.defaults))
for _, snippet := range kind.defaults {
normalized, err := normalizeRelatedLinksTemplate(snippet)
if err != nil {
return tmpl, false, err
}
shipped = append(shipped, normalized)
}
rewritten := tmpl
changed := false
for {
chunks := lexRelatedLinksChunks(rewritten)
start, end, found := findRelatedLinksSection(rewritten, chunks)
if !found {
if !changed && !strings.Contains(rewritten, kind.replacement) && (indexRelatedLinksAction(chunks, 0, relatedLogsReferencePattern) >= 0 || indexRelatedLinksAction(chunks, 0, relatedTracesReferencePattern) >= 0) {
return tmpl, false, errRelatedLinksUnrecognised
}
break
}
section, err := normalizeRelatedLinksTemplate(rewritten[start:end])
if err != nil {
return tmpl, false, err
}
if !slices.Contains(shipped, section) {
return tmpl, false, errRelatedLinksCustomised
}
rewritten = rewritten[:start] + kind.replacement + rewritten[end:]
changed = true
}
if !changed {
return tmpl, false, nil
}
if _, err := normalizeRelatedLinksTemplate(rewritten); err != nil {
return tmpl, false, err
}
return rewritten, true, nil
}
func lexRelatedLinksChunks(tmpl string) []relatedLinksChunk {
var chunks []relatedLinksChunk
pos := 0
for _, match := range relatedLinksActionPattern.FindAllStringSubmatchIndex(tmpl, -1) {
if match[0] > pos {
chunks = append(chunks, relatedLinksChunk{start: pos, end: match[0]})
}
chunks = append(chunks, relatedLinksChunk{start: match[0], end: match[1], action: true, inner: strings.TrimSpace(tmpl[match[2]:match[3]])})
pos = match[1]
}
if pos < len(tmpl) {
chunks = append(chunks, relatedLinksChunk{start: pos, end: len(tmpl)})
}
return chunks
}
// findRelatedLinksSection spans from the first action naming related_logs (or
// the Slack header text before it) to the end of the first action naming
// related_traces that follows.
func findRelatedLinksSection(tmpl string, chunks []relatedLinksChunk) (int, int, bool) {
logsIdx := indexRelatedLinksAction(chunks, 0, relatedLogsReferencePattern)
if logsIdx < 0 {
return 0, 0, false
}
logsEnd := indexRelatedLinksBlockEnd(chunks, logsIdx)
if logsEnd < 0 {
return 0, 0, false
}
tracesIdx := indexRelatedLinksAction(chunks, logsEnd+1, relatedTracesReferencePattern)
if tracesIdx < 0 {
return 0, 0, false
}
tracesEnd := indexRelatedLinksBlockEnd(chunks, tracesIdx)
if tracesEnd < 0 {
return 0, 0, false
}
start := chunks[logsIdx].start
if logsIdx > 0 && !chunks[logsIdx-1].action {
previous := tmpl[chunks[logsIdx-1].start:chunks[logsIdx-1].end]
if header := strings.LastIndex(previous, relatedLinksSlackHeader); header >= 0 {
start = chunks[logsIdx-1].start + header
}
}
return start, chunks[tracesEnd].end, true
}
// indexRelatedLinksAction skips comments so a mention inside {{/* */}} never
// anchors a section.
func indexRelatedLinksAction(chunks []relatedLinksChunk, from int, reference *regexp.Regexp) int {
for i := from; i < len(chunks); i++ {
if chunks[i].action && !strings.HasPrefix(chunks[i].inner, "/*") && reference.MatchString(chunks[i].inner) {
return i
}
}
return -1
}
// indexRelatedLinksBlockEnd returns the chunk closing the block opened at idx,
// or idx itself for a bare action.
func indexRelatedLinksBlockEnd(chunks []relatedLinksChunk, idx int) int {
if !opensRelatedLinksBlock(chunks[idx].inner) {
return idx
}
depth := 0
for i := idx; i < len(chunks); i++ {
if !chunks[i].action {
continue
}
if opensRelatedLinksBlock(chunks[i].inner) {
depth++
continue
}
if chunks[i].inner == "end" {
depth--
if depth == 0 {
return i
}
}
}
return -1
}
func opensRelatedLinksBlock(inner string) bool {
for _, keyword := range []string{"if ", "range ", "with ", "define ", "block "} {
if strings.HasPrefix(inner, keyword) {
return true
}
}
return false
}
// normalizeRelatedLinksTemplate re-emits a parsed template with action spacing
// canonicalised and text runs collapsed to single spaces, so two snippets that
// differ only in whitespace compare equal.
func normalizeRelatedLinksTemplate(tmpl string) (string, error) {
tree := parse.New("related_links")
tree.Mode = parse.SkipFuncCheck
parsed, err := tree.Parse(tmpl, "{{", "}}", map[string]*parse.Tree{})
if err != nil {
return "", err
}
var builder strings.Builder
writeNormalizedRelatedLinksNode(&builder, parsed.Root)
return builder.String(), nil
}
func writeNormalizedRelatedLinksNode(builder *strings.Builder, node parse.Node) {
switch node := node.(type) {
case *parse.ListNode:
for _, child := range node.Nodes {
writeNormalizedRelatedLinksNode(builder, child)
}
case *parse.TextNode:
builder.WriteString(strings.Join(strings.Fields(string(node.Text)), " "))
builder.WriteString("|")
case *parse.IfNode:
builder.WriteString("{{if ")
builder.WriteString(node.Pipe.String())
builder.WriteString("}}")
writeNormalizedRelatedLinksNode(builder, node.List)
if node.ElseList != nil {
builder.WriteString("{{else}}")
writeNormalizedRelatedLinksNode(builder, node.ElseList)
}
builder.WriteString("{{end}}")
default:
builder.WriteString(node.String())
}
}

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

@@ -93,11 +93,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

@@ -22,9 +22,7 @@ const (
{{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary | html }}
{{ end }}{{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description | html }}
{{ end }}{{ if .GeneratorURL }}<a href="{{ .GeneratorURL | html }}">View in SigNoz</a>
{{ end }}{{ if .Annotations.related_logs }}<a href="{{ .Annotations.related_logs | html }}">View related logs</a>
{{ end }}{{ if .Annotations.related_traces }}<a href="{{ .Annotations.related_traces | html }}">View related traces</a>
{{ end }}
{{ end }}{{ template "signoz.relatedLinks.html" . }}
{{ end }}`
)

View File

@@ -100,9 +100,9 @@ func TestDefaultTelegramTemplatesEscapeAlertContent(t *testing.T) {
assert.NotContains(t, rendered, "[FIRING:1]", "the title is sent separately")
assert.Contains(t, rendered, "<b>Summary:</b> latency &gt; 2s")
assert.Contains(t, rendered, `<a href="http://localhost:8080/alerts?a=1&amp;b=2">View in SigNoz</a>`)
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&amp;b=2">View related logs</a>`)
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&amp;b=2">View Related Logs</a>`)
assert.NotContains(t, rendered, "&amp;lt;", "double-escaped")
assert.NotContains(t, rendered, "View related traces")
assert.NotContains(t, rendered, "View Related Traces")
})
}
}

View File

@@ -0,0 +1,51 @@
package alertmanagertypes
import (
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/common/model"
)
const (
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
)
const (
relatedLogsLabel = "View Related Logs"
relatedTracesLabel = "View Related Traces"
)
type RelatedLink struct {
Label string
URL string
}
func BuildRelatedLinks(annotations model.LabelSet) []RelatedLink {
return newRelatedLinks(string(annotations[AnnotationRelatedLogs]), string(annotations[AnnotationRelatedTraces]))
}
// resolveRelatedLinksForTemplate backs the relatedLinks template func: channel templates pass template.Alert, rule templates pass the preprocessed map.
func resolveRelatedLinksForTemplate(alert any) []RelatedLink {
switch a := alert.(type) {
case template.Alert:
return newRelatedLinks(a.Annotations[AnnotationRelatedLogs], a.Annotations[AnnotationRelatedTraces])
case map[string]any:
annotations, _ := a["annotations"].(template.KV)
return newRelatedLinks(annotations[AnnotationRelatedLogs], annotations[AnnotationRelatedTraces])
}
return nil
}
func newRelatedLinks(logsURL, tracesURL string) []RelatedLink {
var links []RelatedLink
if logsURL != "" {
links = append(links, RelatedLink{Label: relatedLogsLabel, URL: logsURL})
}
if tracesURL != "" {
links = append(links, RelatedLink{Label: relatedTracesLabel, URL: tracesURL})
}
return links
}

View File

@@ -0,0 +1,100 @@
package alertmanagertypes
import (
"testing"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
)
func TestBuildRelatedLinks(t *testing.T) {
testCases := []struct {
name string
annotations model.LabelSet
expected []RelatedLink
}{
{
name: "Both_LogsFirst",
annotations: model.LabelSet{"related_traces": "https://signoz.io/traces-explorer?a=1", "related_logs": "https://signoz.io/logs/logs-explorer?a=1"},
expected: []RelatedLink{
{Label: "View Related Logs", URL: "https://signoz.io/logs/logs-explorer?a=1"},
{Label: "View Related Traces", URL: "https://signoz.io/traces-explorer?a=1"},
},
},
{
name: "LogsOnly",
annotations: model.LabelSet{"related_logs": "https://signoz.io/logs/logs-explorer?b=2", "summary": "s"},
expected: []RelatedLink{{Label: "View Related Logs", URL: "https://signoz.io/logs/logs-explorer?b=2"}},
},
{
name: "TracesOnly",
annotations: model.LabelSet{"related_traces": "https://signoz.io/traces-explorer?c=3"},
expected: []RelatedLink{{Label: "View Related Traces", URL: "https://signoz.io/traces-explorer?c=3"}},
},
{
name: "EmptyValues_NoLinks",
annotations: model.LabelSet{"related_logs": "", "related_traces": ""},
expected: nil,
},
{
name: "Nil_NoLinks",
annotations: nil,
expected: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, BuildRelatedLinks(testCase.annotations))
})
}
}
func TestResolveRelatedLinksForTemplate(t *testing.T) {
testCases := []struct {
name string
alert any
expected []RelatedLink
}{
{
name: "ChannelTemplate_Both_LogsFirst",
alert: template.Alert{Annotations: template.KV{"related_traces": "https://signoz.io/traces-explorer?d=4", "related_logs": "https://signoz.io/logs/logs-explorer?d=4"}},
expected: []RelatedLink{
{Label: "View Related Logs", URL: "https://signoz.io/logs/logs-explorer?d=4"},
{Label: "View Related Traces", URL: "https://signoz.io/traces-explorer?d=4"},
},
},
{
name: "ChannelTemplate_NoAnnotations_NoLinks",
alert: template.Alert{Labels: template.KV{"alertname": "a"}},
expected: nil,
},
{
name: "RuleTemplate_LogsOnly",
alert: map[string]any{"annotations": template.KV{"related_logs": "https://signoz.io/logs/logs-explorer?f=6"}, "labels": template.KV{"alertname": "a"}},
expected: []RelatedLink{{Label: "View Related Logs", URL: "https://signoz.io/logs/logs-explorer?f=6"}},
},
{
name: "RuleTemplate_NoAnnotations_NoLinks",
alert: map[string]any{"labels": template.KV{"alertname": "a"}},
expected: nil,
},
{
name: "Nil_NoLinks",
alert: nil,
expected: nil,
},
{
name: "UnsupportedShape_NoLinks",
alert: template.KV{"related_logs": "https://signoz.io/logs/logs-explorer?k=11"},
expected: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, resolveRelatedLinksForTemplate(testCase.alert))
})
}
}

View File

@@ -19,6 +19,7 @@ func AdditionalFuncMap() tmpltext.FuncMap {
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
"relatedLinks": resolveRelatedLinksForTemplate,
}
}
@@ -45,6 +46,12 @@ 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" }}{{ range $i, $link := relatedLinks . }}{{ if $i }} · {{ end }}<{{ $link.URL }}|{{ $link.Label }}>{{ end }}{{ end }}
{{ define "signoz.relatedLinks.markdown" }}{{ range relatedLinks . }}[{{ .Label }}]({{ .URL }})
{{ end }}{{ end }}
{{ define "signoz.relatedLinks.html" }}{{ range relatedLinks . }}<a href="{{ .URL | html }}">{{ .Label | html }}</a>
{{ end }}{{ end }}
`))); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error parsing alertmanager templates")
}

View File

@@ -201,3 +201,116 @@ func TestFromGlobs(t *testing.T) {
})
}
}
func TestFromGlobsRelatedLinks(t *testing.T) {
tmpl, err := FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL = &url.URL{Scheme: "http", Host: "localhost:8080"}
testCases := []struct {
name string
template string
annotations model.LabelSet
expected string
}{
{
name: "Slack_Both_DotSeparated",
template: `{{ template "signoz.relatedLinks.slack" . }}`,
annotations: model.LabelSet{"related_logs": "https://signoz.io/logs/logs-explorer?a=1", "related_traces": "https://signoz.io/traces-explorer?a=1"},
expected: "<https://signoz.io/logs/logs-explorer?a=1|View Related Logs> · <https://signoz.io/traces-explorer?a=1|View Related Traces>",
},
{
name: "Slack_LogsOnly_NoSeparator",
template: `{{ template "signoz.relatedLinks.slack" . }}`,
annotations: model.LabelSet{"related_logs": "https://signoz.io/logs/logs-explorer?b=2"},
expected: "<https://signoz.io/logs/logs-explorer?b=2|View Related Logs>",
},
{
name: "Slack_NoLinks_Empty",
template: `{{ template "signoz.relatedLinks.slack" . }}`,
annotations: model.LabelSet{"summary": "s"},
expected: "",
},
{
name: "Markdown_Both_BlankLineAfterEach",
template: `{{ template "signoz.relatedLinks.markdown" . }}`,
annotations: model.LabelSet{"related_logs": "https://signoz.io/logs/logs-explorer?c=3", "related_traces": "https://signoz.io/traces-explorer?c=3"},
expected: "[View Related Logs](https://signoz.io/logs/logs-explorer?c=3)\n\n[View Related Traces](https://signoz.io/traces-explorer?c=3)\n\n",
},
{
name: "Markdown_TracesOnly",
template: `{{ template "signoz.relatedLinks.markdown" . }}`,
annotations: model.LabelSet{"related_traces": "https://signoz.io/traces-explorer?d=4"},
expected: "[View Related Traces](https://signoz.io/traces-explorer?d=4)\n\n",
},
{
name: "Markdown_NoLinks_Empty",
template: `{{ template "signoz.relatedLinks.markdown" . }}`,
annotations: model.LabelSet{},
expected: "",
},
{
name: "Function_DirectRange",
template: `{{ range relatedLinks . }}{{ .Label }}={{ .URL }};{{ end }}`,
annotations: model.LabelSet{"related_logs": "https://signoz.io/logs/logs-explorer?e=5", "related_traces": "https://signoz.io/traces-explorer?e=5"},
expected: "View Related Logs=https://signoz.io/logs/logs-explorer?e=5;View Related Traces=https://signoz.io/traces-explorer?e=5;",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
alert := &types.Alert{Alert: model.Alert{Labels: model.LabelSet{"alertname": "a"}, Annotations: testCase.annotations}, UpdatedAt: time.Now()}
data := tmpl.Data("__receiver", model.LabelSet{}, alert)
got, err := tmpl.ExecuteTextString(`{{ range .Alerts }}`+testCase.template+`{{ end }}`, data)
require.NoError(t, err)
assert.Equal(t, testCase.expected, got)
})
}
}
func TestFromGlobsRelatedLinksHTML(t *testing.T) {
tmpl, err := FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL = &url.URL{Scheme: "http", Host: "localhost:8080"}
testCases := []struct {
name string
annotations model.LabelSet
expected string
}{
{
name: "Both_EscapedHref_LineEach",
annotations: model.LabelSet{"related_logs": "http://localhost:8080/logs?a=1&b=2", "related_traces": "http://localhost:8080/traces?a=1&b=2"},
expected: "<a href=\"http://localhost:8080/logs?a=1&amp;b=2\">View Related Logs</a>\n<a href=\"http://localhost:8080/traces?a=1&amp;b=2\">View Related Traces</a>\n",
},
{
name: "LogsOnly",
annotations: model.LabelSet{"related_logs": "http://localhost:8080/logs?c=3"},
expected: "<a href=\"http://localhost:8080/logs?c=3\">View Related Logs</a>\n",
},
{
name: "NoLinks_Empty",
annotations: model.LabelSet{"summary": "s"},
expected: "",
},
}
renderers := map[string]func(string, any) (string, error){
"Text": tmpl.ExecuteTextString,
"HTML": tmpl.ExecuteHTMLString,
}
for _, testCase := range testCases {
for engine, render := range renderers {
t.Run(testCase.name+"_"+engine, func(t *testing.T) {
alert := &types.Alert{Alert: model.Alert{Labels: model.LabelSet{"alertname": "a"}, Annotations: testCase.annotations}, UpdatedAt: time.Now()}
data := tmpl.Data("__receiver", model.LabelSet{}, alert)
got, err := render(`{{ range .Alerts }}{{ template "signoz.relatedLinks.html" . }}{{ end }}`, data)
require.NoError(t, err)
assert.Equal(t, testCase.expected, got)
})
}
}
}

View File

@@ -32,8 +32,6 @@ const (
const (
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"

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 }}',
}
],
}
@@ -161,7 +161,7 @@ email_default_config = {
"email_configs": [
{
"to": "test@example.com",
"html": '<html><body>{{ 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 }}</body></html>',
"html": '<html><body>{{ 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 }}</body></html>',
"headers": {
"Subject": '[{{ .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 }}'
},

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 }}'
),
},
[],

View File

@@ -66,7 +66,7 @@ INCIDENTIO_CASES = [
"count": 1,
"json_body": incidentio_event_subset(
"threshold_below_at_least_once",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View Related Logs", r"/logs/logs-explorer\?")],
),
},
),

View File

@@ -73,7 +73,7 @@ JSMOPS_CASES = [
"count": 1,
"json_body": jsmops_alert_subset(
"threshold_below_at_least_once",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View Related Logs", r"/logs/logs-explorer\?")],
),
},
),

View File

@@ -73,7 +73,7 @@ TELEGRAM_CASES = [
"count": 1,
"json_body": telegram_message_subset(
"threshold_below_at_least_once",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View Related Logs", r"/logs/logs-explorer\?")],
),
},
),
@@ -96,7 +96,7 @@ TELEGRAM_CASES = [
"count": 1,
"json_body": telegram_message_subset(
"threshold_above_average",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related traces", r"traces-explorer\?")],
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View Related Traces", r"traces-explorer\?")],
),
},
),