Compare commits

...

9 Commits

Author SHA1 Message Date
praneeth-signoz
a3371d39a1 Merge branch 'main' of github.com:SigNoz/signoz into feat/alert-template-variables 2026-11-13 18:24:24 +05:30
Praneeth Lingam
963bbefc85 fix(alertmanager): make time functions work in custom templates
mapstructure turned time.Time into empty maps, so date, tz and since failed on $alert.starts_at.
buildDataMap now uses toTemplateMap, a tag-driven converter that keeps times; output is unchanged.
Adds now (time.Now) to the shared function map, so channel templates get it too.
Adds engine tests for every function in the Functions API list.
A bare $alert.starts_at now prints the time, and {{ if $alert.ends_at }} is always true.

Assisted-by: Claude Opus 5.5
2026-11-12 14:56:24 +05:30
Praneeth Lingam
10066cb8d6 feat(alertmanager): add $exception.url for exceptions alerts
The link filters the exceptions page by serviceName and exceptionType from the alert labels.
compositeQuery is not used: the page ignores filter.expression and SQL rules have no filter.

Assisted-by: Claude Opus 5.5
2026-11-12 12:11:57 +05:30
Praneeth Lingam
fe86b433be feat(alertmanager): add $alert.is_test for test notifications
Read from the testalert label the rule manager sets on test sends, so no ruler change.
The title's is_test is true when any alert in the group is a test.

Assisted-by: Claude Opus 5.5
2026-11-11 19:20:45 +05:30
Praneeth Lingam
1725589261 feat(alertmanager): fill rule type and match type in templates
match_type becomes private _match_type; nothing wrote it, so no template could depend on it.
The rule type is written even for no-data alerts, since it describes the rule, not the value.

Assisted-by: Claude Opus 5.5
2026-11-11 19:02:25 +05:30
Praneeth Lingam
3c0375c1cb feat(alertmanager): fill threshold op and recovery value in templates
The ruler writes the operator as a word (above, not >) and the recovery target in its unit.
compare_op becomes private _compare_op; nothing wrote it, so no template could depend on it.

Assisted-by: Claude Opus 5.5
2026-11-11 18:44:45 +05:30
Praneeth Lingam
b1a7136a24 feat(alertmanager): fill threshold and add $threshold shortcut
The ruler writes the formatted threshold as the private _threshold annotation.
Bare $threshold now shadows a label named threshold; $labels.threshold still works.

Assisted-by: Claude Opus 5.5
2026-11-11 17:00:37 +05:30
Praneeth Lingam
bd21c2f5c1 feat(alertmanager): add $value shortcut for alert value
Backed by a root field so it resolves even though the value annotation
is private, and wins over a label named value.

Assisted-by: Claude Opus 5.5
2026-11-11 14:19:02 +05:30
Praneeth Lingam
38328ab43a feat(alertmanager): fill alert value and recovering state in templates
The rule manager sends the value and recovering flag as private
annotations; as labels they would change the alert fingerprint.
$alert.status gains recovering, the is_* flags are mutually exclusive,
and total_recovering keeps the counts complete.

Assisted-by: Claude Opus 5.5
2026-11-10 21:31:35 +05:30
22 changed files with 726 additions and 65 deletions

View File

@@ -275,7 +275,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.ExpandAnnotations(expand)
annotations := r.AlertAnnotations(expand, smpl, value, threshold)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -129,10 +129,97 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
assert.Equal(t, c.expectAlerts, alertsFound)
for _, item := range rule.Active {
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue))
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue))
}
})
}
}
func TestAnomalyRule_ValueAndThresholdAnnotations(t *testing.T) {
baseTime := time.Unix(1700000000, 0)
evalTime := baseTime.Add(5 * time.Minute)
target := 2.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test anomaly value"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
},
}},
},
SelectedQuery: "A",
Seasonality: ruletypes.SeasonalityDaily,
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly value",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
Annotations: map[string]string{
"summary": "{{$value}}",
"description": "{{$threshold}}",
},
}
response := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{{
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "checkout"}},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: baseTime.Add(1 * time.Minute).UnixMilli(), Value: 1.5},
{Timestamp: baseTime.Add(2 * time.Minute).UnixMilli(), Value: 3.5},
},
}},
}},
},
},
}
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, instrumentationtest.New().Logger(), mustParseURL(t, "http://localhost:8000"))
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{responses: []*anomaly.AnomaliesResponse{response}}
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
require.Equal(t, 1, alertsFound)
for _, item := range rule.Active {
value := item.Annotations.Get(ruletypes.AnnotationValue)
assert.NotEmpty(t, value)
assert.Equal(t, item.Annotations.Get("summary"), value)
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
assert.NotEmpty(t, threshold)
assert.Equal(t, item.Annotations.Get("description"), threshold)
}
}
func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
// Test missing data alert with AbsentFor grace period
// 1. Call Eval with data at time t1, to populate lastTimestampWithDatapoints

View File

@@ -59,7 +59,7 @@ type Email struct {
// layoutData is the value passed to the email.signoz.html layout
// template. It embeds NotificationTemplateData so templates can reference
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalRecovering`, `.Alert.TotalResolved`,
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
// Title and per-alert Bodies.
type layoutData struct {

View File

@@ -389,7 +389,7 @@ func TestPrepareContent(t *testing.T) {
| Metric | Value |
|--------|-------|
| **Current** | *$value* |
| **Threshold** | *$threshold.value* |
| **Threshold** | *$threshold* |
**Status:** $alert.status | **Severity:** $labels.severity`
titleTemplate := "[$alert.status] $rule.name — $labels.service"
@@ -401,10 +401,10 @@ func TestPrepareContent(t *testing.T) {
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "100",
"threshold.value": "200",
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
ruletypes.AnnotationValue: "100",
ruletypes.AnnotationThresholdValue: "200",
},
},
}
@@ -414,10 +414,10 @@ func TestPrepareContent(t *testing.T) {
StartsAt: time.Now().Add(-2 * time.Hour),
EndsAt: time.Now().Add(-time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "50",
"threshold.value": "200",
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
ruletypes.AnnotationValue: "50",
ruletypes.AnnotationThresholdValue: "200",
},
},
}

View File

@@ -205,16 +205,29 @@ func (at *templater) buildNotificationTemplateData(
alertDataSlice = append(alertDataSlice, ad)
}
// count the number of firing and resolved alerts
var firing, resolved int
// count the number of firing, recovering and resolved alerts
var firing, recovering, resolved int
var isTest bool
for _, ad := range alertDataSlice {
if ad.Alert.IsFiring {
isTest = isTest || ad.Alert.IsTest
switch {
case ad.Alert.IsFiring:
firing++
} else if ad.Alert.IsResolved {
case ad.Alert.IsRecovering:
recovering++
case ad.Alert.IsResolved:
resolved++
}
}
// firing wins over recovering, which wins over resolved
status := string(model.AlertResolved)
if firing > 0 {
status = string(model.AlertFiring)
} else if recovering > 0 {
status = ruletypes.StateRecovering.StringValue()
}
// build the group labels
gl := make(template.KV, len(groupLabels))
for k, v := range groupLabels {
@@ -224,10 +237,12 @@ func (at *templater) buildNotificationTemplateData(
// build the notification template data
return &alertmanagertypes.NotificationTemplateData{
Alert: alertmanagertypes.NotificationAlert{
Receiver: receiver,
Status: string(types.Alerts(alerts...).Status()),
TotalFiring: firing,
TotalResolved: resolved,
Receiver: receiver,
Status: status,
TotalFiring: firing,
TotalRecovering: recovering,
TotalResolved: resolved,
IsTest: isTest,
},
Rule: buildRuleInfo(commonLabels, rawCommonAnnotations),
GroupLabels: gl,
@@ -252,27 +267,39 @@ func buildAlertData(a *types.Alert, receiver string) alertmanagertypes.AlertData
annotations[string(k)] = string(v)
}
// The alertmanager only knows firing and resolved; the rule manager flags a
// firing alert whose value is between the threshold and the recovery
// threshold.
status := string(a.Status())
if a.Status() == model.AlertFiring && annotations[ruletypes.AnnotationIsRecovering] == "true" {
status = ruletypes.StateRecovering.StringValue()
}
return alertmanagertypes.AlertData{
Alert: alertmanagertypes.AlertInfo{
Status: string(a.Status()),
Status: status,
Receiver: receiver,
Value: annotations[ruletypes.AnnotationValue],
StartsAt: a.StartsAt,
EndsAt: a.EndsAt,
GeneratorURL: a.GeneratorURL,
Fingerprint: a.Fingerprint().String(),
IsFiring: a.Status() == model.AlertFiring,
IsResolved: a.Status() == model.AlertResolved,
IsFiring: status == string(model.AlertFiring),
IsResolved: status == string(model.AlertResolved),
IsMissingData: labels[ruletypes.LabelNoData] == "true",
IsRecovering: labels[ruletypes.LabelIsRecovering] == "true",
IsRecovering: status == ruletypes.StateRecovering.StringValue(),
IsTest: labels[ruletypes.LabelTestAlert] == "true",
},
Rule: buildRuleInfo(labels, annotations),
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
Labels: labels,
Rule: buildRuleInfo(labels, annotations),
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
Exception: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedExceptions]},
Labels: labels,
// Strip private annotations once the structured fields above have
// been populated from the raw map.
Annotations: alertmanagertypes.FilterPublicAnnotations(annotations),
Value: annotations[ruletypes.AnnotationValue],
Threshold: annotations[ruletypes.AnnotationThresholdValue],
}
}
@@ -284,10 +311,12 @@ func buildRuleInfo(labels, annotations template.KV) alertmanagertypes.RuleInfo {
ID: labels[ruletypes.LabelRuleID],
URL: labels[ruletypes.LabelRuleSource],
Severity: labels[ruletypes.LabelSeverityName],
Type: annotations[ruletypes.AnnotationRuleType],
MatchType: annotations[ruletypes.AnnotationMatchType],
Threshold: alertmanagertypes.Threshold{
Value: annotations[ruletypes.AnnotationThresholdValue],
Op: annotations[ruletypes.AnnotationCompareOp],
Value: annotations[ruletypes.AnnotationThresholdValue],
Op: annotations[ruletypes.AnnotationCompareOp],
RecoveryValue: annotations[ruletypes.AnnotationRecoveryThreshold],
},
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/notify"
@@ -247,6 +248,178 @@ Description: Request rate exceeded 10k/s`},
wantBody: []string{"Runbook: https://runbook.example.com"},
wantIsDefaultBody: true,
},
{
name: "value annotation feeds alert.value and is hidden from annotations",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors"},
map[string]string{"summary": "errors are high", ruletypes.AnnotationValue: "523"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: "Value: $alert.value, annotations: {{ len $annotations }}",
},
wantTitle: "HighErrors",
wantBody: []string{"Value: 523, annotations: 1"},
},
{
name: "value shortcut resolves bare and in braces and wins over a label named value",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "value": "from-label"},
map[string]string{ruletypes.AnnotationValue: "523"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: "$value | {{ $value }} | {{ if $value }}set{{ end }} | $labels.value",
},
wantTitle: "HighErrors",
wantBody: []string{"523 | 523 | set | from-label"},
},
{
name: "threshold shortcut and rule.threshold.value read the threshold annotation and win over a label named threshold",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "threshold": "from-label"},
map[string]string{ruletypes.AnnotationThresholdValue: "500"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name above $rule.threshold.value",
BodyTemplate: "$threshold | {{ $threshold }} | $rule.threshold.value | $labels.threshold | annotations: {{ len $annotations }}",
},
wantTitle: "HighErrors above 500",
wantBody: []string{"500 | 500 | 500 | from-label | annotations: 0"},
},
{
name: "rule threshold op and recovery value read their private annotations",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors"},
map[string]string{ruletypes.AnnotationThresholdValue: "500", ruletypes.AnnotationCompareOp: "above", ruletypes.AnnotationRecoveryThreshold: "400"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name $rule.threshold.op $rule.threshold.value",
BodyTemplate: "$rule.threshold.op $rule.threshold.value (recovers at $rule.threshold.recovery_value), annotations: {{ len $annotations }}",
},
wantTitle: "HighErrors above 500",
wantBody: []string{"above 500 (recovers at 400), annotations: 0"},
},
{
name: "rule type and match type read their private annotations",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "type": "from-label"},
map[string]string{ruletypes.AnnotationRuleType: "logs", ruletypes.AnnotationMatchType: "on_average"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.type: $rule.name",
BodyTemplate: "$rule.type $rule.match_type | $type | annotations: {{ len $annotations }}",
},
wantTitle: "logs: HighErrors",
wantBody: []string{"logs on_average | from-label | annotations: 0"},
},
{
name: "test label sets is_test on the title and each alert",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", ruletypes.LabelTestAlert: "true"},
nil,
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "{{ if $alert.is_test }}[TEST] {{ end }}$rule.name",
BodyTemplate: "is_test=$alert.is_test",
},
wantTitle: "[TEST] HighErrors",
wantBody: []string{"is_test=true"},
},
{
name: "alert without test label is not a test",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighErrors"}, nil, true),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "{{ if $alert.is_test }}[TEST] {{ end }}$rule.name",
BodyTemplate: "is_test=$alert.is_test",
},
wantTitle: "HighErrors",
wantBody: []string{"is_test=false"},
},
{
name: "exception url reads its private annotation",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "CheckoutExceptions"},
map[string]string{ruletypes.AnnotationRelatedExceptions: "https://signoz.example/exceptions?serviceName=checkout"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: "[View exceptions]($exception.url), annotations: {{ len $annotations }}",
},
wantTitle: "CheckoutExceptions",
wantBody: []string{"[View exceptions](https://signoz.example/exceptions?serviceName=checkout), annotations: 0"},
},
{
name: "recovering annotation sets status, flags and counts with firing winning the title",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "core"},
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
true,
),
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "edge"},
nil,
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$alert.status: $alert.total_firing firing, $alert.total_recovering recovering, $alert.total_resolved resolved",
BodyTemplate: "$labels.team: $alert.status firing=$alert.is_firing recovering=$alert.is_recovering resolved=$alert.is_resolved, annotations: {{ len $annotations }}",
},
wantTitle: "firing: 1 firing, 1 recovering, 0 resolved",
wantBody: []string{
"core: recovering firing=false recovering=true resolved=false, annotations: 0",
"edge: firing firing=true recovering=false resolved=false, annotations: 0",
},
},
{
name: "recovering wins the title over resolved and a resolved alert ignores a stale recovering flag",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "core"},
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
true,
),
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "edge"},
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
false,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$alert.status: $alert.total_firing firing, $alert.total_recovering recovering, $alert.total_resolved resolved",
BodyTemplate: "$labels.team: $alert.status recovering=$alert.is_recovering",
},
wantTitle: "recovering: 0 firing, 1 recovering, 1 resolved",
wantBody: []string{
"core: recovering recovering=true",
"edge: resolved recovering=false",
},
},
{
name: "using non-existing function in template",
alerts: []*types.Alert{
@@ -285,3 +458,61 @@ Description: Request rate exceeded 10k/s`},
})
}
}
func TestExpandTemplateFunctions(t *testing.T) {
at, ctx := testSetup(t)
startsAt := time.Date(2026, time.November, 12, 5, 30, 0, 0, time.UTC)
alert := &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighLatency", ruletypes.LabelSeverityName: "critical", "env": "prod-eu", "host": "web-1.example.com"},
Annotations: model.LabelSet{"summary": " p99 is high "},
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
}}
testCases := []struct {
name string
body string
wantBody string
}{
{name: "ToUpper", body: "{{ $env | toUpper }}", wantBody: "PROD-EU"},
{name: "ToLower", body: "{{ $env | toLower }}", wantBody: "prod-eu"},
{name: "Title", body: "{{ $rule.severity | title }}", wantBody: "Critical"},
{name: "TrimSpace", body: "[{{ $annotations.summary | trimSpace }}]", wantBody: "[p99 is high]"},
{name: "Match", body: `{{ if $env | match "^prod" }}production{{ end }}`, wantBody: "production"},
{name: "ReReplaceAll", body: `{{ $host | reReplaceAll "^([^.]+)\\..*" "$1" }}`, wantBody: "web-1"},
{name: "StringSlice_Join", body: `{{ stringSlice $env $host | join ", " }}`, wantBody: "prod-eu, web-1.example.com"},
{name: "Urlescape", body: `q={{ "a b&c" | urlescape }}`, wantBody: "q=a+b%26c"},
{name: "Date_AlertStartsAt", body: `{{ $alert.starts_at | date "Jan 2, 15:04 MST" }}`, wantBody: "Nov 12, 05:30 UTC"},
{name: "Tz_Date_AlertStartsAt", body: `{{ $alert.starts_at | tz "Asia/Kolkata" | date "15:04 MST" }}`, wantBody: "11:00 IST"},
{name: "Date_AlertEndsAt", body: `{{ $alert.ends_at | date "15:04" }}`, wantBody: "06:30"},
{name: "HumanizeDuration_Seconds", body: "{{ humanizeDuration 3725 }}", wantBody: "1h 2m 5s"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: testCase.body}, []*types.Alert{alert})
require.NoError(t, err)
require.Len(t, result.Body, 1)
assert.Equal(t, testCase.wantBody, result.Body[0])
})
}
t.Run("Now_Tz_Date", func(t *testing.T) {
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: `{{ now | tz "Asia/Kolkata" | date "2006 MST" }}`}, []*types.Alert{alert})
require.NoError(t, err)
require.Len(t, result.Body, 1)
assert.Equal(t, time.Now().In(time.FixedZone("IST", 5*60*60+30*60)).Format("2006 MST"), result.Body[0])
})
t.Run("Since_HumanizeDuration_AlertStartsAt", func(t *testing.T) {
recent := &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighLatency"},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now().Add(time.Hour),
}}
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: "{{ $alert.starts_at | since | humanizeDuration }}"}, []*types.Alert{recent})
require.NoError(t, err)
require.Len(t, result.Body, 1)
assert.Regexp(t, `^1h 0m \d+s$`, result.Body[0])
})
}

View File

@@ -4,10 +4,10 @@ import (
"fmt"
"reflect"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/go-viper/mapstructure/v2"
)
// fieldPath is a dotted mapstructure path into the templating data map,
@@ -131,16 +131,51 @@ func structRootSet(data any) map[string]bool {
return roots
}
// toTemplateMap converts a tagged struct into nested maps keyed by its
// mapstructure tag names, the names templates reference. Unlike
// mapstructure.Decode it keeps time.Time values as is, so date, tz and since
// work on them. Fields that are untagged or tagged "-" are skipped.
func toTemplateMap(val reflect.Value) map[string]any {
typ := val.Type()
result := make(map[string]any, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() {
continue
}
name := strings.Split(field.Tag.Get("mapstructure"), ",")[0]
if name == "" || name == "-" {
continue
}
fv := val.Field(i)
if fv.Kind() == reflect.Pointer {
if fv.IsNil() {
result[name] = nil
continue
}
fv = fv.Elem()
}
if fv.Kind() == reflect.Struct && fv.Type() != reflect.TypeOf(time.Time{}) {
result[name] = toTemplateMap(fv)
continue
}
result[name] = fv.Interface()
}
return result
}
// buildDataMap converts the typed data struct to the map[string]any that the
// template engine indexes into. Each label and annotation is additionally
// exposed at the root under its raw key, so $service.name resolves a flat
// OTel-style label as a single-key index on the root. Struct-path keys
// already present at the root take precedence on collisions.
func buildDataMap(data any) (map[string]any, error) {
var result map[string]any
if err := mapstructure.Decode(data, &result); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template data map")
val := reflect.Indirect(reflect.ValueOf(data))
if val.Kind() != reflect.Struct {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to build template data map: expected a struct, got %T", data)
}
result := toTemplateMap(val)
flatten := func(labels, annotations map[string]string) {
for k, v := range labels {

View File

@@ -229,10 +229,12 @@ func TestPreProcessTemplateAndData(t *testing.T) {
},
expectedData: map[string]any{
"alert": map[string]any{
"receiver": "pagerduty",
"status": "firing",
"total_firing": 3,
"total_resolved": 0,
"receiver": "pagerduty",
"status": "firing",
"total_firing": 3,
"total_recovering": 0,
"total_resolved": 0,
"is_test": false,
},
"severity": "critical",
"http.request.method": "GET",

View File

@@ -1,6 +1,7 @@
package contextlinks
import (
"cmp"
"encoding/json"
"net/url"
"slices"
@@ -26,6 +27,25 @@ func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
}
// PrepareParamsForExceptions returns the exceptions page query params for the
// given range, filtered to the service and exception type found in labels
// (serviceName and exceptionType from SQL, or their builder keys). The page
// writes its time params in milliseconds.
// It doesn't reuse prepareExplorerParams: the exceptions page reads only the
// older filters.items from compositeQuery, not filter.expression.
func PrepareParamsForExceptions(start, end time.Time, labels map[string]string) url.Values {
params := url.Values{}
params.Set("startTime", strconv.FormatInt(start.UnixMilli(), 10))
params.Set("endTime", strconv.FormatInt(end.UnixMilli(), 10))
if serviceName := cmp.Or(labels["serviceName"], labels["service.name"]); serviceName != "" {
params.Set("serviceName", serviceName)
}
if exceptionType := cmp.Or(labels["exceptionType"], labels["exception.type"]); exceptionType != "" {
params.Set("exceptionType", exceptionType)
}
return params
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged

View File

@@ -2,6 +2,7 @@ package contextlinks
import (
"testing"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -222,3 +223,30 @@ func TestPrepareRuleFilterExpression(t *testing.T) {
})
}
}
func TestPrepareParamsForExceptions(t *testing.T) {
start := time.UnixMilli(1705468620000)
end := time.UnixMilli(1705468920000)
testCases := []struct {
name string
labels map[string]string
serviceName string
exceptionType string
}{
{name: "SQLColumns_SetsServiceAndExceptionType", labels: map[string]string{"serviceName": "checkout", "exceptionType": "TimeoutError"}, serviceName: "checkout", exceptionType: "TimeoutError"},
{name: "BuilderKeys_SetsServiceAndExceptionType", labels: map[string]string{"service.name": "payments", "exception.type": "ValueError"}, serviceName: "payments", exceptionType: "ValueError"},
{name: "NoMatchingLabels_SetsTimeOnly", labels: map[string]string{"host": "web-1"}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
params := PrepareParamsForExceptions(start, end, testCase.labels)
assert.Equal(t, "1705468620000", params.Get("startTime"))
assert.Equal(t, "1705468920000", params.Get("endTime"))
assert.Equal(t, testCase.serviceName, params.Get("serviceName"))
assert.Equal(t, testCase.exceptionType, params.Get("exceptionType"))
})
}
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/units"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -257,6 +258,37 @@ func (r *BaseRule) ExpandAnnotations(expand func(string) string) ruletypes.Label
return annotations
}
// AlertAnnotations returns the expanded rule annotations plus the private ones
// the notifier reads into template fields. value and threshold are smpl.V and
// smpl.Target formatted for display.
func (r *BaseRule) AlertAnnotations(expand func(string) string, smpl ruletypes.Sample, value, threshold string) ruletypes.Labels {
annotations := r.ExpandAnnotations(expand)
if !smpl.IsMissing {
annotations = append(annotations,
ruletypes.Label{Name: ruletypes.AnnotationValue, Value: value},
ruletypes.Label{Name: ruletypes.AnnotationThresholdValue, Value: threshold},
)
if op := smpl.CompareOperator.Literal(); op != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationCompareOp, Value: op})
}
if matchType := smpl.MatchType.Literal(); matchType != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationMatchType, Value: matchType})
}
if smpl.RecoveryTarget != nil {
recovery := units.FormatterFromUnit(r.Unit()).Format(*smpl.RecoveryTarget, smpl.TargetUnit)
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRecoveryThreshold, Value: recovery})
}
}
if smpl.IsRecovering {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationIsRecovering, Value: "true"})
}
if signal := r.typ.Signal(); signal != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRuleType, Value: signal})
}
return annotations
}
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
u := r.externalURL.JoinPath(path)
if len(params) > 0 {

View File

@@ -885,3 +885,50 @@ func TestExpandAnnotations(t *testing.T) {
})
}
}
func TestAlertAnnotations(t *testing.T) {
recoveryTarget := 400.0
testCases := []struct {
name string
alertType ruletypes.AlertType
smpl ruletypes.Sample
want map[string]string
}{
{
name: "Firing_AddsValueAndThreshold",
smpl: ruletypes.Sample{},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms"},
},
{
name: "Recovering_AddsValueThresholdAndRecoveringFlag",
smpl: ruletypes.Sample{IsRecovering: true},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationIsRecovering: "true"},
},
{
name: "OperatorAndRecoveryTarget_AddsLiteralOpAndRecoveryThreshold",
smpl: ruletypes.Sample{CompareOperator: ruletypes.ValueIsAboveSymbol, RecoveryTarget: &recoveryTarget, TargetUnit: "ms"},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationCompareOp: "above", ruletypes.AnnotationRecoveryThreshold: "400 ms"},
},
{
name: "MatchTypeAndAlertType_AddsLiteralMatchTypeAndSignal",
alertType: ruletypes.AlertTypeLogs,
smpl: ruletypes.Sample{MatchType: ruletypes.OnAverageShort},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationMatchType: "on_average", ruletypes.AnnotationRuleType: "logs"},
},
{
name: "MissingData_OmitsValueAndThreshold_KeepsRuleType",
alertType: ruletypes.AlertTypeMetric,
smpl: ruletypes.Sample{IsMissing: true, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce, RecoveryTarget: &recoveryTarget},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationRuleType: "metrics"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rule := &BaseRule{typ: testCase.alertType, annotations: ruletypes.FromMap(map[string]string{"summary": "high errors"}), ruleCondition: &ruletypes.RuleCondition{CompositeQuery: &ruletypes.AlertCompositeQuery{Unit: "ms"}}}
expand := func(text string) string { return text }
assert.Equal(t, testCase.want, rule.AlertAnnotations(expand, testCase.smpl, "523 ms", "500 ms").Map())
})
}
}

View File

@@ -175,9 +175,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
r.logger.DebugContext(ctx, "alerting for series", slog.Any("series", result))
value := valueFormatter.Format(result.V, r.Unit())
threshold := valueFormatter.Format(result.Target, result.TargetUnit)
tmplData := ruletypes.AlertTemplateData(l, valueFormatter.Format(result.V, r.Unit()), threshold)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
// who are not used to Go's templating system.
defs := "{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}"
@@ -209,7 +210,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.ExpandAnnotations(expand)
annotations := r.AlertAnnotations(expand, result, value, threshold)
if result.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -938,6 +938,12 @@ func TestPromRuleUnitCombinations(t *testing.T) {
if c.expectAlerts != 0 {
foundCount := 0
for _, item := range rule.Active {
value := item.Annotations.Get(ruletypes.AnnotationValue)
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), value, "case %d", idx)
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
assert.NotEmpty(t, threshold, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), threshold, "case %d", idx)
for _, summary := range c.summaryAny {
if strings.Contains(item.Annotations.Get("summary"), summary) {
foundCount++
@@ -1042,6 +1048,8 @@ func TestPromRuleNoData(t *testing.T) {
for _, item := range rule.Active {
if c.expectNoData {
assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue), "case %d", idx)
} else {
assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
}

View File

@@ -125,6 +125,17 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
}
func (r *ThresholdRule) prepareParamsForExceptions(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
}
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
return contextlinks.PrepareParamsForExceptions(start, end, resultLabels.Map())
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRange(ctx, ts)
if err != nil {
@@ -285,7 +296,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.ExpandAnnotations(expand)
annotations := r.AlertAnnotations(expand, smpl, value, threshold)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
@@ -313,6 +324,13 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
}
case ruletypes.AlertTypeExceptions:
params := r.prepareParamsForExceptions(ctx, ts, smpl.Metric)
if len(params) > 0 {
link := r.ExternalURL("exceptions", params)
r.logger.InfoContext(ctx, "adding exceptions link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedExceptions, Value: link})
}
}
lbs := lb.Labels()

View File

@@ -207,6 +207,60 @@ func TestPrepareParamsForLogs(t *testing.T) {
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForExceptions(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Exceptions In Checkout"),
AlertType: ruletypes.AlertTypeExceptions,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeClickHouseSQL,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "A",
Query: "SELECT count() AS value, toStartOfInterval(timestamp, toIntervalMinute(1)) AS interval, serviceName FROM signoz_traces.distributed_signoz_error_index_v2 WHERE timestamp BETWEEN {{.start_datetime}} AND {{.end_datetime}} GROUP BY serviceName, interval",
},
},
},
},
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{10.0}[0],
SelectedQuery: "A",
},
}
logger := instrumentationtest.New().Logger()
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
},
},
}
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("70", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
require.NoError(t, err)
ts := time.UnixMilli(1705469040000)
params := rule.prepareParamsForExceptions(context.Background(), ts, ruletypes.FromMap(map[string]string{"serviceName": "checkout"}))
assert.Equal(t, "1705468620000", params.Get("startTime"))
assert.Equal(t, "1705468920000", params.Get("endTime"))
assert.Equal(t, "checkout", params.Get("serviceName"))
assert.False(t, params.Has("exceptionType"))
}
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
@@ -678,6 +732,14 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
if c.expectAlerts != 0 {
foundCount := 0
for _, item := range rule.Active {
value := item.Annotations.Get(ruletypes.AnnotationValue)
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), value, "case %d", idx)
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
assert.NotEmpty(t, threshold, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), threshold, "case %d", idx)
assert.Equal(t, c.compareOperator.Literal(), item.Annotations.Get(ruletypes.AnnotationCompareOp), "case %d", idx)
assert.Equal(t, c.matchType.Literal(), item.Annotations.Get(ruletypes.AnnotationMatchType), "case %d", idx)
for _, summary := range c.summaryAny {
if strings.Contains(item.Annotations.Get("summary"), summary) {
foundCount++
@@ -787,6 +849,8 @@ func TestThresholdRuleNoData(t *testing.T) {
for _, item := range rule.Active {
if c.expectNoData {
assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue), "case %d", idx)
} else {
assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
}

View File

@@ -3,6 +3,7 @@ package alertmanagertypes
import (
"bytes"
"net/url"
"time"
tmplhtml "html/template"
tmpltext "text/template"
@@ -19,6 +20,7 @@ func AdditionalFuncMap() tmpltext.FuncMap {
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
"now": time.Now,
}
}

View File

@@ -76,8 +76,14 @@ type AlertData struct {
Rule RuleInfo `json:"rule" mapstructure:"rule"`
Log LinkInfo `json:"log" mapstructure:"log"`
Trace LinkInfo `json:"trace" mapstructure:"trace"`
Exception LinkInfo `json:"exception" mapstructure:"exception"`
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
// Value and Threshold back the $value and $threshold shortcuts for
// $alert.value and $rule.threshold.value.
Value string `json:"value" mapstructure:"value"`
Threshold string `json:"threshold" mapstructure:"threshold"`
}
// AlertInfo holds the per-alert state and timing data.
@@ -93,6 +99,7 @@ type AlertInfo struct {
IsResolved bool `json:"isResolved" mapstructure:"is_resolved"`
IsMissingData bool `json:"isMissingData" mapstructure:"is_missing_data"`
IsRecovering bool `json:"isRecovering" mapstructure:"is_recovering"`
IsTest bool `json:"isTest" mapstructure:"is_test"`
}
// RuleInfo holds the rule metadata extracted from well-known labels and
@@ -102,18 +109,20 @@ type RuleInfo struct {
ID string `json:"id" mapstructure:"id"`
URL string `json:"url" mapstructure:"url"`
Severity string `json:"severity" mapstructure:"severity"`
Type string `json:"type" mapstructure:"type"`
MatchType string `json:"matchType" mapstructure:"match_type"`
Threshold Threshold `json:"threshold" mapstructure:"threshold"`
}
// Threshold describes the breach condition.
type Threshold struct {
Value string `json:"value" mapstructure:"value"`
Op string `json:"op" mapstructure:"op"`
Value string `json:"value" mapstructure:"value"`
Op string `json:"op" mapstructure:"op"`
RecoveryValue string `json:"recoveryValue" mapstructure:"recovery_value"`
}
// LinkInfo groups a single URL so templates can reference $log.url and
// $trace.url uniformly.
// LinkInfo groups a single URL so templates can reference $log.url,
// $trace.url and $exception.url uniformly.
type LinkInfo struct {
URL string `json:"url" mapstructure:"url"`
}
@@ -137,8 +146,10 @@ type NotificationTemplateData struct {
// NotificationAlert holds the aggregate alert fields available to title
// templates (counts, overall status, receiver).
type NotificationAlert struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
TotalFiring int `json:"totalFiring" mapstructure:"total_firing"`
TotalResolved int `json:"totalResolved" mapstructure:"total_resolved"`
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
TotalFiring int `json:"totalFiring" mapstructure:"total_firing"`
TotalRecovering int `json:"totalRecovering" mapstructure:"total_recovering"`
TotalResolved int `json:"totalResolved" mapstructure:"total_resolved"`
IsTest bool `json:"isTest" mapstructure:"is_test"`
}

View File

@@ -41,6 +41,25 @@ func (AlertType) Enum() []any {
}
}
// Signal returns the signal the alert type evaluates (metrics, logs, traces,
// exceptions or ai_traces), or an empty string for an unknown type.
func (t AlertType) Signal() string {
switch t {
case AlertTypeMetric:
return "metrics"
case AlertTypeLogs:
return "logs"
case AlertTypeTraces:
return "traces"
case AlertTypeExceptions:
return "exceptions"
case AlertTypeAITraces:
return "ai_traces"
default:
return ""
}
}
// BuilderQueryType returns the query type the alert type's builder queries
// carry; only AI trace alerts use builder_ai_query.
func (t AlertType) BuilderQueryType() qbtypes.QueryType {

View File

@@ -608,3 +608,24 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
assert.Equal(t, 1, len(vector))
}
func TestAlertTypeSignal(t *testing.T) {
testCases := []struct {
name string
alertType AlertType
want string
}{
{name: "Metric", alertType: AlertTypeMetric, want: "metrics"},
{name: "Logs", alertType: AlertTypeLogs, want: "logs"},
{name: "Traces", alertType: AlertTypeTraces, want: "traces"},
{name: "Exceptions", alertType: AlertTypeExceptions, want: "exceptions"},
{name: "AITraces", alertType: AlertTypeAITraces, want: "ai_traces"},
{name: "Unknown_Empty", alertType: "", want: ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, testCase.alertType.Signal())
})
}
}

View File

@@ -13,29 +13,34 @@ const (
LabelNoData = "nodata"
LabelTestAlert = "testalert"
LabelAlertName = "alertname"
LabelIsRecovering = "is_recovering"
)
// Annotations set by the rule manager and consumed by the alertmanager
// templating layer.
//
// Those prefixed with "_" are private: they're stripped from
// notification-visible surfaces by alertmanagertypes.FilterPublicAnnotations
// before rendering. Only the raw template strings are private — echoing
// them into a notification is circular and never useful.
// Those prefixed with "_" are private: the alertmanager reads them into
// structured template fields, then strips them from notification-visible
// surfaces by alertmanagertypes.FilterPublicAnnotations before rendering.
// The raw template strings, the breached value ($alert.value), the configured
// threshold ($rule.threshold.value), its comparator ($rule.threshold.op), the
// recovery threshold ($rule.threshold.recovery_value), the match type
// ($rule.match_type), the rule's signal ($rule.type), the exceptions page link
// ($exception.url) and the recovering flag ($alert.is_recovering) are private.
//
// The rest are public: they describe the firing alert (the breached value,
// the configured threshold, the comparator, the match type, and deep links
// to relevant logs/traces) and users may reference them directly as
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
// The rest are public: they are deep links to relevant logs/traces and users
// may reference them directly as {{ .Annotations.related_logs }}, etc. in
// their channel templates.
const (
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"
AnnotationMatchType = "match_type"
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationRelatedExceptions = "_related_exceptions"
AnnotationValue = "_value"
AnnotationIsRecovering = "_is_recovering"
AnnotationThresholdValue = "_threshold"
AnnotationRecoveryThreshold = "_recovery_threshold"
AnnotationCompareOp = "_compare_op"
AnnotationMatchType = "_match_type"
AnnotationRuleType = "_rule_type"
)

View File

@@ -62,6 +62,7 @@
<p style="margin:0;font-size:14px;color:#666">
Status: <strong>{{.Alert.Status}}</strong>
{{if .Alert.TotalFiring}} | Firing: <strong style="color:#e53e3e">{{.Alert.TotalFiring}}</strong>{{end}}
{{if .Alert.TotalRecovering}} | Recovering: <strong style="color:#dd6b20">{{.Alert.TotalRecovering}}</strong>{{end}}
{{if .Alert.TotalResolved}} | Resolved: <strong style="color:#38a169">{{.Alert.TotalResolved}}</strong>{{end}}
</p>
</td>