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:
co-authored by
Konrad Lalik
Tom Ratcliffe
Sam Jewell
parent
192d3783d5
commit
c6e52c4766
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
//-------------------------------
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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#"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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#"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user