Alerting: Prometheus mode template function

pull/102062/head
Alexander Akhmetov 4 months ago
parent 0961de396e
commit 641e53753e
No known key found for this signature in database
GPG Key ID: A5A8947133B1B31B
  1. 19
      pkg/services/ngalert/prom/convert.go
  2. 19
      pkg/services/ngalert/prom/convert_test.go
  3. 6
      pkg/services/ngalert/prom/query.go
  4. 132
      pkg/services/ngalert/prom/template.go
  5. 180
      pkg/services/ngalert/prom/template_test.go
  6. 67
      pkg/services/ngalert/state/template/template.go
  7. 70
      pkg/services/ngalert/state/template/template_test.go

@ -21,7 +21,9 @@ const (
) )
const ( const (
queryRefID = "query" // QueryRefID is the ID used for the main query in converted Prometheus rules.
// It is exported to be used in template rendering for Prometheus compatibility mode.
QueryRefID = "query"
prometheusMathRefID = "prometheus_math" prometheusMathRefID = "prometheus_math"
thresholdRefID = "threshold" thresholdRefID = "threshold"
) )
@ -198,7 +200,7 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
if isRecordingRule { if isRecordingRule {
record = &models.Record{ record = &models.Record{
From: queryRefID, From: QueryRefID,
Metric: rule.Record, Metric: rule.Record,
TargetDatasourceUID: p.cfg.DatasourceUID, TargetDatasourceUID: p.cfg.DatasourceUID,
} }
@ -220,6 +222,17 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)) labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels))
maps.Copy(labels, promGroup.Labels) maps.Copy(labels, promGroup.Labels)
maps.Copy(labels, rule.Labels) maps.Copy(labels, rule.Labels)
err = convertTemplates(labels)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to convert labels: %w", err)
}
annotations := make(map[string]string, len(rule.Annotations))
maps.Copy(annotations, rule.Annotations)
err = convertTemplates(annotations)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to convert annotations: %w", err)
}
originalRuleDefinition, err := yaml.Marshal(rule) originalRuleDefinition, err := yaml.Marshal(rule)
if err != nil { if err != nil {
@ -234,7 +247,7 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
Condition: query[len(query)-1].RefID, Condition: query[len(query)-1].RefID,
NoDataState: p.cfg.NoDataState, NoDataState: p.cfg.NoDataState,
ExecErrState: p.cfg.ExecErrState, ExecErrState: p.cfg.ExecErrState,
Annotations: rule.Annotations, Annotations: annotations,
Labels: labels, Labels: labels,
For: forInterval, For: forInterval,
RuleGroup: promGroup.Name, RuleGroup: promGroup.Name,

@ -196,7 +196,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
if promRule.Record != "" { if promRule.Record != "" {
require.Equal(t, fmt.Sprintf("[%s] %s", tc.promGroup.Name, promRule.Record), grafanaRule.Title) require.Equal(t, fmt.Sprintf("[%s] %s", tc.promGroup.Name, promRule.Record), grafanaRule.Title)
require.NotNil(t, grafanaRule.Record) require.NotNil(t, grafanaRule.Record)
require.Equal(t, grafanaRule.Record.From, queryRefID) require.Equal(t, grafanaRule.Record.From, QueryRefID)
require.Equal(t, promRule.Record, grafanaRule.Record.Metric) require.Equal(t, promRule.Record, grafanaRule.Record.Metric)
require.Equal(t, tc.config.DatasourceUID, grafanaRule.Record.TargetDatasourceUID) require.Equal(t, tc.config.DatasourceUID, grafanaRule.Record.TargetDatasourceUID)
} else { } else {
@ -209,16 +209,23 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
} }
require.Equal(t, expectedFor, grafanaRule.For, tc.name) require.Equal(t, expectedFor, grafanaRule.For, tc.name)
expectedLabels := make(map[string]string, len(promRule.Labels)+len(tc.promGroup.Labels))
maps.Copy(expectedLabels, tc.promGroup.Labels)
maps.Copy(expectedLabels, promRule.Labels)
uidData := fmt.Sprintf("%d|%s|%s|%d", tc.orgID, tc.namespace, tc.promGroup.Name, j) uidData := fmt.Sprintf("%d|%s|%s|%d", tc.orgID, tc.namespace, tc.promGroup.Name, j)
u := uuid.NewSHA1(uuid.NameSpaceOID, []byte(uidData)) u := uuid.NewSHA1(uuid.NameSpaceOID, []byte(uidData))
require.Equal(t, u.String(), grafanaRule.UID, tc.name) require.Equal(t, u.String(), grafanaRule.UID, tc.name)
expectedLabels := make(map[string]string, len(promRule.Labels)+len(tc.promGroup.Labels))
maps.Copy(expectedLabels, tc.promGroup.Labels)
maps.Copy(expectedLabels, promRule.Labels)
require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name) require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name)
require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name)
var expectedAnnotations map[string]string
if promRule.Annotations == nil {
expectedAnnotations = map[string]string{}
} else {
expectedAnnotations = promRule.Annotations
}
require.Equal(t, expectedAnnotations, grafanaRule.Annotations, tc.name)
require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To) require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To)
require.Equal(t, models.Duration(10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From) require.Equal(t, models.Duration(10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From)

@ -25,7 +25,7 @@ func createQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange,
"expr": expr, "expr": expr,
"instant": true, "instant": true,
"range": false, "range": false,
"refId": queryRefID, "refId": QueryRefID,
} }
if datasourceType == datasources.DS_LOKI { if datasourceType == datasources.DS_LOKI {
@ -40,7 +40,7 @@ func createQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange,
return models.AlertQuery{ return models.AlertQuery{
DatasourceUID: datasourceUID, DatasourceUID: datasourceUID,
Model: modelJSON, Model: modelJSON,
RefID: queryRefID, RefID: QueryRefID,
RelativeTimeRange: models.RelativeTimeRange{ RelativeTimeRange: models.RelativeTimeRange{
From: models.Duration(fromTimeRange + evaluationOffset), From: models.Duration(fromTimeRange + evaluationOffset),
To: models.Duration(0 + evaluationOffset), To: models.Duration(0 + evaluationOffset),
@ -66,7 +66,7 @@ func createMathNode() (models.AlertQuery, error) {
Type: expr.QueryTypeMath, Type: expr.QueryTypeMath,
}, },
MathQuery: expr.MathQuery{ MathQuery: expr.MathQuery{
Expression: fmt.Sprintf("is_number($%[1]s) || is_nan($%[1]s) || is_inf($%[1]s)", queryRefID), Expression: fmt.Sprintf("is_number($%[1]s) || is_nan($%[1]s) || is_inf($%[1]s)", QueryRefID),
}, },
} }

@ -0,0 +1,132 @@
package prom
import (
"reflect"
"strings"
"text/template/parse"
)
const (
PrometheusModeTemplateCall = "{{- _prometheusMode -}}"
)
func convertTemplates(m map[string]string) error {
for k, v := range m {
nv, err := convertTemplate(v)
if err != nil {
return err
}
m[k] = nv
}
return nil
}
// convertTemplate analyzes a template string to determine if it uses Prometheus-style
// value references ($value or .Value). If it does, it adds a _prometheusMode function call
// to the beginning of the template to switch the template rendering to Prometheus compatibility mode.
// This allows templates converted from Prometheus alerting rules to continue working without modification.
func convertTemplate(tmpl string) (string, error) {
searchFor := map[string]struct{}{
"$value": {},
".Value": {},
"$.Value": {},
}
// Create a parser with SkipFuncCheck to avoid errors with unknown functions
p := parse.New("tmpl")
p.Mode = parse.ParseComments | parse.SkipFuncCheck
// Add temporary prefix with the variables that are expected by the template,
// otherwise the parsing fails.
tmpPrefix := `{{$value := ""}}{{$labels := ""}}`
tree, err := p.Parse(tmpPrefix+tmpl, "{{", "}}", map[string]*parse.Tree{})
if err != nil {
return "", err
}
// Search for the variables in the template
found := walkNodes(tree.Root, searchFor)
if found {
return PrometheusModeTemplateCall + tmpl, nil
}
return tmpl, nil
}
// walkNodes recursively traverses the AST to find all variable nodes with certain names
// and returns the boolean indicating whether they are present in the template.
func walkNodes(node parse.Node, searchFor map[string]struct{}) bool {
var found bool
var walk func(node parse.Node)
walk = func(node parse.Node) {
if node == nil || reflect.ValueOf(node).IsNil() {
return
}
switch n := node.(type) {
case *parse.VariableNode:
if _, ok := searchFor[strings.Join(n.Ident, ".")]; ok {
found = true
}
case *parse.FieldNode:
if _, ok := searchFor["."+strings.Join(n.Ident, ".")]; ok {
found = true
}
case *parse.ActionNode:
walk(n.Pipe)
case *parse.ChainNode:
walk(n.Node)
case *parse.CommandNode:
for _, arg := range n.Args {
walk(arg)
}
case *parse.ListNode:
for _, child := range n.Nodes {
walk(child)
}
case *parse.PipeNode:
for _, cmd := range n.Cmds {
walk(cmd)
}
case *parse.IfNode:
walk(n.Pipe)
walk(n.List)
if n.ElseList != nil {
walk(n.ElseList)
}
case *parse.RangeNode:
walk(n.Pipe)
walk(n.List)
if n.ElseList != nil {
walk(n.ElseList)
}
case *parse.WithNode:
walk(n.Pipe)
walk(n.List)
if n.ElseList != nil {
walk(n.ElseList)
}
case *parse.TemplateNode:
if n.Pipe != nil {
walk(n.Pipe)
}
}
}
walk(node)
return found
}

@ -0,0 +1,180 @@
package prom
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestConvertTemplates(t *testing.T) {
testCases := []struct {
name string
templates map[string]string
expected map[string]string
shouldFail bool
}{
{
name: "simple template with $value",
templates: map[string]string{
"template": "Value is {{$value}}",
},
expected: map[string]string{
"template": "{{- _prometheusMode -}}Value is {{$value}}",
},
shouldFail: false,
},
{
name: "simple template with .Value",
templates: map[string]string{
"template": "Value is {{.Value}}",
},
expected: map[string]string{
"template": "{{- _prometheusMode -}}Value is {{.Value}}",
},
shouldFail: false,
},
{
name: "template with $.Value",
templates: map[string]string{
"template": `
{{- range .Items}}
Value: {{.Value}}
Global Value: {{$.Value}}
{{- end}}
`,
},
expected: map[string]string{
"template": `{{- _prometheusMode -}}
{{- range .Items}}
Value: {{.Value}}
Global Value: {{$.Value}}
{{- end}}
`,
},
shouldFail: false,
},
{
name: "complex template with $value in if statement",
templates: map[string]string{
"template": "{{if gt $value 100.0}}Too high{{else}}Good{{end}}",
},
expected: map[string]string{
"template": "{{- _prometheusMode -}}{{if gt $value 100.0}}Too high{{else}}Good{{end}}",
},
shouldFail: false,
},
{
name: "complex template with .Value in if statement",
templates: map[string]string{
"template": "{{if gt .Value 100.0}}Too high{{else}}Good{{end}}",
},
expected: map[string]string{
"template": "{{- _prometheusMode -}}{{if gt .Value 100.0}}Too high{{else}}Good{{end}}",
},
shouldFail: false,
},
{
name: "template with $value in nested structures",
templates: map[string]string{
"template": "{{range .Values}}{{if eq . $value}}Match{{end}}{{end}}",
},
expected: map[string]string{
"template": "{{- _prometheusMode -}}{{range .Values}}{{if eq . $value}}Match{{end}}{{end}}",
},
shouldFail: false,
},
{
name: "template without $value or .Value",
templates: map[string]string{
"template": "This template does not use the values",
},
expected: map[string]string{
"template": "This template does not use the values",
},
shouldFail: false,
},
{
name: "template with $labels",
templates: map[string]string{
"template": "Instance: {{ $labels.instance }}",
},
expected: map[string]string{
"template": "Instance: {{ $labels.instance }}",
},
shouldFail: false,
},
{
name: "template with both $value and $labels",
templates: map[string]string{
"template": "{{ $labels.instance }} has value {{ $value }}",
},
expected: map[string]string{
"template": "{{- _prometheusMode -}}{{ $labels.instance }} has value {{ $value }}",
},
shouldFail: false,
},
{
name: "template with multiple $value occurrences",
templates: map[string]string{
"template": "First: {{ $value }}, Second: {{ $value }}",
},
expected: map[string]string{
"template": "{{- _prometheusMode -}}First: {{ $value }}, Second: {{ $value }}",
},
shouldFail: false,
},
{
name: "multiple templates with mixed patterns",
templates: map[string]string{
"summary": "Instance {{ $labels.instance }} has high CPU usage",
"description": "{{ $labels.instance }} has value {{ $value }}",
},
expected: map[string]string{
"summary": "Instance {{ $labels.instance }} has high CPU usage",
"description": "{{- _prometheusMode -}}{{ $labels.instance }} has value {{ $value }}",
},
shouldFail: false,
},
{
name: "all templates with values",
templates: map[string]string{
"summary": "Value is {{ $value }}",
"description": "Detailed value is {{ .Value }}",
},
expected: map[string]string{
"summary": "{{- _prometheusMode -}}Value is {{ $value }}",
"description": "{{- _prometheusMode -}}Detailed value is {{ .Value }}",
},
shouldFail: false,
},
{
name: "no templates with values",
templates: map[string]string{
"summary": "Instance {{ $labels.instance }} is down",
"description": "Instance {{ $labels.instance }} has been down for more than 5 minutes",
},
expected: map[string]string{
"summary": "Instance {{ $labels.instance }} is down",
"description": "Instance {{ $labels.instance }} has been down for more than 5 minutes",
},
shouldFail: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
input := make(map[string]string)
for k, v := range tc.templates {
input[k] = v
}
err := convertTemplates(input)
if tc.shouldFail {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, input)
}
})
}
}

@ -8,14 +8,16 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"text/template"
"time" "time"
"github.com/prometheus/common/model" "github.com/prometheus/common/model"
"github.com/prometheus/prometheus/model/timestamp" "github.com/prometheus/prometheus/model/timestamp"
"github.com/prometheus/prometheus/promql" "github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/template" promTemplate "github.com/prometheus/prometheus/template"
"github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval"
"github.com/grafana/grafana/pkg/services/ngalert/prom"
) )
type Labels map[string]string type Labels map[string]string
@ -71,16 +73,53 @@ func NewValues(captures map[string]eval.NumberValueCapture) map[string]Value {
} }
type Data struct { type Data struct {
Labels Labels Labels Labels
Values map[string]Value Values map[string]Value
Value string evaluationString string
prometheusMode bool
prometheusQueryValue string
} }
func NewData(labels map[string]string, res eval.Result) Data { func NewData(labels map[string]string, res eval.Result) Data {
values := NewValues(res.Values)
value := res.EvaluationString
if v, ok := values[prom.QueryRefID]; ok {
value = fmt.Sprintf("%g", v.Value)
}
return Data{ return Data{
Labels: labels, Labels: labels,
Values: NewValues(res.Values), Values: values,
Value: res.EvaluationString, evaluationString: res.EvaluationString,
prometheusQueryValue: value,
}
}
// Value returns the value to be used in templates.
// In standard mode, it returns the full evaluation string.
// In Prometheus compatibility mode (when _prometheusMode function is called),
// it returns only the numeric value of the query result.
func (d Data) Value() string {
if d.prometheusMode {
return d.prometheusQueryValue
} else {
return d.evaluationString
}
}
// makePrometheusModeFunc returns a template function that when called, switches the template
// rendering to Prometheus compatibility mode. In this mode, $value and .Value will return
// the numeric value of the query result instead of the full evaluation string.
//
// This function is primarily used when converting Prometheus alert rules to Grafana
// to maintain compatibility with existing Prometheus templates that expect
// $value and .Value to behave like in Prometheus.
func makePrometheusModeFunc(d *Data) func() string {
return func() string {
d.prometheusMode = true
return ""
} }
} }
@ -103,7 +142,14 @@ func Expand(ctx context.Context, name, tmpl string, data Data, externalURL *url.
// add __alert_ to avoid possible conflicts with other templates // add __alert_ to avoid possible conflicts with other templates
name = "__alert_" + name name = "__alert_" + name
// add variables for the labels and values to the beginning of the template // add variables for the labels and values to the beginning of the template
tmpl = "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + tmpl
// if the template starts with {{ _prometheusMode }}, we need to add the variables after that
// to override the $value.
if strings.HasPrefix(tmpl, prom.PrometheusModeTemplateCall) {
tmpl = prom.PrometheusModeTemplateCall + "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + tmpl[len(prom.PrometheusModeTemplateCall):]
} else {
tmpl = "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + tmpl
}
// ctx and queryFunc are no-ops as `query()` is not supported in Grafana // ctx and queryFunc are no-ops as `query()` is not supported in Grafana
queryFunc := func(context.Context, string, time.Time) (promql.Vector, error) { queryFunc := func(context.Context, string, time.Time) (promql.Vector, error) {
return nil, nil return nil, nil
@ -112,8 +158,11 @@ func Expand(ctx context.Context, name, tmpl string, data Data, externalURL *url.
// Use missingkey=invalid so missing data shows <no value> instead of the type's default value // Use missingkey=invalid so missing data shows <no value> instead of the type's default value
options := []string{"missingkey=invalid"} options := []string{"missingkey=invalid"}
expander := template.NewTemplateExpander(ctx, tmpl, name, data, tm, queryFunc, externalURL, options) expander := promTemplate.NewTemplateExpander(ctx, tmpl, name, &data, tm, queryFunc, externalURL, options)
expander.Funcs(defaultFuncs) expander.Funcs(defaultFuncs)
expander.Funcs(template.FuncMap{
"_prometheusMode": makePrometheusModeFunc(&data),
})
result, err := expander.Expand() result, err := expander.Expand()
if err != nil { if err != nil {

@ -3,6 +3,7 @@ package template
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/url" "net/url"
"testing" "testing"
@ -447,3 +448,72 @@ func TestExpandTemplate(t *testing.T) {
}) })
} }
} }
func TestPrometheusModeTemplate(t *testing.T) {
ctx := context.Background()
values := map[string]eval.NumberValueCapture{
"A": {
Var: "A",
Labels: map[string]string{"instance": "server1"},
Value: util.Pointer(42.5),
},
"query": {
Var: "query",
Labels: map[string]string{"instance": "server1"},
Value: util.Pointer(100.0),
},
}
result := eval.Result{
Values: values,
EvaluationString: "[ var='A' labels={instance=server1} value=12.3 ], [ var='query' labels={instance=server1} value=100 ]",
}
testCases := []struct {
name string
template string
expected string
}{
{
name: "Standard template with $labels",
template: "Instance {{ $labels.instance }} has high CPU",
expected: "Instance server1 has high CPU",
},
{
name: "Standard template with $value",
template: "Value is {{ $value }}",
expected: fmt.Sprintf("Value is %s", result.EvaluationString),
},
{
name: "Standard template with .Value",
template: "Value is {{ $.Value }}",
expected: fmt.Sprintf("Value is %s", result.EvaluationString),
},
{
name: "Template with prometheus mode and $value",
template: "{{- _prometheusMode -}}Value is {{ $value }}",
expected: "Value is 100",
},
{
name: "Template with prometheus mode and .Value",
template: "{{- _prometheusMode -}}Value is {{ .Value }}",
expected: "Value is 100",
},
{
name: "Template with prometheus mode and $.Value",
template: "{{- _prometheusMode -}}Value is {{ $.Value }}",
expected: "Value is 100",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
labels := map[string]string{"alertname": "HighCPU", "severity": "critical", "instance": "server1"}
data := NewData(labels, result)
result, err := Expand(ctx, "test", tc.template, data, nil, result.EvaluatedAt)
require.NoError(t, err)
require.Equal(t, tc.expected, result)
})
}
}

Loading…
Cancel
Save