Compare commits

...

2 Commits

Author SHA1 Message Date
Alexander Akhmetov 6da408fcb9 Alerting: Update documentation about ValueString 2025-12-02 22:23:25 +01:00
Seunghun Shin 55b94e6df6 Alerting: Add expression type information to webhook valueString (#112312)
* Alerting: Add expression type to webhook valueString
- Add Type field to NumberValueCapture struct
- Implement AlertQuery.GetExpressionType() method
- Update valueString format to include type information

* Alerting: Add expression type to webhook valueString
- Fix tests

* Alerting: Add expression type to webhook valueString
- Update default annotations in notifier templates to include type field

* Alerting: Add expression type to webhook valueString
- Add type='math' to webhook and email test expectations
2025-12-02 22:03:17 +01:00
10 changed files with 94 additions and 43 deletions
@@ -170,7 +170,7 @@ This example prints the `$value` variable:
When using multiple data sources, it would display something like this:
```
[ var='A' labels={instance=instance1} value=81.234, , [ var='B' labels={instance=instance2} value=1 ] ]: CPU usage has exceeded 80% for the last 5 minutes.
[ var='A' labels={instance=instance1} type='reduce' value=81.234 ], [ var='B' labels={instance=instance2} type='threshold' value=1 ]: CPU usage has exceeded 80% for the last 5 minutes.
```
But with a single data source, it would display just the value of the query:
@@ -116,7 +116,7 @@ Grafana-managed alerts include these additional properties:
| `PanelURL` | string | A link to the panel if the alert has a Panel ID annotation, with time range from `1h` before alert start to end (or now if firing). |
| `SilenceURL` | string | A link to silence the alert. |
| `Values` | [KV](#kv) | The values of expressions used to evaluate the alert condition. Only relevant values are included. |
| `ValueString` | string | A string that contains the labels and value of each reduced expression in the alert. |
| `ValueString` | string | A string containing the labels, expression type, and value of each reduced expression in the alert. |
| `OrgID` | integer | The ID of the organization that owns the alert. |
This example iterates over the list of firing and resolved alerts (`.Alerts`) in the notification and prints the data for each alert:
@@ -2,17 +2,18 @@
title: 'JSON alert object'
---
| Key | Type | Description |
| -------------- | ------ | ----------------------------------------------------------------------------------- |
| `status` | string | Current status of the alert, `firing` or `resolved`. |
| `labels` | object | Labels that are part of this alert, map of string keys to string values. |
| `annotations` | object | Annotations that are part of this alert, map of string keys to string values. |
| `startsAt` | string | Start time of the alert. |
| `endsAt` | string | End time of the alert, default value when not resolved is `0001-01-01T00:00:00Z`. |
| `values` | object | Values that triggered the current status. |
| `generatorURL` | string | URL of the alert rule in the Grafana UI. |
| `fingerprint` | string | The labels fingerprint, alarms with the same labels will have the same fingerprint. |
| `silenceURL` | string | URL to silence the alert rule in the Grafana UI. |
| `dashboardURL` | string | A link to the Grafana Dashboard if the alert has a Dashboard UID annotation. |
| `panelURL` | string | A link to the panel if the alert has a Panel ID annotation. |
| `imageURL` | string | URL of a screenshot of a panel assigned to the rule that created this notification. |
| Key | Type | Description |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------ |
| `status` | string | Current status of the alert, `firing` or `resolved`. |
| `labels` | object | Labels that are part of this alert, map of string keys to string values. |
| `annotations` | object | Annotations that are part of this alert, map of string keys to string values. |
| `startsAt` | string | Start time of the alert. |
| `endsAt` | string | End time of the alert, default value when not resolved is `0001-01-01T00:00:00Z`. |
| `values` | object | Values that triggered the current status. |
| `valueString` | string | A string representation of expression values including variable names, labels, expression types, and values. |
| `generatorURL` | string | URL of the alert rule in the Grafana UI. |
| `fingerprint` | string | The labels fingerprint, alarms with the same labels will have the same fingerprint. |
| `silenceURL` | string | URL to silence the alert rule in the Grafana UI. |
| `dashboardURL` | string | A link to the Grafana Dashboard if the alert has a Dashboard UID annotation. |
| `panelURL` | string | A link to the panel if the alert has a Panel ID annotation. |
| `imageURL` | string | URL of a screenshot of a panel assigned to the rule that created this notification. |
+17
View File
@@ -437,6 +437,7 @@ type NumberValueCapture struct {
Var string // RefID
IsDatasourceNode bool
Labels data.Labels
Type string // Expression type (reduce, threshold, classic_conditions, etc.)
Value *float64
}
@@ -462,17 +463,33 @@ func IsNoData(res backend.DataResponse) bool {
func queryDataResponseToExecutionResults(c models.Condition, execResp *backend.QueryDataResponse) ExecutionResults {
// captures contains the values of all instant queries and expressions for each dimension
captures := make(map[string]map[data.Fingerprint]NumberValueCapture)
// Build a lookup table for expression types by RefID
expressionTypes := make(map[string]string)
for _, query := range c.Data {
if exprType, err := query.GetExpressionType(); err == nil {
expressionTypes[query.RefID] = exprType
}
}
captureFn := func(refID string, datasourceType expr.NodeType, labels data.Labels, value *float64) {
m := captures[refID]
if m == nil {
m = make(map[data.Fingerprint]NumberValueCapture)
}
fp := labels.Fingerprint()
exprType := expressionTypes[refID]
if exprType == "" && datasourceType == expr.TypeDatasourceNode {
exprType = "query"
}
m[fp] = NumberValueCapture{
Var: refID,
IsDatasourceNode: datasourceType == expr.TypeDatasourceNode,
Value: value,
Labels: labels.Copy(),
Type: exprType,
}
captures[refID] = m
}
+5
View File
@@ -27,6 +27,7 @@ func extractEvalString(frame *data.Frame) (s string) {
sb.WriteString(fmt.Sprintf("var='%s%v' ", frame.RefID, i))
sb.WriteString(fmt.Sprintf("metric='%s' ", m.Metric))
sb.WriteString(fmt.Sprintf("labels={%s} ", m.Labels))
sb.WriteString("type='classic_conditions' ")
valString := "null"
if m.Value != nil {
@@ -53,6 +54,9 @@ func extractEvalString(frame *data.Frame) (s string) {
sb.WriteString("[ ")
sb.WriteString(fmt.Sprintf("var='%s' ", capture.Var))
sb.WriteString(fmt.Sprintf("labels={%s} ", capture.Labels))
if capture.Type != "" {
sb.WriteString(fmt.Sprintf("type='%s' ", capture.Type))
}
valString := "null"
if capture.Value != nil {
valString = fmt.Sprintf("%v", *capture.Value)
@@ -92,6 +96,7 @@ func extractValues(frame *data.Frame) map[string]NumberValueCapture {
Var: frame.RefID,
Labels: match.Labels,
Value: match.Value,
Type: "classic_conditions",
}
}
return v
+9 -9
View File
@@ -21,7 +21,7 @@ func TestExtractEvalString(t *testing.T) {
inFrame: newMetaFrame([]classic.EvalMatch{
{Metric: "Test", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(32.3)},
}, util.Pointer(1.0)),
outString: `[ var='0' metric='Test' labels={host=foo} value=32.3 ]`,
outString: `[ var='0' metric='Test' labels={host=foo} type='classic_conditions' value=32.3 ]`,
},
{
desc: "2 EvalMatches",
@@ -29,7 +29,7 @@ func TestExtractEvalString(t *testing.T) {
{Metric: "Test", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(32.3)},
{Metric: "Test", Labels: data.Labels{"host": "baz"}, Value: util.Pointer(10.0)},
}, util.Pointer(1.0), withRefID("A")),
outString: `[ var='A0' metric='Test' labels={host=foo} value=32.3 ], [ var='A1' metric='Test' labels={host=baz} value=10 ]`,
outString: `[ var='A0' metric='Test' labels={host=foo} type='classic_conditions' value=32.3 ], [ var='A1' metric='Test' labels={host=baz} type='classic_conditions' value=10 ]`,
},
{
desc: "3 EvalMatches",
@@ -38,15 +38,15 @@ func TestExtractEvalString(t *testing.T) {
{Metric: "Test", Labels: data.Labels{"host": "baz"}, Value: util.Pointer(10.0)},
{Metric: "TestA", Labels: data.Labels{"host": "zip"}, Value: util.Pointer(11.0)},
}, util.Pointer(1.0), withRefID("A")),
outString: `[ var='A0' metric='Test' labels={host=foo} value=32.3 ], [ var='A1' metric='Test' labels={host=baz} value=10 ], [ var='A2' metric='TestA' labels={host=zip} value=11 ]`,
outString: `[ var='A0' metric='Test' labels={host=foo} type='classic_conditions' value=32.3 ], [ var='A1' metric='Test' labels={host=baz} type='classic_conditions' value=10 ], [ var='A2' metric='TestA' labels={host=zip} type='classic_conditions' value=11 ]`,
},
{
desc: "Captures are sorted in ascending order of var",
inFrame: newMetaFrame([]NumberValueCapture{
{Var: "B", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(1.0)},
{Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(10.0)},
{Var: "B", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(1.0), Type: "reduce"},
{Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(10.0), Type: "threshold"},
}, util.Pointer(1.0)),
outString: `[ var='A' labels={host=foo} value=10 ], [ var='B' labels={host=foo} value=1 ]`,
outString: `[ var='A' labels={host=foo} type='threshold' value=10 ], [ var='B' labels={host=foo} type='reduce' value=1 ]`,
},
}
for _, tc := range cases {
@@ -71,7 +71,7 @@ func TestExtractValues(t *testing.T) {
{Metric: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(1.0)},
}, util.Pointer(1.0), withRefID("A")),
values: map[string]NumberValueCapture{
"A0": {Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(1.0)},
"A0": {Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(1.0), Type: "classic_conditions"},
},
}, {
desc: "Classic condition frame with multiple matches",
@@ -80,8 +80,8 @@ func TestExtractValues(t *testing.T) {
{Metric: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(3.0)},
}, util.Pointer(1.0), withRefID("A")),
values: map[string]NumberValueCapture{
"A0": {Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(1.0)},
"A1": {Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(3.0)},
"A0": {Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(1.0), Type: "classic_conditions"},
"A1": {Var: "A", Labels: data.Labels{"host": "foo"}, Value: util.Pointer(3.0), Type: "classic_conditions"},
},
}, {
desc: "Nil value",
@@ -342,3 +342,31 @@ func (aq *AlertQuery) InitDefaults() error {
aq.Model = model
return nil
}
// GetExpressionType returns the type of expression for this AlertQuery.
// It returns "query" for regular datasource queries and the actual type for expressions.
func (aq *AlertQuery) GetExpressionType() (string, error) {
if aq.modelProps == nil {
err := aq.setModelProps()
if err != nil {
return "", err
}
}
// Check if this is an expression query
isExpr, err := aq.IsExpression()
if err != nil {
return "", err
}
if !isExpr {
return "query", nil // Regular data source query
}
// Extract type from model
if exprType, ok := aq.modelProps["type"].(string); ok {
return exprType, nil
}
return "unknown", nil
}
+1 -1
View File
@@ -20,7 +20,7 @@ var (
}
DefaultAnnotations = map[string]string{
alertingModels.ValuesAnnotation: `{"B":22,"C":1}`,
alertingModels.ValueStringAnnotation: `[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]`,
alertingModels.ValueStringAnnotation: `[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='reduce' value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='threshold' value=1 ]`,
alertingModels.OrgIDAnnotation: `1`,
alertingModels.DashboardUIDAnnotation: `dashboard_uid`,
alertingModels.PanelIDAnnotation: `1`,
@@ -137,7 +137,7 @@ func TestIntegrationTestReceivers(t *testing.T) {
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='reduce' value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='threshold' value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
@@ -225,7 +225,7 @@ func TestIntegrationTestReceivers(t *testing.T) {
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='reduce' value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='threshold' value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
@@ -307,7 +307,7 @@ func TestIntegrationTestReceivers(t *testing.T) {
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='reduce' value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='threshold' value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
@@ -400,7 +400,7 @@ func TestIntegrationTestReceivers(t *testing.T) {
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='reduce' value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='threshold' value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
@@ -506,7 +506,7 @@ func TestIntegrationTestReceivers(t *testing.T) {
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='reduce' value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='threshold' value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
@@ -805,7 +805,7 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) {
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='reduce' value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} type='threshold' value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
@@ -2440,7 +2440,7 @@ var expEmailNotifications = []*notifications.SendEmailCommandSync{
PanelURL: "",
OrgID: util.Pointer(int64(1)),
Values: map[string]float64{"A": 1},
ValueString: "[ var='A' labels={} value=1 ]",
ValueString: "[ var='A' labels={} type='math' value=1 ]",
},
},
"GroupLabels": template.KV{"alertname": "EmailAlert"},
@@ -2594,10 +2594,10 @@ var expNonEmailNotifications = map[string][]string{
"grafana_folder": "default"
},
"annotations": {},
"startsAt": "%s",
"startsAt": "%s",
"values": {"A": 1},
"valueString": "[ var='A' labels={} value=1 ]",
"endsAt": "0001-01-01T00:00:00Z",
"valueString": "[ var='A' labels={} type='math' value=1 ]",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://localhost:3000/alerting/grafana/UID_WebhookAlert/view?orgId=1",
"fingerprint": "15c59b0a380bd9f1",
"silenceURL": "http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_WebhookAlert&orgId=1",
@@ -2768,10 +2768,10 @@ var expNonEmailNotifications = map[string][]string{
"alertname": "AlertmanagerAlert",
"grafana_folder": "default"
},
"annotations": {
"__orgId__":"1",
"annotations": {
"__orgId__":"1",
"__values__": "{\"A\":1}",
"__value_string__": "[ var='A' labels={} value=1 ]"
"__value_string__": "[ var='A' labels={} type='math' value=1 ]"
},
"startsAt": "%s",
"endsAt": "0001-01-01T00:00:00Z",
+4 -4
View File
@@ -150,8 +150,8 @@ func TestGrafanaRuleConfig(t *testing.T) {
for i, alert := range result {
require.NotEmpty(t, alert.Annotations["values.B"])
require.NotEmpty(t, alert.Annotations["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} type='reduce' value=%s ]", dynamicLabels[i], alert.Annotations["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} type='threshold' value=%s ]", dynamicLabels[i], alert.Annotations["values.C"])
require.Contains(t, alert.Annotations["value"], valueB)
require.Contains(t, alert.Annotations["value"], valueC)
}
@@ -172,8 +172,8 @@ func TestGrafanaRuleConfig(t *testing.T) {
for i, alert := range result {
require.NotEmpty(t, alert.Labels["values.B"])
require.NotEmpty(t, alert.Labels["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} type='reduce' value=%s ]", dynamicLabels[i], alert.Labels["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} type='threshold' value=%s ]", dynamicLabels[i], alert.Labels["values.C"])
require.Contains(t, alert.Labels["value"], valueB)
require.Contains(t, alert.Labels["value"], valueC)
}