From 0e0ed43153c9d2f70df24f00457a8a07862f5886 Mon Sep 17 00:00:00 2001 From: Owen Diehl Date: Tue, 25 May 2021 11:54:50 -0400 Subject: [PATCH] Alerting/testing promql extraction (#34665) * promql compat for marshaling * extracts upstream instant queries into data frame for alerting * eval string parity --- pkg/services/ngalert/api/api_testing.go | 2 +- pkg/services/ngalert/api/promql_compat.go | 145 ++++++++++++++++++ .../ngalert/api/promql_compat_test.go | 107 +++++++++++++ pkg/services/ngalert/api/util.go | 1 - 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 pkg/services/ngalert/api/promql_compat.go create mode 100644 pkg/services/ngalert/api/promql_compat_test.go diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index 15f44609098..0567c2782cf 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -74,7 +74,7 @@ func (srv TestingApiSrv) RouteTestRuleConfig(c *models.ReqContext, body apimodel http.MethodGet, queryURL, nil, - jsonExtractor(nil), + instantQueryResultsExtractor, nil, ) } diff --git a/pkg/services/ngalert/api/promql_compat.go b/pkg/services/ngalert/api/promql_compat.go new file mode 100644 index 00000000000..e93ff9f890e --- /dev/null +++ b/pkg/services/ngalert/api/promql_compat.go @@ -0,0 +1,145 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + cortex_util "github.com/cortexproject/cortex/pkg/util" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/ngalert/eval" + "github.com/prometheus/prometheus/pkg/labels" + "github.com/prometheus/prometheus/promql" + "github.com/prometheus/prometheus/promql/parser" + + "github.com/grafana/grafana/pkg/util" +) + +type instantQueryResponse struct { + Status string `json:"status"` + Data queryData `json:"data,omitempty"` + ErrorType string `json:"errorType,omitempty"` + Error string `json:"error,omitempty"` +} + +type queryData struct { + ResultType parser.ValueType `json:"resultType"` + Result json.RawMessage `json:"result"` + vector vector `json:"-"` + scalar scalar `json:"-"` +} + +type scalar promql.Scalar + +func (s *scalar) UnmarshalJSON(b []byte) error { + var xs []interface{} + if err := json.Unmarshal(b, &xs); err != nil { + return err + } + // scalars are encoded like `[ts/1000, "value"]` + if len(xs) != 2 { + return fmt.Errorf("unexpected number of scalar encoded values: %d", len(xs)) + } + ts, ok := xs[0].(float64) + if !ok { + return fmt.Errorf("first value in scalar uncoercible to timestamp: %v", xs[0]) + } + s.T = int64(ts) * 1000 + v, ok := xs[1].(string) + if !ok { + return fmt.Errorf("second value in scalar not string encoded: %v", xs[1]) + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return err + } + s.V = f + return nil +} + +func (d *queryData) UnmarshalJSON(b []byte) error { + type plain queryData + if err := json.Unmarshal(b, (*plain)(d)); err != nil { + return err + } + + switch d.ResultType { + case parser.ValueTypeScalar: + return json.Unmarshal(d.Result, &d.scalar) + case parser.ValueTypeVector: + return json.Unmarshal(d.Result, &d.vector) + default: + return fmt.Errorf("unexpected response type: %s", d.ResultType) + } +} + +type sample struct { + Metric labels.Labels `json:"metric"` + Value scalar `json:"value"` +} +type vector []sample + +func instantQueryResults(resp instantQueryResponse) (eval.Results, error) { + if resp.Error != "" || resp.Status != "success" { + return nil, errors.New(resp.Error) + } + + switch resp.Data.ResultType { + case parser.ValueTypeScalar: + return eval.Results{{ + Instance: map[string]string{}, + State: eval.Alerting, + EvaluatedAt: cortex_util.TimeFromMillis(resp.Data.scalar.T), + EvaluationString: extractEvalStringFromProm(sample{ + Value: resp.Data.scalar, + }), + }}, nil + case parser.ValueTypeVector: + results := make(eval.Results, 0, len(resp.Data.vector)) + for _, s := range resp.Data.vector { + results = append(results, eval.Result{ + Instance: s.Metric.Map(), + State: eval.Alerting, + EvaluatedAt: cortex_util.TimeFromMillis(s.Value.T), + EvaluationString: extractEvalStringFromProm(s), + }) + } + return results, nil + default: + return nil, fmt.Errorf("unexpected response type: %s", resp.Data.ResultType) + } +} + +func instantQueryResultsExtractor(b []byte) (interface{}, error) { + var resp instantQueryResponse + err := json.Unmarshal(b, &resp) + if err != nil { + return nil, err + } + + res, err := instantQueryResults(resp) + if err != nil { + return nil, err + } + frame := res.AsDataFrame() + + return util.DynMap{ + "instances": []*data.Frame{&frame}, + }, nil +} + +// extractEvalStringFromProm is intended to mimic the functionality used in ngalert/eval +func extractEvalStringFromProm(s sample) string { + var sb strings.Builder + sb.WriteString("[ ") + var ls string + if len(s.Metric) > 0 { + ls = s.Metric.String() + } + sb.WriteString(fmt.Sprintf("labels={%s} ", ls)) + sb.WriteString(fmt.Sprintf("value=%v ", fmt.Sprintf("%v", s.Value.V))) + sb.WriteString("]") + return sb.String() +} diff --git a/pkg/services/ngalert/api/promql_compat_test.go b/pkg/services/ngalert/api/promql_compat_test.go new file mode 100644 index 00000000000..351d104b9e9 --- /dev/null +++ b/pkg/services/ngalert/api/promql_compat_test.go @@ -0,0 +1,107 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/prometheus/prometheus/pkg/labels" + "github.com/prometheus/prometheus/promql/parser" + "github.com/stretchr/testify/require" +) + +func Test_instantQueryMarshaling(t *testing.T) { + for _, tc := range []struct { + desc string + in string + exp parser.ValueType + expScalar *scalar + expVector *vector + isError bool // successfully unpack an upstream error + }{ + { + desc: "scalar", + in: `{ + "status": "success", + "data": { + "resultType": "scalar", + "result": [ + 12, + "2" + ] + } +}`, + exp: parser.ValueTypeScalar, + expScalar: &scalar{ + T: 12000, + V: 2, + }, + }, + { + desc: "vector", + in: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "apiserver_request:burnrate1d" + }, + "value": [ + 12.04, + "10.5" + ] + } + ] + } +}`, + exp: parser.ValueTypeVector, + expVector: &vector{ + sample{ + Value: scalar{ + T: 12000, // loses some precision during marshaling + V: 10.5, + }, + Metric: []labels.Label{{ + Name: "__name__", + Value: "apiserver_request:burnrate1d", + }}, + }, + }, + }, + { + desc: "successfully parse error", + in: `{ + "status": "failure", + "errorType": "someErr", + "error": "error doing something" +}`, + isError: true, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + var out instantQueryResponse + err := json.Unmarshal([]byte(tc.in), &out) + require.NoError(t, err) + + if tc.isError { + require.Equal(t, out.Status, "failure") + require.Greater(t, len(out.ErrorType), 0) + require.Greater(t, len(out.Error), 0) + return + } + + require.Equal(t, tc.exp, out.Data.ResultType) + b, err := json.MarshalIndent(out, "", " ") + require.Nil(t, err) + require.Equal(t, tc.in, string(b)) + + if tc.expScalar != nil { + require.Equal(t, *tc.expScalar, out.Data.scalar) + } + if tc.expVector != nil { + require.Equal(t, *tc.expVector, out.Data.vector) + } + }) + } +} diff --git a/pkg/services/ngalert/api/util.go b/pkg/services/ngalert/api/util.go index 7cfc104a418..2862af18bd6 100644 --- a/pkg/services/ngalert/api/util.go +++ b/pkg/services/ngalert/api/util.go @@ -229,7 +229,6 @@ func conditionEval(c *models.ReqContext, cmd ngmodels.EvalAlertConditionCommand, } frame := evalResults.AsDataFrame() - return response.JSONStreaming(http.StatusOK, util.DynMap{ "instances": []*data.Frame{&frame}, })