diff --git a/.golangci.yml b/.golangci.yml index 7dee10b5f3..b63e33e9de 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -44,6 +44,8 @@ linters: - nilnil - nlreturn - noctx + # TODO(bwplotka): Remove once https://github.com/golangci/golangci-lint/issues/3228 is fixed. + - nolintlint - nonamedreturns - nosprintfhostport - paralleltest diff --git a/cmd/rule-evaluator/internal/alerts_test.go b/cmd/rule-evaluator/internal/alerts_test.go index c9a6c33de4..1be498d423 100644 --- a/cmd/rule-evaluator/internal/alerts_test.go +++ b/cmd/rule-evaluator/internal/alerts_test.go @@ -15,6 +15,7 @@ package internal import ( + "context" "io" "net/http" "net/http/httptest" @@ -23,6 +24,8 @@ import ( "github.com/go-kit/log" "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/model/timestamp" + "github.com/prometheus/prometheus/promql" "github.com/prometheus/prometheus/promql/parser" "github.com/prometheus/prometheus/rules" "github.com/stretchr/testify/assert" @@ -32,31 +35,84 @@ import ( func TestAPI_HandleAlertsEndpoint(t *testing.T) { t.Parallel() - api := &API{ - rulesManager: RuleGroupsRetrieverMock{ - AlertingRulesFunc: func() []*rules.AlertingRule { - return []*rules.AlertingRule{ - rules.NewAlertingRule("test-alert-1", &parser.NumberLiteral{Val: 33}, time.Hour, time.Hour*4, []labels.Label{{Name: "instance", Value: "localhost:9090"}}, []labels.Label{{Name: "summary", Value: "Test alert 1"}, {Name: "description", Value: "This is a test alert"}}, nil, "", false, log.NewNopLogger()), - rules.NewAlertingRule("test-alert-2", &parser.NumberLiteral{Val: 33}, time.Hour, time.Hour*4, []labels.Label{{Name: "instance", Value: "localhost:9090"}}, []labels.Label{{Name: "summary", Value: "Test alert 2"}, {Name: "description", Value: "This is another test alert"}}, nil, "", false, log.NewNopLogger()), - } + newAlertRule := func(name string) *rules.AlertingRule { + return rules.NewAlertingRule(name, &parser.NumberLiteral{Val: 33}, time.Hour, time.Hour*4, []labels.Label{{Name: "instance", Value: "localhost:9090"}}, []labels.Label{{Name: "summary", Value: "Test alert"}, {Name: "description", Value: "This is a test alert"}}, nil, "", false, log.NewNopLogger()) + } + + // Alerting rule with active (firing) alerts. + newFiringAlertRule := func(name string) *rules.AlertingRule { + a := newAlertRule(name) + + ts, _ := time.Parse(time.RFC3339Nano, "2025-04-11T14:03:59.791816+01:00") + // AlertingRule does not allow injecting active alerts, so we use Eval with a fake querier + // that always return 2 series, which will cause alerting rule to contain 2 active alerts. + _, err := a.Eval(t.Context(), ts, func(context.Context, string, time.Time) (promql.Vector, error) { + return promql.Vector{ + promql.Sample{T: timestamp.FromTime(ts), F: 10, Metric: labels.FromStrings("foo", "bar")}, + promql.Sample{T: timestamp.FromTime(ts), F: 11, Metric: labels.FromStrings("foo", "bar2")}, + }, nil + }, nil, 0) + require.NoError(t, err) + return a + } + + logger := log.NewNopLogger() + for _, tcase := range []struct { + name string + alertingRules []*rules.AlertingRule + expectedJSON string + }{ + { + name: "no alerts", + alertingRules: []*rules.AlertingRule{}, + expectedJSON: `{"status":"success","data":{"alerts":[]}}`, + }, + { + name: "no firing alerts", + alertingRules: []*rules.AlertingRule{ + newAlertRule("test-alert-1"), + newAlertRule("test-alert-2"), }, + // Alert API returns only active alerts. + expectedJSON: `{"status":"success","data":{"alerts":[]}}`, }, - logger: log.NewNopLogger(), - } - w := httptest.NewRecorder() + { + name: "mix of firing and not-firing alerts", + alertingRules: []*rules.AlertingRule{ + newAlertRule("test-alert-1"), + newFiringAlertRule("test-alert-2"), + }, + expectedJSON: `{"status":"success","data":{"alerts":[{"labels":{"alertname":"test-alert-2","foo":"bar2","instance":"localhost:9090"},"annotations":{"description":"This is a test alert","summary":"Test alert"},"state":"pending","activeAt":"2025-04-11T14:03:59.791816+01:00","value":"1.1e+01"},{"labels":{"alertname":"test-alert-2","foo":"bar","instance":"localhost:9090"},"annotations":{"description":"This is a test alert","summary":"Test alert"},"state":"pending","activeAt":"2025-04-11T14:03:59.791816+01:00","value":"1e+01"}]}}`, + }, + { + name: "only firing alerts", + alertingRules: []*rules.AlertingRule{ + newFiringAlertRule("test-alert-1"), + newFiringAlertRule("test-alert-2"), + }, + expectedJSON: `{"status":"success","data":{"alerts":[{"labels":{"alertname":"test-alert-1","foo":"bar2","instance":"localhost:9090"},"annotations":{"description":"This is a test alert","summary":"Test alert"},"state":"pending","activeAt":"2025-04-11T14:03:59.791816+01:00","value":"1.1e+01"},{"labels":{"alertname":"test-alert-1","foo":"bar","instance":"localhost:9090"},"annotations":{"description":"This is a test alert","summary":"Test alert"},"state":"pending","activeAt":"2025-04-11T14:03:59.791816+01:00","value":"1e+01"},{"labels":{"alertname":"test-alert-2","foo":"bar2","instance":"localhost:9090"},"annotations":{"description":"This is a test alert","summary":"Test alert"},"state":"pending","activeAt":"2025-04-11T14:03:59.791816+01:00","value":"1.1e+01"},{"labels":{"alertname":"test-alert-2","foo":"bar","instance":"localhost:9090"},"annotations":{"description":"This is a test alert","summary":"Test alert"},"state":"pending","activeAt":"2025-04-11T14:03:59.791816+01:00","value":"1e+01"}]}}`, + }, + } { + t.Run(tcase.name, func(t *testing.T) { + t.Parallel() - req := httptest.NewRequest(http.MethodGet, "/api/v1/alerts", nil) + api := &API{ + rulesManager: RuleGroupsRetrieverMock{ + AlertingRulesFunc: func() []*rules.AlertingRule { return tcase.alertingRules }, + }, + logger: logger, + } + w := httptest.NewRecorder() + api.HandleAlertsEndpoint(w, httptest.NewRequest(http.MethodGet, "/api/v1/alerts", nil)) - api.HandleAlertsEndpoint(w, req) + result := w.Result() + defer result.Body.Close() - result := w.Result() - defer result.Body.Close() + data, err := io.ReadAll(result.Body) + require.NoError(t, err) - data, err := io.ReadAll(result.Body) - if err != nil { - t.Errorf("Error: %v", err) + assert.Equal(t, http.StatusOK, result.StatusCode) + require.JSONEq(t, tcase.expectedJSON, string(data)) + }) } - - assert.Equal(t, http.StatusOK, result.StatusCode) - require.JSONEq(t, `{"status":"success","data":{"alerts":[]}}`, string(data)) } diff --git a/cmd/rule-evaluator/internal/api.go b/cmd/rule-evaluator/internal/api.go index 610785e846..85dc457d95 100644 --- a/cmd/rule-evaluator/internal/api.go +++ b/cmd/rule-evaluator/internal/api.go @@ -17,7 +17,9 @@ package internal import ( "encoding/json" "net/http" + "sort" "strconv" + "strings" "time" "github.com/go-kit/log" @@ -134,6 +136,16 @@ func alertsToAPIAlerts(alerts []*rules.Alert) []*apiv1.Alert { Value: strconv.FormatFloat(ruleAlert.Value, 'e', -1, 64), }) } - + // Sort for testability. + sort.Slice(apiAlerts, func(i, j int) bool { + a, b := apiAlerts[i].Labels.Hash(), apiAlerts[j].Labels.Hash() + if a == b { + a, b = apiAlerts[i].Annotations.Hash(), apiAlerts[j].Annotations.Hash() + } + if a == b { + return strings.Compare(apiAlerts[i].State, apiAlerts[j].State) < 0 // firing before pending. + } + return a >= b + }) return apiAlerts }