SQL Expressions: Make SQL Expressions work with Alerting (#101820)

Initial support for alerting with SQL expressions

- When `format` is set to `alerting`, SQL expressions output in a format suitable for alerting evaluation.
- Outstanding TODOs:
  - Deduplicate output rows
  - Add more tests
  - Fix broken alerting UI rendering (likely due to shape change to undocumented full-long format)
- Basic usage:
  - SQL must return one numeric column and one or more string columns.
  - Each row may become an alert.
  - The alert fires if the numeric value is non-zero.
  - String columns are treated as labels.

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
Co-authored-by: Sam Jewell <sam.jewell@grafana.com>
This commit is contained in:
Kyle Brandt
2025-04-02 09:39:36 -04:00
committed by GitHub
co-authored by Konrad Lalik Tom Ratcliffe Sam Jewell
parent 192d3783d5
commit c6e52c4766
19 changed files with 409 additions and 357 deletions
-128
View File
@@ -1,128 +0,0 @@
package expr
import (
"fmt"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
func ConvertFromFullLongToNumericMulti(frames data.Frames) (data.Frames, error) {
if len(frames) != 1 {
return nil, fmt.Errorf("expected exactly one frame, got %d", len(frames))
}
frame := frames[0]
if frame.Meta == nil || frame.Meta.Type != numericFullLongType {
return nil, fmt.Errorf("expected frame of type %q", numericFullLongType)
}
var (
metricField *data.Field
valueField *data.Field
displayField *data.Field
labelFields []*data.Field
)
// Identify key fields
for _, f := range frame.Fields {
switch f.Name {
case SQLMetricFieldName:
metricField = f
case SQLValueFieldName:
valueField = f
case SQLDisplayFieldName:
displayField = f
default:
if f.Type() == data.FieldTypeNullableString {
labelFields = append(labelFields, f)
}
}
}
if metricField == nil || valueField == nil {
return nil, fmt.Errorf("missing required fields: %q or %q", SQLMetricFieldName, SQLValueFieldName)
}
type seriesKey struct {
metric string
labelFP data.Fingerprint
displayName string
}
type seriesEntry struct {
indices []int
labels data.Labels
displayName *string
}
grouped := make(map[seriesKey]*seriesEntry)
for i := 0; i < frame.Rows(); i++ {
if valueField.NilAt(i) {
continue // skip null values
}
metric := metricField.At(i).(string)
// collect labels
labels := data.Labels{}
for _, f := range labelFields {
if f.NilAt(i) {
continue
}
val := f.At(i).(*string)
if val != nil {
labels[f.Name] = *val
}
}
fp := labels.Fingerprint()
// handle optional display name
var displayPtr *string
displayKey := ""
if displayField != nil && !displayField.NilAt(i) {
if raw := displayField.At(i).(*string); raw != nil {
displayPtr = raw
displayKey = *raw
}
}
key := seriesKey{
metric: metric,
labelFP: fp,
displayName: displayKey,
}
entry, ok := grouped[key]
if !ok {
entry = &seriesEntry{
labels: labels,
displayName: displayPtr,
}
grouped[key] = entry
}
entry.indices = append(entry.indices, i)
}
var result data.Frames
for key, entry := range grouped {
values := make([]*float64, 0, len(entry.indices))
for _, i := range entry.indices {
v, err := valueField.FloatAt(i)
if err != nil {
return nil, fmt.Errorf("failed to convert value at index %d to float: %w", i, err)
}
values = append(values, &v)
}
field := data.NewField(key.metric, entry.labels, values)
if entry.displayName != nil {
field.Config = &data.FieldConfig{DisplayNameFromDS: *entry.displayName}
}
frame := data.NewFrame("", field)
frame.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
result = append(result, frame)
}
return result, nil
}
-192
View File
@@ -1,192 +0,0 @@
package expr
import (
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/require"
)
func TestConvertFromFullLongToNumericMulti(t *testing.T) {
t.Run("SingleRowNoLabels", func(t *testing.T) {
input := data.NewFrame("",
data.NewField(SQLMetricFieldName, nil, []string{"cpu"}),
data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
)
input.Meta = &data.FrameMeta{Type: numericFullLongType}
out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
require.NoError(t, err)
require.Len(t, out, 1)
expected := data.NewFrame("",
data.NewField("cpu", nil, []*float64{fp(3.14)}),
)
expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
}
})
t.Run("TwoRowsWithLabelsAndDisplay", func(t *testing.T) {
input := data.NewFrame("",
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU A"), sp("CPU A")}),
data.NewField("host", nil, []*string{sp("a"), sp("a")}),
)
input.Meta = &data.FrameMeta{Type: numericFullLongType}
out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
require.NoError(t, err)
require.Len(t, out, 1)
expected := data.NewFrame("",
func() *data.Field {
f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0), fp(2.0)})
f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
return f
}(),
)
expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
}
})
t.Run("SkipsNullValues", func(t *testing.T) {
input := data.NewFrame("",
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), nil}),
)
input.Meta = &data.FrameMeta{Type: numericFullLongType}
out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
require.NoError(t, err)
require.Len(t, out, 1)
expected := data.NewFrame("",
data.NewField("cpu", nil, []*float64{fp(1.0)}),
)
expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
}
})
}
func TestConvertNumericMultiRoundTripToFullLongAndBack(t *testing.T) {
t.Run("TwoFieldsWithSparseLabels", func(t *testing.T) {
input := data.Frames{
data.NewFrame("",
data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)}),
),
data.NewFrame("",
data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []*float64{fp(2.0)}),
),
}
for _, f := range input {
f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
}
fullLong, err := ConvertToFullLong(input)
require.NoError(t, err)
require.Len(t, fullLong, 1)
roundTrip, err := ConvertFromFullLongToNumericMulti(fullLong)
require.NoError(t, err)
expected := data.Frames{
data.NewFrame("",
data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)}),
),
data.NewFrame("",
data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []*float64{fp(2.0)}),
),
}
for _, f := range expected {
f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
}
sortFramesByMetricDisplayAndLabels(expected)
sortFramesByMetricDisplayAndLabels(roundTrip)
require.Len(t, roundTrip, len(expected))
for i := range expected {
if diff := cmp.Diff(expected[i], roundTrip[i], data.FrameTestCompareOptions()...); diff != "" {
t.Errorf("Mismatch on frame %d (-want +got):\n%s", i, diff)
}
}
})
t.Run("PreservesDisplayName", func(t *testing.T) {
input := data.Frames{
data.NewFrame("",
func() *data.Field {
f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)})
f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
return f
}(),
),
}
input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
fullLong, err := ConvertToFullLong(input)
require.NoError(t, err)
require.Len(t, fullLong, 1)
roundTrip, err := ConvertFromFullLongToNumericMulti(fullLong)
require.NoError(t, err)
expected := data.Frames{
data.NewFrame("",
func() *data.Field {
f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)})
f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
return f
}(),
),
}
expected[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
sortFramesByMetricDisplayAndLabels(expected)
sortFramesByMetricDisplayAndLabels(roundTrip)
require.Len(t, roundTrip, 1)
if diff := cmp.Diff(expected[0], roundTrip[0], data.FrameTestCompareOptions()...); diff != "" {
t.Errorf("Mismatch (-want +got):\n%s", diff)
}
})
}
func sortFramesByMetricDisplayAndLabels(frames data.Frames) {
sort.Slice(frames, func(i, j int) bool {
fi := frames[i].Fields[0]
fj := frames[j].Fields[0]
// 1. Metric name
if fi.Name != fj.Name {
return fi.Name < fj.Name
}
// 2. Display name (if set)
var di, dj string
if fi.Config != nil {
di = fi.Config.DisplayNameFromDS
}
if fj.Config != nil {
dj = fj.Config.DisplayNameFromDS
}
if di != dj {
return di < dj
}
// 3. Labels fingerprint
return fi.Labels.Fingerprint() < fj.Labels.Fingerprint()
})
}
+1
View File
@@ -77,6 +77,7 @@ type ClassicQuery struct {
// SQLQuery requires the sqlExpression feature flag
type SQLExpression struct {
Expression string `json:"expression" jsonschema:"minLength=1,example=SELECT * FROM A LIMIT 1"`
Format string `json:"format"`
}
//-------------------------------
+10 -9
View File
@@ -16,8 +16,8 @@
"type": "__expr__",
"uid": "TheUID"
},
"type": "math",
"expression": "$A - $B"
"expression": "$A - $B",
"type": "math"
},
{
"refId": "C",
@@ -25,12 +25,12 @@
"type": "__expr__",
"uid": "TheUID"
},
"type": "reduce",
"expression": "$A",
"reducer": "max",
"settings": {
"mode": "dropNN"
}
},
"type": "reduce"
},
{
"refId": "D",
@@ -38,11 +38,11 @@
"type": "__expr__",
"uid": "TheUID"
},
"expression": "$A",
"window": "1d",
"downsampler": "last",
"expression": "$A",
"type": "resample",
"upsampler": "pad",
"type": "resample"
"window": "1d"
},
{
"refId": "E",
@@ -79,7 +79,6 @@
"type": "__expr__",
"uid": "TheUID"
},
"expression": "A",
"conditions": [
{
"evaluator": {
@@ -90,6 +89,7 @@
}
}
],
"expression": "A",
"type": "threshold"
},
{
@@ -98,7 +98,6 @@
"type": "__expr__",
"uid": "TheUID"
},
"expression": "B",
"conditions": [
{
"evaluator": {
@@ -147,6 +146,7 @@
}
}
],
"expression": "B",
"type": "threshold"
},
{
@@ -156,6 +156,7 @@
"uid": "TheUID"
},
"expression": "SELECT * FROM A limit 1",
"format": "",
"type": "sql"
}
]
+5 -1
View File
@@ -892,6 +892,7 @@
"type": "object",
"required": [
"expression",
"format",
"type",
"refId"
],
@@ -926,6 +927,9 @@
"SELECT * FROM A LIMIT 1"
]
},
"format": {
"type": "string"
},
"hide": {
"description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)",
"type": "boolean"
@@ -1025,4 +1029,4 @@
},
"additionalProperties": true,
"$schema": "https://json-schema.org/draft-04/schema#"
}
}
+9 -8
View File
@@ -13,8 +13,8 @@
"refId": "B",
"maxDataPoints": 1000,
"intervalMs": 5,
"type": "math",
"expression": "$A - $B"
"expression": "$A - $B",
"type": "math"
},
{
"refId": "C",
@@ -31,11 +31,11 @@
"refId": "D",
"maxDataPoints": 1000,
"intervalMs": 5,
"upsampler": "pad",
"type": "resample",
"downsampler": "last",
"expression": "$A",
"window": "1d",
"downsampler": "last"
"type": "resample",
"upsampler": "pad",
"window": "1d"
},
{
"refId": "E",
@@ -68,7 +68,6 @@
"refId": "F",
"maxDataPoints": 1000,
"intervalMs": 5,
"expression": "A",
"conditions": [
{
"evaluator": {
@@ -79,13 +78,13 @@
}
}
],
"expression": "A",
"type": "threshold"
},
{
"refId": "G",
"maxDataPoints": 1000,
"intervalMs": 5,
"expression": "B",
"conditions": [
{
"evaluator": {
@@ -134,6 +133,7 @@
}
}
],
"expression": "B",
"type": "threshold"
},
{
@@ -141,6 +141,7 @@
"maxDataPoints": 1000,
"intervalMs": 5,
"expression": "SELECT * FROM A limit 1",
"format": "",
"type": "sql"
}
]
+5 -1
View File
@@ -942,6 +942,7 @@
"type": "object",
"required": [
"expression",
"format",
"type",
"refId"
],
@@ -976,6 +977,9 @@
"SELECT * FROM A LIMIT 1"
]
},
"format": {
"type": "string"
},
"hide": {
"description": "true if query is disabled (ie should not be returned to the dashboard)\nNOTE: this does not always imply that the query should not be executed since\nthe results from a hidden query may be used as the input to other queries (SSE etc)",
"type": "boolean"
@@ -1083,4 +1087,4 @@
},
"additionalProperties": false,
"$schema": "https://json-schema.org/draft-04/schema#"
}
}
+9 -4
View File
@@ -552,7 +552,7 @@
{
"metadata": {
"name": "sql",
"resourceVersion": "1709915973363",
"resourceVersion": "1743431268321",
"creationTimestamp": "2024-02-29T00:58:00Z"
},
"spec": {
@@ -573,10 +573,14 @@
],
"minLength": 1,
"type": "string"
},
"format": {
"type": "string"
}
},
"required": [
"expression"
"expression",
"format"
],
"type": "object"
},
@@ -584,11 +588,12 @@
{
"name": "Select the first row from A",
"saveModel": {
"expression": "SELECT * FROM A limit 1"
"expression": "SELECT * FROM A limit 1",
"format": ""
}
}
]
}
}
]
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ func (h *ExpressionQueryReader) ReadQuery(
eq.Properties = q
// TODO: Cascade limit from Grafana config in this (new Expression Parser) branch of the code
cellLimit := 0 // zero means no limit
eq.Command, err = NewSQLCommand(common.RefID, q.Expression, int64(cellLimit))
eq.Command, err = NewSQLCommand(common.RefID, q.Format, q.Expression, int64(cellLimit))
}
case QueryTypeThreshold:
+78 -5
View File
@@ -30,10 +30,11 @@ type SQLCommand struct {
varsToQuery []string
refID string
limit int64
format string
}
// NewSQLCommand creates a new SQLCommand.
func NewSQLCommand(refID, rawSQL string, limit int64) (*SQLCommand, error) {
func NewSQLCommand(refID, format, rawSQL string, limit int64) (*SQLCommand, error) {
if rawSQL == "" {
return nil, ErrMissingSQLQuery
}
@@ -62,6 +63,7 @@ func NewSQLCommand(refID, rawSQL string, limit int64) (*SQLCommand, error) {
varsToQuery: tables,
refID: refID,
limit: limit,
format: format,
}, nil
}
@@ -83,7 +85,10 @@ func UnmarshalSQLCommand(rn *rawNode, limit int64) (*SQLCommand, error) {
return nil, fmt.Errorf("expected sql expression to be type string, but got type %T", expressionRaw)
}
return NewSQLCommand(rn.RefID, expression, limit)
formatRaw := rn.Query["format"]
format, _ := formatRaw.(string)
return NewSQLCommand(rn.RefID, format, expression, limit)
}
// NeedsVars returns the variable names (refIds) that are dependencies
@@ -140,10 +145,24 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V
return rsp, nil
}
rsp.Values = mathexp.Values{
mathexp.TableData{Frame: frame},
}
switch gr.format {
case "alerting":
numberSet, err := extractNumberSetFromSQLForAlerting(frame)
if err != nil {
rsp.Error = err
return rsp, nil
}
vals := make([]mathexp.Value, 0, len(numberSet))
for i := range numberSet {
vals = append(vals, numberSet[i])
}
rsp.Values = vals
default:
rsp.Values = mathexp.Values{
mathexp.TableData{Frame: frame},
}
}
return rsp, nil
}
@@ -162,3 +181,57 @@ func totalCells(frames []*data.Frame) (total int64) {
}
return
}
func extractNumberSetFromSQLForAlerting(frame *data.Frame) ([]mathexp.Number, error) {
var (
numericField *data.Field
numericFieldIx int
)
// Find the only numeric field
for i, f := range frame.Fields {
if f.Type().Numeric() {
if numericField != nil {
return nil, fmt.Errorf("expected exactly one numeric field, but found multiple")
}
numericField = f
numericFieldIx = i
}
}
if numericField == nil {
return nil, fmt.Errorf("no numeric field found in frame")
}
numbers := make([]mathexp.Number, frame.Rows())
for i := 0; i < frame.Rows(); i++ {
val, err := numericField.FloatAt(i)
if err != nil {
return nil, fmt.Errorf("failed to read numeric value at row %d: %w", i, err)
}
labels := data.Labels{}
for j, f := range frame.Fields {
if j == numericFieldIx || (f.Type() != data.FieldTypeString && f.Type() != data.FieldTypeNullableString) {
continue
}
val := f.At(i)
switch v := val.(type) {
case *string:
if v != nil {
labels[f.Name] = *v
}
case string:
labels[f.Name] = v
}
}
n := mathexp.NewNumber(numericField.Name, labels)
n.Frame.Fields[0].Config = numericField.Config
n.SetValue(&val)
numbers[i] = n
}
return numbers, nil
}
+80
View File
@@ -0,0 +1,80 @@
package expr
import (
"testing"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/require"
)
func TestExtractNumberSetFromSQLForAlerting(t *testing.T) {
t.Run("SingleRowNoLabels", func(t *testing.T) {
input := data.NewFrame("",
data.NewField(SQLMetricFieldName, nil, []string{"cpu"}), // will be treated as a label
data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
)
numbers, err := extractNumberSetFromSQLForAlerting(input)
require.NoError(t, err)
require.Len(t, numbers, 1)
got := numbers[0]
require.Equal(t, fp(3.14), got.GetFloat64Value())
require.Equal(t, data.Labels{
SQLMetricFieldName: "cpu",
}, got.GetLabels())
})
t.Run("TwoRowsWithLabelsAndDisplay", func(t *testing.T) {
input := data.NewFrame("",
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU A"), sp("CPU A")}),
data.NewField("host", nil, []*string{sp("a"), sp("a")}),
)
numbers, err := extractNumberSetFromSQLForAlerting(input)
require.NoError(t, err)
require.Len(t, numbers, 2)
require.Equal(t, fp(1.0), numbers[0].GetFloat64Value())
require.Equal(t, data.Labels{
SQLMetricFieldName: "cpu",
SQLDisplayFieldName: "CPU A",
"host": "a",
}, numbers[0].GetLabels())
require.Equal(t, fp(2.0), numbers[1].GetFloat64Value())
require.Equal(t, data.Labels{
SQLMetricFieldName: "cpu",
SQLDisplayFieldName: "CPU A",
"host": "a",
}, numbers[1].GetLabels())
})
t.Run("TwoFieldsWithSparseLabels", func(t *testing.T) {
input := data.NewFrame("",
data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
data.NewField("env", nil, []*string{nil, sp("prod")}),
data.NewField("host", nil, []*string{sp("a"), sp("b")}),
)
numbers, err := extractNumberSetFromSQLForAlerting(input)
require.NoError(t, err)
require.Len(t, numbers, 2)
require.Equal(t, fp(1.0), numbers[0].GetFloat64Value())
require.Equal(t, data.Labels{
SQLMetricFieldName: "cpu",
"host": "a",
}, numbers[0].GetLabels())
require.Equal(t, fp(2.0), numbers[1].GetFloat64Value())
require.Equal(t, data.Labels{
SQLMetricFieldName: "cpu",
"host": "b",
"env": "prod",
}, numbers[1].GetLabels())
})
}
+2 -2
View File
@@ -15,7 +15,7 @@ import (
)
func TestNewCommand(t *testing.T) {
cmd, err := NewSQLCommand("a", "select a from foo, bar", 0)
cmd, err := NewSQLCommand("a", "", "select a from foo, bar", 0)
if err != nil && strings.Contains(err.Error(), "feature is not enabled") {
return
}
@@ -123,7 +123,7 @@ func TestSQLCommandCellLimits(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, err := NewSQLCommand("a", "select a from foo, bar", tt.limit)
cmd, err := NewSQLCommand("a", "", "select a from foo, bar", tt.limit)
require.NoError(t, err, "Failed to create SQL command")
vars := mathexp.Vars{}