SQL Expressions: Rework backend errors and error instrumentation (#109633)

* Capture error_type label on metrics/traces
* Make error messages more helpful to user
* Use errutil, categorized errors, and tie them to error_type (category in code)
* Misc trace fixes
* Add metric to track SQL input conversion
This commit is contained in:
Kyle Brandt
2025-08-25 11:13:42 -04:00
committed by GitHub
parent 539b413584
commit 4f0cb47d3c
18 changed files with 858 additions and 299 deletions
+98 -8
View File
@@ -8,17 +8,19 @@ import (
"testing"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/expr/metrics"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func TestNewCommand(t *testing.T) {
cmd, err := NewSQLCommand(t.Context(), "a", "", "select a from foo, bar", 0, 0, 0)
cmd, err := NewSQLCommand(t.Context(), log.NewNullLogger(), "a", "", "select a from foo, bar", 0, 0, 0)
if err != nil && strings.Contains(err.Error(), "feature is not enabled") {
return
}
@@ -91,7 +93,7 @@ func TestSQLCommandCellLimits(t *testing.T) {
},
vars: []string{"foo"},
expectError: true,
errorContains: "exceeds limit",
errorContains: "exceeded the configured limit",
},
{
name: "single (wide) frame exceeds cell limit",
@@ -101,7 +103,7 @@ func TestSQLCommandCellLimits(t *testing.T) {
},
vars: []string{"foo"},
expectError: true,
errorContains: "exceeds limit",
errorContains: "exceeded the configured limit",
},
{
name: "multiple frames exceed cell limit",
@@ -112,7 +114,7 @@ func TestSQLCommandCellLimits(t *testing.T) {
},
vars: []string{"foo", "bar"},
expectError: true,
errorContains: "exceeds limit",
errorContains: "exceeded the configured limit",
},
{
name: "limit of 0 means no limit: allow large frame",
@@ -126,7 +128,7 @@ func TestSQLCommandCellLimits(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, err := NewSQLCommand(t.Context(), "a", "", "select a from foo, bar", tt.limit, 0, 0)
cmd, err := NewSQLCommand(t.Context(), log.New(), "a", "", "select a from foo, bar", tt.limit, 0, 0)
require.NoError(t, err, "Failed to create SQL command")
vars := mathexp.Vars{}
@@ -154,15 +156,15 @@ func TestSQLCommandMetrics(t *testing.T) {
m := metrics.NewTestMetrics()
// Create a command
cmd, err := NewSQLCommand(t.Context(), "A", "someformat", "select * from foo", 0, 0, 0)
cmd, err := NewSQLCommand(t.Context(), log.NewNullLogger(), "A", "someformat", "select * from foo", 0, 0, 0)
require.NoError(t, err)
// Execute successful command
_, err = cmd.Execute(context.Background(), time.Now(), mathexp.Vars{}, &testTracer{}, m)
require.NoError(t, err)
// Verify error count was not incremented
require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandCount), "Expected error metric not to be recorded")
// Verify count metric was recorded
require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandCount), "Expected count metric to be recorded")
// Verify duration was recorded
require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandDuration), "Expected duration metric to be recorded")
@@ -171,6 +173,90 @@ func TestSQLCommandMetrics(t *testing.T) {
require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandCellCount), "Expected cell count metric to be recorded")
}
func TestHandleSqlInput(t *testing.T) {
tests := []struct {
name string
frames data.Frames
expectErr string
expectFrame bool
converted bool
}{
{
name: "single frame with no fields and no type is passed through",
frames: data.Frames{data.NewFrame("")},
expectFrame: true,
},
{
name: "single frame with no fields but type timeseries-multi is passed through",
frames: data.Frames{data.NewFrame("").SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti})},
expectFrame: true,
},
{
name: "single frame, no labels, no type → passes through",
frames: data.Frames{
data.NewFrame("",
data.NewField("time", nil, []time.Time{time.Unix(1, 0)}),
data.NewField("value", nil, []*float64{fp(2)}),
),
},
expectFrame: true,
},
{
name: "single frame with labels, but missing FrameMeta.Type → error",
frames: data.Frames{
data.NewFrame("",
data.NewField("time", nil, []time.Time{time.Unix(1, 0)}),
data.NewField("value", data.Labels{"foo": "bar"}, []*float64{fp(2)}),
),
},
expectErr: "labels in the response that can not be mapped to a table",
},
{
name: "multiple frames, no type → error",
frames: data.Frames{
data.NewFrame("",
data.NewField("time", nil, []time.Time{time.Unix(1, 0)}),
data.NewField("value", nil, []*float64{fp(2)}),
),
data.NewFrame("",
data.NewField("time", nil, []time.Time{time.Unix(1, 0)}),
data.NewField("value", nil, []*float64{fp(2)}),
),
},
expectErr: "more than one dataframe that can not be automatically mapped to a single table",
},
{
name: "supported type (timeseries-multi) triggers ConvertToFullLong",
frames: data.Frames{
data.NewFrame("",
data.NewField("time", nil, []time.Time{time.Unix(1, 0)}),
data.NewField("value", data.Labels{"host": "a"}, []*float64{fp(2)}),
).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}),
},
expectFrame: true,
converted: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
res, c := handleSqlInput(t.Context(), &testTracer{}, "a", map[string]struct{}{"b": {}}, "fakeDS", tc.frames)
require.Equal(t, tc.converted, c, "conversion bool mismatch")
if tc.expectErr != "" {
require.Error(t, res.Error)
require.ErrorContains(t, res.Error, tc.expectErr)
} else {
require.NoError(t, res.Error)
if tc.expectFrame {
require.Len(t, res.Values, 1)
require.IsType(t, mathexp.TableData{}, res.Values[0])
require.NotNil(t, res.Values[0].(mathexp.TableData).Frame)
}
}
})
}
}
type testTracer struct {
trace.Tracer
}
@@ -193,3 +279,7 @@ func (ts *testSpan) RecordError(err error, opt ...trace.EventOption) {
}
func (ts *testSpan) SetStatus(code codes.Code, msg string) {}
func (ts *testSpan) AddEvent(name string, opts ...trace.EventOption) {}
func (ts *testSpan) SetAttributes(kv ...attribute.KeyValue) {}