Skip to content

Commit 57e7bb8

Browse files
authored
capture duration and label with error source (#116)
* capture duration and label with error source
1 parent c3e8950 commit 57e7bb8

File tree

4 files changed

+136
-20
lines changed

4 files changed

+136
-20
lines changed

datasource.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9-
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
109
"net/http"
1110
"strings"
1211
"sync"
1312
"time"
1413

14+
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
15+
1516
"github.com/grafana/grafana-plugin-sdk-go/backend"
1617
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
1718
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
@@ -239,7 +240,8 @@ func (ds *SQLDatasource) handleQuery(ctx context.Context, req backend.DataQuery,
239240
// * Some datasources (snowflake) expire connections or have an authentication token that expires if not used in 1 or 4 hours.
240241
// Because the datasource driver does not include an option for permanent connections, we retry the connection
241242
// if the query fails. NOTE: this does not include some errors like "ErrNoRows"
242-
res, err := QueryDB(ctx, dbConn.db, ds.c.Converters(), fillMode, q, args...)
243+
dbQuery := NewQuery(dbConn.db, dbConn.settings, ds.c.Converters(), fillMode)
244+
res, err := dbQuery.Run(ctx, q, args...)
243245
if err == nil {
244246
return res, nil
245247
}
@@ -263,7 +265,9 @@ func (ds *SQLDatasource) handleQuery(ctx context.Context, req backend.DataQuery,
263265
if ds.driverSettings.Pause > 0 {
264266
time.Sleep(time.Duration(ds.driverSettings.Pause * int(time.Second)))
265267
}
266-
res, err = QueryDB(ctx, db, ds.c.Converters(), fillMode, q, args...)
268+
269+
dbQuery := NewQuery(db, dbConn.settings, ds.c.Converters(), fillMode)
270+
res, err = dbQuery.Run(ctx, q, args...)
267271
if err == nil {
268272
return res, err
269273
}
@@ -284,7 +288,8 @@ func (ds *SQLDatasource) handleQuery(ctx context.Context, req backend.DataQuery,
284288
continue
285289
}
286290

287-
res, err = QueryDB(ctx, db, ds.c.Converters(), fillMode, q, args...)
291+
dbQuery := NewQuery(db, dbConn.settings, ds.c.Converters(), fillMode)
292+
res, err = dbQuery.Run(ctx, q, args...)
288293
if err == nil {
289294
return res, err
290295
}

query.go

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ import (
77
"errors"
88
"fmt"
99
"net/http"
10+
"strings"
1011
"time"
1112

1213
"github.com/grafana/dataplane/sdata/timeseries"
1314
"github.com/grafana/grafana-plugin-sdk-go/backend"
1415
"github.com/grafana/grafana-plugin-sdk-go/data"
1516
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
17+
"github.com/prometheus/client_golang/prometheus"
18+
"github.com/prometheus/client_golang/prometheus/promauto"
1619
)
1720

1821
// FormatQueryOption defines how the user has chosen to represent the data
@@ -36,6 +39,12 @@ const (
3639
// Deprecated: use sqlutil.Query directly instead
3740
type Query = sqlutil.Query
3841

42+
var duration = promauto.NewHistogramVec(prometheus.HistogramOpts{
43+
Namespace: "plugins",
44+
Name: "plugin_external_requests_duration_seconds",
45+
Help: "Duration of requests to external services",
46+
}, []string{"datasource_name", "datasource_type", "error_source"})
47+
3948
// GetQuery wraps sqlutil's GetQuery to add headers if needed
4049
func GetQuery(query backend.DataQuery, headers http.Header, setHeaders bool) (*Query, error) {
4150
model, err := sqlutil.GetQuery(query)
@@ -51,29 +60,63 @@ func GetQuery(query backend.DataQuery, headers http.Header, setHeaders bool) (*Q
5160
return model, nil
5261
}
5362

54-
// QueryDB sends the query to the connection and converts the rows to a dataframe.
55-
func QueryDB(ctx context.Context, db Connection, converters []sqlutil.Converter, fillMode *data.FillMissing, query *Query, args ...interface{}) (data.Frames, error) {
56-
// Query the rows from the database
57-
rows, err := db.QueryContext(ctx, query.RawSQL, args...)
63+
type DBQuery struct {
64+
DSName string
65+
DB Connection
66+
Settings backend.DataSourceInstanceSettings
67+
converters []sqlutil.Converter
68+
fillMode *data.FillMissing
69+
}
70+
71+
func NewQuery(db Connection, settings backend.DataSourceInstanceSettings, converters []sqlutil.Converter, fillMode *data.FillMissing) *DBQuery {
72+
dsName, ok := sanitizeLabelName(settings.Name)
73+
if !ok {
74+
backend.Logger.Warn("Failed to sanitize datasource name for prometheus label", dsName)
75+
}
76+
return &DBQuery{
77+
DB: db,
78+
DSName: dsName,
79+
converters: converters,
80+
fillMode: fillMode,
81+
}
82+
}
83+
84+
// Run sends the query to the connection and converts the rows to a dataframe.
85+
func (q *DBQuery) Run(ctx context.Context, query *Query, args ...interface{}) (data.Frames, error) {
86+
start := time.Now()
87+
var errWithSource error
88+
89+
defer func() {
90+
if q.DSName != "" {
91+
errorSource := "none"
92+
if errWithSource != nil {
93+
errorSource = string(ErrorSource(errWithSource))
94+
}
95+
96+
duration.WithLabelValues(q.DSName, q.Settings.Type, errorSource).Observe(time.Since(start).Seconds())
97+
}
98+
}()
99+
100+
rows, err := q.DB.QueryContext(ctx, query.RawSQL, args...)
58101
if err != nil {
59102
errType := ErrorQuery
60103
if errors.Is(err, context.Canceled) {
61104
errType = context.Canceled
62105
}
63-
err := DownstreamError(fmt.Errorf("%w: %s", errType, err.Error()))
64-
return sqlutil.ErrorFrameFromQuery(query), err
106+
errWithSource := DownstreamError(fmt.Errorf("%w: %s", errType, err.Error()))
107+
return sqlutil.ErrorFrameFromQuery(query), errWithSource
65108
}
66109

67110
// Check for an error response
68111
if err := rows.Err(); err != nil {
69112
if errors.Is(err, sql.ErrNoRows) {
70113
// Should we even response with an error here?
71114
// The panel will simply show "no data"
72-
err := DownstreamError(fmt.Errorf("%s: %w", "No results from query", err))
73-
return sqlutil.ErrorFrameFromQuery(query), err
115+
errWithSource := DownstreamError(fmt.Errorf("%s: %w", "No results from query", err))
116+
return sqlutil.ErrorFrameFromQuery(query), errWithSource
74117
}
75-
err := DownstreamError(fmt.Errorf("%s: %w", "Error response from database", err))
76-
return sqlutil.ErrorFrameFromQuery(query), err
118+
errWithSource := DownstreamError(fmt.Errorf("%s: %w", "Error response from database", err))
119+
return sqlutil.ErrorFrameFromQuery(query), errWithSource
77120
}
78121

79122
defer func() {
@@ -83,10 +126,10 @@ func QueryDB(ctx context.Context, db Connection, converters []sqlutil.Converter,
83126
}()
84127

85128
// Convert the response to frames
86-
res, err := getFrames(rows, -1, converters, fillMode, query)
129+
res, err := getFrames(rows, -1, q.converters, q.fillMode, query)
87130
if err != nil {
88-
err := PluginError(fmt.Errorf("%w: %s", err, "Could not process SQL results"))
89-
return sqlutil.ErrorFrameFromQuery(query), err
131+
errWithSource := PluginError(fmt.Errorf("%w: %s", err, "Could not process SQL results"))
132+
return sqlutil.ErrorFrameFromQuery(query), errWithSource
90133
}
91134

92135
return res, nil
@@ -203,3 +246,29 @@ func applyHeaders(query *Query, headers http.Header) *Query {
203246

204247
return query
205248
}
249+
250+
// sanitizeLabelName removes all invalid chars from the label name.
251+
// If the label name is empty or contains only invalid chars, it will return false indicating it was not sanitized.
252+
// copied from https://github.com/grafana/grafana/blob/main/pkg/infra/metrics/metricutil/utils.go#L14
253+
func sanitizeLabelName(name string) (string, bool) {
254+
if len(name) == 0 {
255+
backend.Logger.Warn(fmt.Sprintf("label name cannot be empty: %s", name))
256+
return "", false
257+
}
258+
259+
out := strings.Builder{}
260+
for i, b := range name {
261+
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || (b >= '0' && b <= '9' && i > 0) {
262+
out.WriteRune(b)
263+
} else if b == ' ' {
264+
out.WriteRune('_')
265+
}
266+
}
267+
268+
if out.Len() == 0 {
269+
backend.Logger.Warn(fmt.Sprintf("label name only contains invalid chars: %q", name))
270+
return "", false
271+
}
272+
273+
return out.String(), true
274+
}

query_integration_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111
"time"
1212

13+
"github.com/grafana/grafana-plugin-sdk-go/backend"
1314
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
1415

1516
_ "github.com/go-sql-driver/mysql"
@@ -75,7 +76,12 @@ func TestQuery_MySQL(t *testing.T) {
7576
RawSQL: "SELECT SLEEP(5)",
7677
}
7778

78-
_, err := QueryDB(ctx, db, []sqlutil.Converter{}, nil, q)
79+
settings := backend.DataSourceInstanceSettings{
80+
Name: "foo",
81+
}
82+
83+
sqlQuery := NewQuery(db, settings, []sqlutil.Converter{}, nil)
84+
_, err := sqlQuery.Run(ctx, q)
7985
if err == nil {
8086
t.Fatal("expected an error but received none")
8187
}

query_test.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"testing"
99
"time"
1010

11+
"github.com/grafana/grafana-plugin-sdk-go/backend"
1112
"github.com/grafana/grafana-plugin-sdk-go/data"
1213
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
14+
"github.com/stretchr/testify/assert"
1315
"github.com/stretchr/testify/require"
1416
)
1517

@@ -78,7 +80,12 @@ func TestQuery_Timeout(t *testing.T) {
7880

7981
defer conn.Close()
8082

81-
_, err := QueryDB(ctx, conn, []sqlutil.Converter{}, nil, &Query{})
83+
settings := backend.DataSourceInstanceSettings{
84+
Name: "foo",
85+
}
86+
87+
sqlQuery := NewQuery(conn, settings, []sqlutil.Converter{}, nil)
88+
_, err := sqlQuery.Run(ctx, &Query{})
8289

8390
if !errors.Is(err, context.Canceled) {
8491
t.Fatal("expected error to be context.Canceled, received", err)
@@ -100,7 +107,12 @@ func TestQuery_Timeout(t *testing.T) {
100107

101108
defer conn.Close()
102109

103-
_, err := QueryDB(ctx, conn, []sqlutil.Converter{}, nil, &Query{})
110+
settings := backend.DataSourceInstanceSettings{
111+
Name: "foo",
112+
}
113+
114+
sqlQuery := NewQuery(conn, settings, []sqlutil.Converter{}, nil)
115+
_, err := sqlQuery.Run(ctx, &Query{})
104116

105117
if !errors.Is(err, ErrorQuery) {
106118
t.Fatal("expected function to complete, received error: ", err)
@@ -152,3 +164,27 @@ func TestFixFrameForLongToMulti(t *testing.T) {
152164
require.Equal(t, err, fmt.Errorf("can not convert to wide series, input is missing a time field"))
153165
})
154166
}
167+
168+
func TestLabelNameSanitization(t *testing.T) {
169+
testcases := []struct {
170+
input string
171+
expected string
172+
err bool
173+
}{
174+
{input: "job", expected: "job"},
175+
{input: "job._loal['", expected: "job_loal"},
176+
{input: "", expected: "", err: true},
177+
{input: ";;;", expected: "", err: true},
178+
{input: "Data source", expected: "Data_source"},
179+
}
180+
181+
for _, tc := range testcases {
182+
got, ok := sanitizeLabelName(tc.input)
183+
if tc.err {
184+
assert.Equal(t, false, ok)
185+
} else {
186+
assert.Equal(t, true, ok)
187+
assert.Equal(t, tc.expected, got)
188+
}
189+
}
190+
}

0 commit comments

Comments
 (0)