diff --git a/internal/checks/base_test.go b/internal/checks/base_test.go index 27bb0746..c2fee12f 100644 --- a/internal/checks/base_test.go +++ b/internal/checks/base_test.go @@ -26,6 +26,7 @@ func runTests(t *testing.T, testCases []checkTest, opts ...cmp.Option) { p := parser.NewParser() ctx := context.Background() for _, tc := range testCases { + // original test t.Run(tc.description, func(t *testing.T) { rules, err := p.Parse([]byte(tc.content)) if err != nil { @@ -38,6 +39,8 @@ func runTests(t *testing.T, testCases []checkTest, opts ...cmp.Option) { } } }) + + // broken alerting rule to test check against rules with syntax error t.Run(tc.description+" (bogus alerting rule)", func(t *testing.T) { rules, err := p.Parse([]byte(` - alert: foo @@ -52,6 +55,8 @@ func runTests(t *testing.T, testCases []checkTest, opts ...cmp.Option) { _ = tc.checker.Check(ctx, rule) } }) + + // broken recording rule to test check against rules with syntax error t.Run(tc.description+" (bogus recording rule)", func(t *testing.T) { rules, err := p.Parse([]byte(` - record: foo diff --git a/internal/checks/check_test.go b/internal/checks/check_test.go new file mode 100644 index 00000000..f25624b4 --- /dev/null +++ b/internal/checks/check_test.go @@ -0,0 +1,286 @@ +package checks_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/cloudflare/pint/internal/checks" + "github.com/cloudflare/pint/internal/parser" +) + +type newCheckFn func(string) checks.RuleChecker + +type problemsFn func(string) []checks.Problem + +type checkTestT struct { + description string + content string + checker newCheckFn + problems problemsFn + mocks []prometheusMock +} + +func runTestsT(t *testing.T, testCases []checkTestT, opts ...cmp.Option) { + p := parser.NewParser() + brokenRules, err := p.Parse([]byte(` +- alert: foo + expr: 'foo{}{} > 0' + annotations: + summary: '{{ $labels.job }} is incorrect' + +- record: foo + expr: 'foo{}{}' +`)) + require.NoError(t, err, "failed to parse broken test rules") + + ctx := context.Background() + for _, tc := range testCases { + // original test + t.Run(tc.description, func(t *testing.T) { + var uri string + if len(tc.mocks) > 0 { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for i := range tc.mocks { + if tc.mocks[i].maybeApply(w, r) { + tc.mocks[i].wasUsed = true + return + } + } + t.Errorf("no matching response for %s request", r.URL.Path) + t.FailNow() + })) + defer srv.Close() + uri = srv.URL + } + + rules, err := p.Parse([]byte(tc.content)) + require.NoError(t, err, "cannot parse rule content") + for _, rule := range rules { + problems := tc.checker(uri).Check(ctx, rule) + require.Equal(t, tc.problems(uri), problems) + } + + // verify that all mocks were used + for _, mock := range tc.mocks { + require.True(t, mock.wasUsed, "unused mock in %s: %s", tc.description, mock.conds) + } + }) + + // broken rules to test check against rules with syntax error + t.Run(tc.description+" (bogus rules)", func(t *testing.T) { + for _, rule := range brokenRules { + _ = tc.checker("").Check(ctx, rule) + } + }) + } +} + +func noProblems(uri string) []checks.Problem { + return nil +} + +type requestCondition interface { + isMatch(*http.Request) bool +} + +type responseWriter interface { + respond(w http.ResponseWriter) +} + +type prometheusMock struct { + conds []requestCondition + resp responseWriter + wasUsed bool +} + +func (pm *prometheusMock) maybeApply(w http.ResponseWriter, r *http.Request) bool { + for _, cond := range pm.conds { + if !cond.isMatch(r) { + return false + } + } + pm.wasUsed = true + pm.resp.respond(w) + return true +} + +type requestPathCond struct { + path string +} + +func (rpc requestPathCond) isMatch(r *http.Request) bool { + return r.URL.Path == rpc.path +} + +type formCond struct { + key string + value string +} + +func (fc formCond) isMatch(r *http.Request) bool { + err := r.ParseForm() + if err != nil { + return false + } + return r.Form.Get(fc.key) == fc.value +} + +var ( + // requireConfigPath = requestPathCond{path: "/api/v1/config"} + requireQueryPath = requestPathCond{path: "/api/v1/query"} + requireRangeQueryPath = requestPathCond{path: "/api/v1/query_range"} +) + +type promError struct { + code int + errorType v1.ErrorType + err string +} + +func (pe promError) respond(w http.ResponseWriter) { + w.WriteHeader(pe.code) + w.Header().Set("Content-Type", "application/json") + perr := struct { + Status string `json:"status"` + ErrorType v1.ErrorType `json:"errorType"` + Error string `json:"error"` + }{ + Status: "error", + ErrorType: pe.errorType, + Error: pe.err, + } + d, err := json.MarshalIndent(perr, "", " ") + if err != nil { + panic(err) + } + _, _ = w.Write(d) +} + +type vectorResponse struct { + samples model.Vector +} + +func (vr vectorResponse) respond(w http.ResponseWriter) { + w.WriteHeader(200) + w.Header().Set("Content-Type", "application/json") + result := struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result model.Vector `json:"result"` + } `json:"data"` + }{ + Status: "success", + Data: struct { + ResultType string `json:"resultType"` + Result model.Vector `json:"result"` + }{ + ResultType: "vector", + Result: vr.samples, + }, + } + d, err := json.MarshalIndent(result, "", " ") + if err != nil { + panic(err) + } + _, _ = w.Write(d) +} + +type matrixResponse struct { + samples []*model.SampleStream +} + +func (mr matrixResponse) respond(w http.ResponseWriter) { + w.WriteHeader(200) + w.Header().Set("Content-Type", "application/json") + result := struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []*model.SampleStream `json:"result"` + } `json:"data"` + }{ + Status: "success", + Data: struct { + ResultType string `json:"resultType"` + Result []*model.SampleStream `json:"result"` + }{ + ResultType: "matrix", + Result: mr.samples, + }, + } + d, err := json.MarshalIndent(result, "", " ") + if err != nil { + panic(err) + } + _, _ = w.Write(d) +} + +var ( + respondWithBadData = promError{code: 400, errorType: v1.ErrBadData, err: "bad input data"} + respondWithInternalError = promError{code: 500, errorType: v1.ErrServer, err: "internal error"} + respondWithEmptyVector = vectorResponse{samples: model.Vector{}} + respondWithEmptyMatrix = matrixResponse{samples: []*model.SampleStream{}} + respondWithSingleInstantVector = vectorResponse{ + samples: generateVector(map[string]string{}), + } + respondWithSingleRangeVector1W = matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + } +) + +func generateVector(labels map[string]string) (v model.Vector) { + metric := model.Metric{} + for k, v := range labels { + metric[model.LabelName(k)] = model.LabelValue(v) + } + v = append(v, &model.Sample{ + Metric: metric, + Value: model.SampleValue(1), + Timestamp: model.TimeFromUnix(time.Now().Unix()), + }) + return +} + +func generateSampleStream(labels map[string]string, from, until time.Time, step time.Duration) (s *model.SampleStream) { + metric := model.Metric{} + for k, v := range labels { + metric[model.LabelName(k)] = model.LabelValue(v) + } + s = &model.SampleStream{ + Metric: metric, + } + for from.Before(until) { + s.Values = append(s.Values, model.SamplePair{ + Timestamp: model.TimeFromUnix(from.Unix()), + Value: 1, + }) + from = from.Add(step) + } + return +} + +func checkErrorBadData(name, uri, err string) string { + return fmt.Sprintf(`prometheus %q at %s failed with: %s`, name, uri, err) +} + +func checkErrorUnableToRun(c, name, uri, err string) string { + return fmt.Sprintf(`cound't run %q checks due to prometheus %q at %s connection error: %s`, c, name, uri, err) +} diff --git a/internal/checks/promql_series.go b/internal/checks/promql_series.go index 2f648271..bf4c9368 100644 --- a/internal/checks/promql_series.go +++ b/internal/checks/promql_series.go @@ -104,7 +104,7 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr Lines: expr.Lines(), Reporter: c.Reporter(), Text: fmt.Sprintf("%s didn't have any series for %q metric in the last %s", - promText(c.prom.Name(), trs.uri), bareSelector.String(), trs.sinceDesc()), + promText(c.prom.Name(), trs.uri), bareSelector.String(), trs.sinceDesc(trs.from)), Severity: Bug, }) log.Debug().Str("check", c.Reporter()).Stringer("selector", &bareSelector).Msg("No historical series for base metric") @@ -118,7 +118,7 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr log.Debug().Str("check", c.Reporter()).Stringer("selector", &selector).Str("label", name).Msg("Checking if base metric has historical series with required label") trsLabelCount, err := c.serieTimeRanges(ctx, fmt.Sprintf("count(%s) by (%s)", bareSelector.String(), name), rangeStart, rangeStep) if err != nil { - problems = append(problems, c.queryProblem(err, bareSelector.String(), expr)) + problems = append(problems, c.queryProblem(err, selector.String(), expr)) continue } @@ -130,7 +130,7 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr Reporter: c.Reporter(), Text: fmt.Sprintf( "%s has %q metric but there are no series with %q label in the last %s", - promText(c.prom.Name(), trsLabelCount.uri), bareSelector.String(), name, trsLabelCount.sinceDesc()), + promText(c.prom.Name(), trsLabelCount.uri), bareSelector.String(), name, trsLabelCount.sinceDesc(trsLabelCount.from)), Severity: Bug, }) log.Debug().Str("check", c.Reporter()).Stringer("selector", &selector).Str("label", name).Msg("No historical series with label used for the query") @@ -154,7 +154,7 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr Reporter: c.Reporter(), Text: fmt.Sprintf( "%s doesn't currently have %q, it was last present %s ago", - promText(c.prom.Name(), trs.uri), bareSelector.String(), output.HumanizeDuration(time.Since(trs.newest()))), + promText(c.prom.Name(), trs.uri), bareSelector.String(), trs.sinceDesc(trs.newest())), Severity: Bug, }) log.Debug().Str("check", c.Reporter()).Stringer("selector", &bareSelector).Msg("Series disappeared from prometheus ") @@ -176,7 +176,7 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr trsLabel, err := c.serieTimeRanges(ctx, fmt.Sprintf("count(%s)", labelSelector.String()), rangeStart, rangeStep) if err != nil { - problems = append(problems, c.queryProblem(err, bareSelector.String(), expr)) + problems = append(problems, c.queryProblem(err, labelSelector.String(), expr)) continue } @@ -184,7 +184,7 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr if len(trsLabel.ranges) == 0 { text := fmt.Sprintf( "%s has %q metric but there are no series matching {%s} in the last %s", - promText(c.prom.Name(), trsLabel.uri), bareSelector.String(), lm.String(), trsLabel.sinceDesc()) + promText(c.prom.Name(), trsLabel.uri), bareSelector.String(), lm.String(), trsLabel.sinceDesc(trs.from)) var s Severity = Bug for _, name := range highChurnLabels { if lm.Name == name { @@ -210,12 +210,12 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr !trsLabel.oldest().After(rangeStart.Add(rangeStep)) && trsLabel.newest().Before(time.Now().Add(rangeStep*-1)) { problems = append(problems, Problem{ - Fragment: bareSelector.String(), + Fragment: labelSelector.String(), Lines: expr.Lines(), Reporter: c.Reporter(), Text: fmt.Sprintf( "%s has %q metric but doesn't currently have series matching {%s}, such series was last present %s ago", - promText(c.prom.Name(), trs.uri), bareSelector.String(), lm.String(), output.HumanizeDuration(time.Since(trs.newest()))), + promText(c.prom.Name(), trs.uri), bareSelector.String(), lm.String(), trsLabel.sinceDesc(trsLabel.newest())), Severity: Bug, }) log.Debug().Str("check", c.Reporter()).Stringer("selector", &selector).Stringer("matcher", lm).Msg("Series matching filter disappeared from prometheus ") @@ -229,9 +229,9 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr Lines: expr.Lines(), Reporter: c.Reporter(), Text: fmt.Sprintf( - "metric %q with label %s is only sometimes present on %s with average life span of %s", + "metric %q with label {%s} is only sometimes present on %s with average life span of %s", bareSelector.String(), lm.String(), promText(c.prom.Name(), trs.uri), - output.HumanizeDuration(trs.avgLife())), + output.HumanizeDuration(trsLabel.avgLife())), Severity: Warning, }) log.Debug().Str("check", c.Reporter()).Stringer("selector", &selector).Stringer("matcher", lm).Msg("Series matching filter are only sometimes present") @@ -244,12 +244,12 @@ func (c SeriesCheck) Check(ctx context.Context, rule parser.Rule) (problems []Pr // 8. If foo is SOMETIMES there -> WARN if len(trs.ranges) > 1 { problems = append(problems, Problem{ - Fragment: selector.String(), + Fragment: bareSelector.String(), Lines: expr.Lines(), Reporter: c.Reporter(), Text: fmt.Sprintf( "metric %q is only sometimes present on %s with average life span of %s in the last %s", - bareSelector.String(), promText(c.prom.Name(), trs.uri), output.HumanizeDuration(trs.avgLife()), trs.sinceDesc()), + bareSelector.String(), promText(c.prom.Name(), trs.uri), output.HumanizeDuration(trs.avgLife()), trs.sinceDesc(trs.from)), Severity: Warning, }) log.Debug().Str("check", c.Reporter()).Stringer("selector", &bareSelector).Msg("Metric only sometimes present") @@ -427,8 +427,8 @@ func (tr timeRanges) newest() (ts time.Time) { return } -func (tr timeRanges) sinceDesc() (s string) { - dur := time.Since(tr.from) +func (tr timeRanges) sinceDesc(t time.Time) (s string) { + dur := time.Since(t) if dur > time.Hour*24 { return output.HumanizeDuration(dur.Round(time.Hour)) } diff --git a/internal/checks/promql_series_test.go b/internal/checks/promql_series_test.go index cfde0eb5..4eb1a03a 100644 --- a/internal/checks/promql_series_test.go +++ b/internal/checks/promql_series_test.go @@ -2,315 +2,164 @@ package checks_test import ( "fmt" - "net/http" - "net/http/httptest" "testing" "time" + "github.com/prometheus/common/model" + "github.com/cloudflare/pint/internal/checks" ) -func TestSeriesCheck(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - t.Fatal(err) - } - query := r.Form.Get("query") +func newSeriesCheck(uri string) checks.RuleChecker { + return checks.NewSeriesCheck(simpleProm("prom", uri, time.Second*5, true)) +} - switch query { - case `count(test_metric)`, `count(test_metric_c) by (step)`: - w.WriteHeader(400) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"error", - "errorType":"execution", - "error":"query failed" - }`)) - case "count(notfound)", - `count(notfound{job="bar"}`, - `count(notfound{job="foo"})`, - `count(notfound{job!="foo"})`, - `count({__name__="notfound",job="bar"})`, - `count(ALERTS{alertname="foo"})`, - `count(ALERTS{notfound="foo"})`, - `count(test_metric{step="2"})`, - `count(test_metric_c{step="3"})`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/query_range" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"matrix","result":[]}}`)) - return - } - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[] - } - }`)) - case "count(found_1)", `count({__name__="notfound"})`, `count(ALERTS) by (notfound)`, `count(test_metric_c)`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/query_range" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"matrix", - "result":[ - { - "metric":{},"values":[ - [1614859502.068,"1"] - ] - }, - { - "metric":{},"values":[ - [1614869502.068,"1"] - ] - } - ] - } - }`)) - return - } - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[{"metric":{},"value":[1614859502.068,"1"]}] - } - }`)) +func noMetricText(name, uri, metric, since string) string { + return fmt.Sprintf(`prometheus %q at %s didn't have any series for %q metric in the last %s`, name, uri, metric, since) +} - case "count(found_7)", `count(ALERTS)`, `count(ALERTS) by (alertname)`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/query_range" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"matrix", - "result":[ - { - "metric":{"alertname": "xxx"},"values":[ - [1614859502.068,"1"] - ] - }, - { - "metric":{"alertname": "yyy"},"values":[ - [1614859502.068,"1"] - ] - } - ] - } - }`)) - return - } - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[ - {"metric":{"alertname": "xxx"},"value":[1614859502.068,"7"]}, - {"metric":{"alertname": "yyy"},"value":[1614859502.068,"7"]} - ] - } - }`)) - case `count(node_filesystem_readonly{mountpoint!=""})`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[{"metric":{},"value":[1614859502.068,"1"]}] - } - }`)) - case `count(disk_info{interface_speed!="6.0 Gb/s",type="sat"})`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[{"metric":{},"value":[1614859502.068,"1"]}] - } - }`)) - case `count(found{job="notfound"})`, `count(notfound{job="notfound"})`, `count(notfound{job="bar"})`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/query_range" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"matrix","result":[]}}`)) - return - } - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[] - } - }`)) - case `count(found)`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/query_range" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"matrix", - "result":[ - { - "metric":{},"values":[ - [1614859502.068,"1"] - ] - }, - { - "metric":{},"values":[ - [1614869502.068,"1"] - ] - } - ] - } - }`)) - return - } - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[{"metric":{},"value":[1614859502.068,"1"]}] - } - }`)) - case `count(found) by (job)`, `count({__name__="notfound"}) by (job)`: - w.WriteHeader(200) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/query_range" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"matrix", - "result":[ - { - "metric":{"job": "xxx"},"values":[ - [1614859502.068,"1"] - ] - }, - { - "metric":{"job": "yyy"},"values":[ - [1614859502.068,"1"] - ] - } - ] - } - }`)) - return - } - _, _ = w.Write([]byte(`{ - "status":"success", - "data":{ - "resultType":"vector", - "result":[ - {"metric":{"job": "xxx"},"value":[1614859502.068,"1"]}, - {"metric":{"job": "yyy"},"value":[1614859502.068,"1"]} - ] - } - }`)) - default: - w.WriteHeader(400) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "status":"error", - "errorType":"bad_data", - "error":"unhandled query" - }`)) - } - })) - defer srv.Close() +func noFilterMatchText(name, uri, metric, filter, since string) string { + return fmt.Sprintf(`prometheus %q at %s has %q metric but there are no series matching %s in the last %s`, name, uri, metric, filter, since) +} + +func noLabelKeyText(name, uri, metric, label, since string) string { + return fmt.Sprintf(`prometheus %q at %s has %q metric but there are no series with %q label in the last %s`, name, uri, metric, label, since) +} - testCases := []checkTest{ +func noSeriesText(name, uri, metric, since string) string { + return fmt.Sprintf(`prometheus %q at %s didn't have any series for %q metric in the last %s`, name, uri, metric, since) +} + +func seriesDisappearedText(name, uri, metric, since string) string { + return fmt.Sprintf(`prometheus %q at %s doesn't currently have %q, it was last present %s ago`, name, uri, metric, since) +} + +func filterDisappeardText(name, uri, metric, filter, since string) string { + return fmt.Sprintf(`prometheus %q at %s has %q metric but doesn't currently have series matching %s, such series was last present %s ago`, name, uri, metric, filter, since) +} + +func filterSometimesText(name, uri, metric, filter, since string) string { + return fmt.Sprintf(`metric %q with label %s is only sometimes present on prometheus %q at %s with average life span of %s`, metric, filter, name, uri, since) +} + +func seriesSometimesText(name, uri, metric, since, avg string) string { + return fmt.Sprintf(`metric %q is only sometimes present on prometheus %q at %s with average life span of %s in the last %s`, metric, name, uri, avg, since) +} + +func TestSeriesCheck(t *testing.T) { + testCases := []checkTestT{ { description: "ignores rules with syntax errors", content: "- record: foo\n expr: sum(foo) without(\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), + checker: newSeriesCheck, + problems: noProblems, }, { description: "bad response", content: "- record: foo\n expr: sum(foo)\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: "foo", + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: checkErrorBadData("prom", uri, "bad_data: bad input data"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ { - Fragment: "foo", - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s failed with: bad_data: unhandled query`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{requireQueryPath}, + resp: respondWithBadData, }, }, }, { description: "bad uri", content: "- record: foo\n expr: sum(foo)\n", - checker: checks.NewSeriesCheck(simpleProm("prom", "http://", time.Second*5, false)), - problems: []checks.Problem{ - { - Fragment: "foo", - Lines: []int{2}, - Reporter: "promql/series", - Text: `cound't run "promql/series" checks due to prometheus "prom" at http:// connection error: Post "http:///api/v1/query": http: no Host in request URL`, - Severity: checks.Warning, - }, + checker: func(s string) checks.RuleChecker { + return checks.NewSeriesCheck(simpleProm("prom", "http://", time.Second*5, false)) + }, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: "foo", + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: checkErrorUnableToRun(checks.SeriesCheckName, "prom", "http://", `Post "http:///api/v1/query": http: no Host in request URL`), + Severity: checks.Warning, + }, + } }, }, { description: "simple query", content: "- record: foo\n expr: sum(notfound)\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: "notfound", + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noMetricText("prom", uri, "notfound", "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ { - Fragment: "notfound", - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s didn't have any series for "notfound" metric in the last 1w`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{requireQueryPath}, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{requireRangeQueryPath}, + resp: respondWithEmptyMatrix, }, }, }, { description: "complex query", content: "- record: foo\n expr: sum(found_7 * on (job) sum(sum(notfound))) / found_7\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: "notfound", + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noMetricText("prom", uri, "notfound", "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ { - Fragment: "notfound", - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s didn't have any series for "notfound" metric in the last 1w`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: "count(notfound)"}, + }, + resp: respondWithEmptyVector, }, - }, - }, - { - description: "complex query / bug", - content: "- record: foo\n expr: sum(found_7 * on (job) sum(sum(notfound))) / found_7\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ { - Fragment: "notfound", - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s didn't have any series for "notfound" metric in the last 1w`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(notfound)"}, + }, + resp: respondWithEmptyMatrix, + }, + { + conds: []requestCondition{requireQueryPath, formCond{key: "query", value: "count(found_7)"}}, + resp: respondWithSingleInstantVector, }, }, }, { description: "label_replace()", - content: `- alert: foo + content: ` +- alert: foo expr: | count( label_replace( @@ -331,43 +180,883 @@ func TestSeriesCheck(t *testing.T) { ) for: 5m `, - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), + checker: newSeriesCheck, + problems: noProblems, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(disk_info{interface_speed!="6.0 Gb/s",type="sat"})`}, + }, + resp: respondWithSingleInstantVector, + }, + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(node_filesystem_readonly{mountpoint!=""})`}, + }, + resp: respondWithSingleInstantVector, + }, + }, }, { description: "offset", content: "- record: foo\n expr: node_filesystem_readonly{mountpoint!=\"\"} offset 5m\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), + checker: newSeriesCheck, + problems: noProblems, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(node_filesystem_readonly{mountpoint!=""})`}, + }, + resp: respondWithSingleInstantVector, + }, + }, }, { description: "negative offset", content: "- record: foo\n expr: node_filesystem_readonly{mountpoint!=\"\"} offset -15m\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), + checker: newSeriesCheck, + problems: noProblems, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(node_filesystem_readonly{mountpoint!=""})`}, + }, + resp: respondWithSingleInstantVector, + }, + }, + }, + { + description: "#1 series present", + content: "- record: foo\n expr: found > 0\n", + checker: newSeriesCheck, + problems: noProblems, + mocks: []prometheusMock{ + { + conds: []requestCondition{requireQueryPath}, + resp: respondWithSingleInstantVector, + }, + }, + }, + { + description: "#1 query error", + content: "- record: foo\n expr: found > 0\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: checkErrorUnableToRun(checks.SeriesCheckName, "prom", uri, "server_error: server error: 500"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{requireQueryPath}, + resp: respondWithInternalError, + }, + }, + }, + { + description: "#2 series never present", + content: "- record: foo\n expr: sum(notfound)\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: "notfound", + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noMetricText("prom", uri, "notfound", "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{requireQueryPath}, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{requireRangeQueryPath}, + resp: respondWithEmptyMatrix, + }, + }, + }, + { + description: "#2 query error", + content: "- record: foo\n expr: found > 0\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: checkErrorUnableToRun(checks.SeriesCheckName, "prom", uri, "server_error: server error: 500"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{requireQueryPath}, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{requireRangeQueryPath}, + resp: respondWithInternalError, + }, + }, + }, + { + description: "#3 metric present, label missing", + content: "- record: foo\n expr: sum(found{job=\"foo\", notfound=\"xxx\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found{job="foo",notfound="xxx"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noLabelKeyText("prom", uri, "found", "notfound", "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(found{job="foo",notfound="xxx"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found)`}, + }, + resp: respondWithSingleRangeVector1W, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found) by (job)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"job": "xxx"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found) by (notfound)`}, + }, + resp: respondWithSingleRangeVector1W, + }, + }, + }, + { + description: "#3 metric present, label query error", + content: "- record: foo\n expr: sum(found{notfound=\"xxx\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found{notfound="xxx"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: checkErrorUnableToRun(checks.SeriesCheckName, "prom", uri, "server_error: server error: 500"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(found{notfound="xxx"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found)`}, + }, + resp: respondWithSingleRangeVector1W, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found) by (notfound)`}, + }, + resp: respondWithInternalError, + }, + }, + }, + { + description: "#4 metric was present but disappeared", + content: "- record: foo\n expr: sum(found{job=\"foo\", instance=\"bar\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: seriesDisappearedText("prom", uri, "found", "4d"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(found{instance="bar",job="foo"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-4).Add(time.Minute*-5), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found) by (job)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"job": "foo"}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-4).Add(time.Minute*-5), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found) by (instance)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"instance": "bar"}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-4).Add(time.Minute*-5), + time.Minute*5, + ), + }, + }, + }, + }, + }, + { + description: "#5 metric was present but not with label", + content: "- record: foo\n expr: sum(found{notfound=\"notfound\", instance=~\".+\", not!=\"negative\", instance!~\"bad\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found{instance!~"bad",instance=~".+",not!="negative",notfound="notfound"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noFilterMatchText("prom", uri, "found", `{notfound="notfound"}`, "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(found{instance!~"bad",instance=~".+",not!="negative",notfound="notfound"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found)`}, + }, + resp: respondWithSingleRangeVector1W, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(found) by (instance)"}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"instance": "bar"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(found) by (not)"}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"not": "yyy"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(found) by (notfound)"}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"notfound": "found"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found{instance=~".+"})`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"instance": "bar"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found{notfound="notfound"})`}, + }, + resp: respondWithEmptyMatrix, + }, + }, + }, + { + description: "#5 label query error", + content: "- record: foo\n expr: sum(found{error=\"xxx\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found{error="xxx"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: checkErrorUnableToRun(checks.SeriesCheckName, "prom", uri, "server_error: server error: 500"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(found{error="xxx"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found)`}, + }, + resp: respondWithSingleRangeVector1W, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(found) by (error)"}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"error": "bar"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found{error="xxx"})`}, + }, + resp: respondWithInternalError, + }, + }, + }, + { + description: "#5 high churn labels", + content: "- record: foo\n expr: sum(sometimes{churn=\"notfound\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `sometimes{churn="notfound"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noFilterMatchText("prom", uri, "sometimes", `{churn="notfound"}`, "1w") + `, "churn" looks like a high churn label`, + Severity: checks.Warning, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(sometimes{churn="notfound"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(sometimes)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-7).Add(time.Hour), + time.Minute*5, + ), + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-5), + time.Now().Add(time.Hour*24*-5).Add(time.Minute*10), + time.Minute*5, + ), + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-2), + time.Now().Add(time.Hour*24*-2).Add(time.Minute*20), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(sometimes) by (churn)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"churn": "aaa"}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-7).Add(time.Hour), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"churn": "bbb"}, + time.Now().Add(time.Hour*24*-5), + time.Now().Add(time.Hour*24*-5).Add(time.Minute*10), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"churn": "ccc"}, + time.Now().Add(time.Hour*24*-2), + time.Now().Add(time.Hour*24*-2).Add(time.Minute*20), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(sometimes{churn="notfound"})`}, + }, + resp: respondWithEmptyMatrix, + }, + }, + }, + { + description: "#6 metric was always present but label disappeared", + content: "- record: foo\n expr: sum({__name__=\"found\", removed=\"xxx\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found{removed="xxx"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: filterDisappeardText("prom", uri, `{__name__="found"}`, `{removed="xxx"}`, "5d16h"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count({__name__="found",removed="xxx"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count({__name__="found"})`}, + }, + resp: respondWithSingleRangeVector1W, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count({__name__="found"}) by (removed)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"removed": "xxx"}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-6).Add(time.Hour*8), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found{removed="xxx"})`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-6).Add(time.Hour*8), + time.Minute*5, + ), + }, + }, + }, + }, + }, + { + description: "#7 metric was always present but label only sometimes", + content: "- record: foo\n expr: sum(found{sometimes=\"xxx\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found{sometimes="xxx"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: filterSometimesText("prom", uri, `found`, `{sometimes="xxx"}`, "18h45m"), + Severity: checks.Warning, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(found{sometimes="xxx"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found)`}, + }, + resp: respondWithSingleRangeVector1W, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found) by (sometimes)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"sometimes": "aaa"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"sometimes": "bbb"}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-4), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"sometimes": "xxx"}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-6).Add(time.Hour*8), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"sometimes": "xxx"}, + time.Now().Add(time.Hour*24*-5), + time.Now().Add(time.Hour*24*-4), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"sometimes": "xxx"}, + time.Now().Add(time.Hour*24*-2), + time.Now().Add(time.Hour*24*-2), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found{sometimes="xxx"})`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-6).Add(time.Hour*8), + time.Minute*5, + ), + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-5), + time.Now().Add(time.Hour*24*-4), + time.Minute*5, + ), + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-2), + time.Now().Add(time.Hour*24*-2), + time.Minute*5, + ), + }, + }, + }, + }, + }, + { + description: "#8 metric is sometimes present", + content: "- record: foo\n expr: sum(sometimes{foo!=\"bar\"})\n", + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `sometimes`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: seriesSometimesText("prom", uri, "sometimes", "1w", "35m"), + Severity: checks.Warning, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(sometimes{foo!="bar"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(sometimes)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-7).Add(time.Hour), + time.Minute*5, + ), + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-5), + time.Now().Add(time.Hour*24*-5).Add(time.Minute*10), + time.Minute*5, + ), + generateSampleStream( + map[string]string{}, + time.Now().Add(time.Hour*24*-2), + time.Now().Add(time.Hour*24*-2).Add(time.Minute*20), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(sometimes) by (foo)`}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"foo": "aaa"}, + time.Now().Add(time.Hour*24*-7), + time.Now().Add(time.Hour*24*-7).Add(time.Hour), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"foo": "bbb"}, + time.Now().Add(time.Hour*24*-5), + time.Now().Add(time.Hour*24*-5).Add(time.Minute*10), + time.Minute*5, + ), + generateSampleStream( + map[string]string{"foo": "ccc"}, + time.Now().Add(time.Hour*24*-2), + time.Now().Add(time.Hour*24*-2).Add(time.Minute*20), + time.Minute*5, + ), + }, + }, + }, + }, }, { description: "series found, label missing", content: "- record: foo\n expr: found{job=\"notfound\"}\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `found{job="notfound"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noFilterMatchText("prom", uri, "found", `{job="notfound"}`, "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ { - Fragment: `found{job="notfound"}`, - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s has "found" metric but there are no series matching {job="notfound"} in the last 1w, "job" looks like a high churn label`, srv.URL), - Severity: checks.Warning, + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(found{job="notfound"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(found)"}, + }, + resp: respondWithSingleRangeVector1W, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(found) by (job)"}, + }, + resp: matrixResponse{ + samples: []*model.SampleStream{ + generateSampleStream( + map[string]string{"job": "found"}, + time.Now().Add(time.Hour*24*-7), + time.Now(), + time.Minute*5, + ), + }, + }, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(found{job="notfound"})`}, + }, + resp: respondWithEmptyMatrix, }, }, }, { description: "series missing, label missing", content: "- record: foo\n expr: notfound{job=\"notfound\"}\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: "notfound", + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noSeriesText("prom", uri, "notfound", "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ { - Fragment: "notfound", - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s didn't have any series for "notfound" metric in the last 1w`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(notfound{job="notfound"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: "count(notfound)"}, + }, + resp: respondWithEmptyMatrix, }, }, }, @@ -377,14 +1066,32 @@ func TestSeriesCheck(t *testing.T) { - record: foo expr: '{__name__="notfound", job="bar"}' `, - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `{__name__="notfound"}`, + Lines: []int{3}, + Reporter: checks.SeriesCheckName, + Text: noSeriesText("prom", uri, `{__name__="notfound"}`, "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ { - Fragment: `{__name__="notfound",job="bar"}`, - Lines: []int{3}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s has "{__name__=\"notfound\"}" metric but there are no series matching {job="bar"} in the last 1w, "job" looks like a high churn label`, srv.URL), - Severity: checks.Warning, + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count({__name__="notfound",job="bar"})`}, + }, + resp: respondWithEmptyVector, + }, + { + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count({__name__="notfound"})`}, + }, + resp: respondWithEmptyMatrix, }, }, }, @@ -395,7 +1102,8 @@ func TestSeriesCheck(t *testing.T) { - record: foo expr: count(notfound) == 0 `, - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), + checker: newSeriesCheck, + problems: noProblems, }, { description: "series missing but check disabled, labels", @@ -404,7 +1112,8 @@ func TestSeriesCheck(t *testing.T) { - record: foo expr: count(notfound{job="foo"}) == 0 `, - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), + checker: newSeriesCheck, + problems: noProblems, }, { description: "series missing but check disabled, negative labels", @@ -413,7 +1122,8 @@ func TestSeriesCheck(t *testing.T) { - record: foo expr: count(notfound{job!="foo"}) == 0 `, - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), + checker: newSeriesCheck, + problems: noProblems, }, { description: "series missing, disabled comment for labels", @@ -422,73 +1132,74 @@ func TestSeriesCheck(t *testing.T) { - record: foo expr: count(notfound) == 0 `, - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `notfound`, + Lines: []int{4}, + Reporter: checks.SeriesCheckName, + Text: noSeriesText("prom", uri, "notfound", "1w"), + Severity: checks.Bug, + }, + } + }, + mocks: []prometheusMock{ + { + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(notfound)`}, + }, + resp: respondWithEmptyVector, + }, { - Fragment: `notfound`, - Lines: []int{4}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s didn't have any series for "notfound" metric in the last 1w`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(notfound)`}, + }, + resp: respondWithEmptyMatrix, }, }, }, { description: "ALERTS{notfound=...}", content: "- alert: foo\n expr: count(ALERTS{notfound=\"foo\"}) >= 10\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ - { - Fragment: `ALERTS{notfound="foo"}`, - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s has "ALERTS" metric but there are no series with "notfound" label in the last 1w`, srv.URL), - Severity: checks.Bug, - }, + checker: newSeriesCheck, + problems: func(uri string) []checks.Problem { + return []checks.Problem{ + { + Fragment: `ALERTS{notfound="foo"}`, + Lines: []int{2}, + Reporter: checks.SeriesCheckName, + Text: noLabelKeyText("prom", uri, "ALERTS", "notfound", "1w"), + Severity: checks.Bug, + }, + } }, - }, - { - description: "ALERTS{alertname=...}", - content: "- alert: foo\n expr: count(ALERTS{alertname=\"foo\"}) >= 10\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ + mocks: []prometheusMock{ { - Fragment: `ALERTS{alertname="foo"}`, - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`prometheus "prom" at %s has "ALERTS" metric but there are no series matching {alertname="foo"} in the last 1w, "alertname" looks like a high churn label`, srv.URL), - Severity: checks.Warning, + conds: []requestCondition{ + requireQueryPath, + formCond{key: "query", value: `count(ALERTS{notfound="foo"})`}, + }, + resp: respondWithEmptyVector, }, - }, - }, - { - description: "step 2 error", - content: "- alert: foo\n expr: test_metric{step=\"2\"}\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ { - Fragment: "test_metric", - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`cound't run "promql/series" checks due to prometheus "prom" at %s connection error: no more retries possible`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(ALERTS)`}, + }, + resp: respondWithSingleRangeVector1W, }, - }, - }, - { - description: "step 3 error", - content: "- alert: foo\n expr: test_metric_c{step=\"3\"}\n", - checker: checks.NewSeriesCheck(simpleProm("prom", srv.URL, time.Second*5, true)), - problems: []checks.Problem{ { - Fragment: "test_metric_c", - Lines: []int{2}, - Reporter: "promql/series", - Text: fmt.Sprintf(`cound't run "promql/series" checks due to prometheus "prom" at %s connection error: no more retries possible`, srv.URL), - Severity: checks.Bug, + conds: []requestCondition{ + requireRangeQueryPath, + formCond{key: "query", value: `count(ALERTS) by (notfound)`}, + }, + resp: respondWithSingleRangeVector1W, }, }, }, } - runTests(t, testCases) + runTestsT(t, testCases) }