Alerting: Add rule_matcher filter to Prometheus rules API (#115297)

**What is this feature?**

Add `rule_matcher` filter to the Prometheus-compatible list rules API: `/api/prometheus/grafana/api/v1/rules`. It allows to filter rules by static labels (not by alert instance labels).

**Special notes:**
  - Equality (`=`) and inequality (`!=`) matchers are pushed down to the database. Regex matchers (`=~`, `!~`) are applied in-memory at the API layer.
  - SQLite: Uses GLOB pattern matching
  - MySQL / PostgreSQL: Use JSON functions to compare label values


---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Alexander Akhmetov
2025-12-16 14:13:50 +01:00
committed by GitHub
co-authored by Konrad Lalik
parent c7c1dd4ead
commit c0295d06a3
18 changed files with 1137 additions and 71 deletions
@@ -2886,6 +2886,189 @@ func TestRouteGetRuleStatuses(t *testing.T) {
})
}
})
t.Run("with rule_matcher filter", func(t *testing.T) {
fakeStore, fakeAIM, api := setupAPI(t)
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "alerting", "severity": "critical"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "Alerting", "severity": "warning"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "platform", "severity": "critical"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule4"), gen.WithLabels(map[string]string{"env": "production"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_special"), gen.WithLabels(map[string]string{"key": `value"with"quotes`}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_empty"), gen.WithLabels(map[string]string{"empty": ""}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_nonempty"), gen.WithLabels(map[string]string{"empty": "nonempty"}), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule_multiline"), gen.WithLabels(map[string]string{"description": "line1\nline2\\end\"quote"}), gen.WithNoNotificationSettings())
testCases := []struct {
name string
matchers []string
expectedUIDs []string
}{
{
name: "equality matcher filters by team=alerting",
matchers: []string{`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule1"},
},
{
name: "inequality matcher filters severity!=warning",
matchers: []string{`{"name":"severity","value":"warning","isRegex":false,"isEqual":false}`},
expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
{
name: "regex matcher filters team=~plat.*",
matchers: []string{`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`},
expectedUIDs: []string{"rule3"},
},
{
name: "not-regex matcher filters severity!~warn.*",
matchers: []string{`{"name":"severity","value":"warn.*","isRegex":true,"isEqual":false}`},
expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
{
name: "multiple matchers are ANDed",
matchers: []string{
`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`,
`{"name":"severity","value":"critical","isRegex":false,"isEqual":true}`,
},
expectedUIDs: []string{"rule1"},
},
{
name: "matcher with non-existent label returns no rules",
matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{},
},
{
name: "equality matcher is case-sensitive",
matchers: []string{`{"name":"team","value":"Alerting","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule2"},
},
{
name: "quotes in label value are handled correctly",
matchers: []string{`{"name":"key","value":"value\"with\"quotes","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule_special"},
},
{
name: "no matchers returns all rules",
matchers: []string{},
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
{
name: "empty string value matches correctly",
matchers: []string{`{"name":"empty","value":"","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_multiline"},
},
{
name: "special characters in label value are handled correctly",
matchers: []string{`{"name":"description","value":"line1\nline2\\end\"quote","isRegex":false,"isEqual":true}`},
expectedUIDs: []string{"rule_multiline"},
},
{
name: "inequality matcher on non-existent label matches all rules",
matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":false}`},
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
reqURL := "/api/v1/rules"
for i, matcher := range tc.matchers {
if i == 0 {
reqURL += "?rule_matcher=" + url.QueryEscape(matcher)
} else {
reqURL += "&rule_matcher=" + url.QueryEscape(matcher)
}
}
req, err := http.NewRequest("GET", reqURL, nil)
require.NoError(t, err)
ctx := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions},
}
resp := api.RouteGetRuleStatuses(ctx)
require.Equal(t, http.StatusOK, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
require.Equal(t, "success", res.Status)
actualUIDs := []string{}
for _, group := range res.Data.RuleGroups {
for _, rule := range group.Rules {
actualUIDs = append(actualUIDs, rule.UID)
}
}
require.ElementsMatch(t, tc.expectedUIDs, actualUIDs)
})
}
})
t.Run("pagination with rule_matcher in-memory filtering", func(t *testing.T) {
fakeStore, fakeAIM, api := setupAPI(t)
// Create 3 groups with 2 rules each:
// Group 1 & 2: team=backend (won't match filter)
// Group 3: team=frontend (will match filter)
// This tests that pagination continues fetching when early pages are filtered out
group1Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace1", RuleGroup: "group1"}
group2Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace2", RuleGroup: "group2"}
group3Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace3", RuleGroup: "group3"}
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule4"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule5"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings())
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
gen.WithUID("rule6"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings())
// Request with regex rule_matcher filter for team=~"alerting" and group_limit=1 to force pagination
matcher := `{"name":"team","value":"alerting","isRegex":true,"isEqual":true}`
reqURL := "/api/v1/rules?rule_matcher=" + url.QueryEscape(matcher) + "&group_limit=1"
req, err := http.NewRequest("GET", reqURL, nil)
require.NoError(t, err)
ctx := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions},
}
resp := api.RouteGetRuleStatuses(ctx)
require.Equal(t, http.StatusOK, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
require.Equal(t, "success", res.Status)
actualUIDs := []string{}
for _, group := range res.Data.RuleGroups {
for _, rule := range group.Rules {
actualUIDs = append(actualUIDs, rule.UID)
}
}
// Should return group3 rules (rule5, rule6), pagination should continue past filtered groups
require.ElementsMatch(t, []string{"rule5", "rule6"}, actualUIDs)
})
}
func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) {
@@ -33,6 +33,12 @@ import (
"go.opentelemetry.io/otel/trace"
)
const (
queryIncludeInternalLabels = "includeInternalLabels"
queryRuleMatcher = "rule_matcher"
queryInstanceMatcher = "matcher"
)
type RuleStoreReader interface {
GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error)
ListAlertRulesStoreV2
@@ -62,6 +68,20 @@ type PrometheusSrv struct {
// Package-level OpenTelemetry tracer per Grafana instrumentation conventions.
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/ngalert/api/prometheus")
// badRequestError returns a Prometheus-compatible error response for bad request data.
func badRequestError(err error) apimodels.RuleResponse {
return apimodels.RuleResponse{
DiscoveryBase: apimodels.DiscoveryBase{
Status: "error",
Error: err.Error(),
ErrorType: apiv1.ErrBadData,
},
Data: apimodels.RuleDiscovery{
RuleGroups: []apimodels.RuleGroup{},
},
}
}
func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status StatusReader, store RuleStoreReader, authz RuleGroupAccessControlService, provenanceStore ProvenanceStore) *PrometheusSrv {
return &PrometheusSrv{
log,
@@ -73,8 +93,6 @@ func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status
}
}
const queryIncludeInternalLabels = "includeInternalLabels"
func getBoolWithDefault(vals url.Values, field string, d bool) bool {
f := vals.Get(field)
if f == "" {
@@ -188,15 +206,15 @@ func getPanelIDFromQuery(v url.Values) (int64, error) {
return 0, nil
}
func getMatchersFromQuery(v url.Values) (labels.Matchers, error) {
func getMatchersFromQuery(v url.Values, paramName string) (labels.Matchers, error) {
var matchers labels.Matchers
for _, s := range v["matcher"] {
for _, s := range v[paramName] {
var m labels.Matcher
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, err
}
if len(m.Name) == 0 {
return nil, errors.New("bad matcher: the name cannot be blank")
return nil, fmt.Errorf("bad %s: the name cannot be blank", paramName)
}
matchers = append(matchers, &m)
}
@@ -454,6 +472,7 @@ type paginationContext struct {
stateFilterSet map[eval.State]struct{}
healthFilterSet map[string]struct{}
matchers labels.Matchers
ruleLabelMatchers labels.Matchers
labelOptions []ngmodels.LabelOption
limitAlertsPerRule int64
limitRulesPerGroup int64
@@ -476,6 +495,9 @@ func accumulateTotals(dest, source map[string]int64) {
// fetchAndFilterPage fetches one page from the store and applies filters
func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlertRulesStoreV2, span trace.Span, token string, remainingGroups, remainingRules int64) (pageResult, error) {
// Split matchers: only equality/inequality are supported by the store
storeMatchers := filterOutRegexMatchers(ctx.ruleLabelMatchers)
byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{
ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{
OrgID: ctx.opts.OrgID,
@@ -488,6 +510,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
DataSourceUIDs: ctx.dataSourceUIDs,
SearchTitle: ctx.title,
SearchRuleGroup: ctx.searchRuleGroup,
LabelMatchers: storeMatchers,
},
RuleType: ctx.ruleType,
Limit: remainingGroups,
@@ -534,6 +557,8 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
filterRulesByHealth(ruleGroup, ctx.healthFilterSet)
}
filterRulesByLabelMatchers(ruleGroup, ctx.ruleLabelMatchers)
if ctx.limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > ctx.limitRulesPerGroup {
ruleGroup.Rules = ruleGroup.Rules[0:ctx.limitRulesPerGroup]
}
@@ -546,6 +571,17 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
return result, nil
}
func filterOutRegexMatchers(matchers labels.Matchers) labels.Matchers {
var result labels.Matchers
for _, m := range matchers {
if m.Type == labels.MatchEqual || m.Type == labels.MatchNotEqual {
result = append(result, m)
}
}
return result
}
// paginateRuleGroups fetches pages until limits are satisfied applying filters at each step
func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *paginationContext, span trace.Span, maxGroups, maxRules int64, startToken string) ([]apimodels.RuleGroup, map[string]int64, string, error) {
allGroups := []apimodels.RuleGroup{}
@@ -644,21 +680,30 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
attribute.Int64("limit_rules", limitRulesPerGroup),
attribute.Int64("limit_alerts", limitAlertsPerRule),
)
matchers, err := getMatchersFromQuery(opts.Query)
matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
span.SetAttributes(attribute.Int("matcher_count", len(matchers)))
ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher)
if err != nil {
return badRequestError(err)
}
regexCount := 0
for _, m := range ruleLabelMatchers {
if m.Type == labels.MatchRegexp || m.Type == labels.MatchNotRegexp {
regexCount++
}
}
span.SetAttributes(
attribute.Int("rule_matcher_count", len(ruleLabelMatchers)),
attribute.Int("rule_matcher_regex_count", regexCount),
)
stateFilterSet, err := GetStatesFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
span.SetAttributes(
attribute.Int("state_filter_count", len(stateFilterSet)),
@@ -667,10 +712,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
healthFilterSet, err := GetHealthFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
span.SetAttributes(
attribute.Int("health_filter_count", len(healthFilterSet)),
@@ -808,6 +850,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
stateFilterSet: stateFilterSet,
healthFilterSet: healthFilterSet,
matchers: matchers,
ruleLabelMatchers: ruleLabelMatchers,
labelOptions: labelOptions,
limitAlertsPerRule: limitAlertsPerRule,
limitRulesPerGroup: limitRulesPerGroup,
@@ -833,6 +876,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
return ruleResponse
}
// nolint:gocyclo
func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse {
ruleResponse := apimodels.RuleResponse{
DiscoveryBase: apimodels.DiscoveryBase{
@@ -846,41 +890,30 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
dashboardUID := opts.Query.Get("dashboard_uid")
panelID, err := getPanelIDFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = fmt.Sprintf("invalid panel_id: %s", err.Error())
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(fmt.Errorf("invalid panel_id: %w", err))
}
if dashboardUID == "" && panelID != 0 {
ruleResponse.Status = "error"
ruleResponse.Error = "panel_id must be set with dashboard_uid"
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(errors.New("panel_id must be set with dashboard_uid"))
}
limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1)
limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1)
matchers, err := getMatchersFromQuery(opts.Query)
matchers, err := getMatchersFromQuery(opts.Query, queryInstanceMatcher)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
ruleLabelMatchers, err := getMatchersFromQuery(opts.Query, queryRuleMatcher)
if err != nil {
return badRequestError(err)
}
stateFilterSet, err := GetStatesFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
healthFilterSet, err := GetHealthFromQuery(opts.Query)
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = err.Error()
ruleResponse.ErrorType = apiv1.ErrBadData
return ruleResponse
return badRequestError(err)
}
var labelOptions []ngmodels.LabelOption
@@ -913,6 +946,9 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
dataSourceUIDs := opts.Query["datasource_uid"]
searchRuleGroup := opts.Query.Get("search.rule_group")
// Split matchers: only equality/inequality are supported by the store
storeMatchers := filterOutRegexMatchers(ruleLabelMatchers)
alertRuleQuery := ngmodels.ListAlertRulesQuery{
OrgID: opts.OrgID,
NamespaceUIDs: namespaceUIDs,
@@ -924,6 +960,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
SearchTitle: title,
SearchRuleGroup: searchRuleGroup,
DataSourceUIDs: dataSourceUIDs,
LabelMatchers: storeMatchers,
}
ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery)
if err != nil {
@@ -978,6 +1015,10 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
filterRulesByHealth(ruleGroup, healthFilterSet)
}
if len(ruleLabelMatchers) > 0 {
filterRulesByLabelMatchers(ruleGroup, ruleLabelMatchers)
}
if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup {
ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup]
}
@@ -1105,6 +1146,30 @@ func filterRulesByHealth(ruleGroup *apimodels.RuleGroup, withHealthFast map[stri
ruleGroup.Rules = filteredRules
}
func filterRulesByLabelMatchers(ruleGroup *apimodels.RuleGroup, matchers labels.Matchers) {
if len(matchers) == 0 {
return
}
filteredRules := make([]apimodels.AlertingRule, 0, len(ruleGroup.Rules))
for _, rule := range ruleGroup.Rules {
ruleLabels := rule.Labels.Map()
matches := true
for _, m := range matchers {
if !m.Matches(ruleLabels[m.Name]) {
matches = false
break
}
}
if matches {
filteredRules = append(filteredRules, rule)
}
}
ruleGroup.Rules = filteredRules
}
// This is the same as matchers.Matches but avoids the need to create a LabelSet
func matchersMatch(matchers []*labels.Matcher, labels map[string]string) bool {
for _, m := range matchers {
@@ -462,4 +462,10 @@ type GetGrafanaRuleStatusesParams struct {
// in: query
// required: false
Matchers []string `json:"matcher"`
// Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {"type":0,"name":"severity","value":"critical"}).
// For equality matchers with empty string values (e.g., name=""), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).
// in: query
// required: false
RuleLabelMatchers []string `json:"rule_matcher"`
}
@@ -7561,6 +7561,15 @@
},
"name": "matcher",
"type": "array"
},
{
"description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).",
"in": "query",
"items": {
"type": "string"
},
"name": "rule_matcher",
"type": "array"
}
],
"responses": {
@@ -1947,6 +1947,15 @@
"description": "Filter by label matchers encoded as JSON representations of Prometheus matchers (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}). Provide one matcher per query string value.",
"name": "matcher",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"description": "Filter rules by their static labels (not alert instance labels). Each value is a JSON-encoded Prometheus-like matcher (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}).\nFor equality matchers with empty string values (e.g., name=\"\"), rules that have the label with an empty value OR rules without the label will match (standard Prometheus behavior).",
"name": "rule_matcher",
"in": "query"
}
],
"responses": {
@@ -18,6 +18,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/prometheus/alertmanager/pkg/labels"
prommodels "github.com/prometheus/common/model"
"github.com/grafana/grafana-plugin-sdk-go/data"
@@ -1012,6 +1013,10 @@ type ListAlertRulesQuery struct {
SearchRuleGroup string
HasPrometheusRuleDefinition *bool
// LabelMatchers filters rules by their labels.
// Only equality and inequality matchers are supported, no regex operators.
LabelMatchers labels.Matchers
}
type ListAlertRulesExtendedQuery struct {
+39
View File
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/google/uuid"
"github.com/prometheus/alertmanager/pkg/labels"
"golang.org/x/exp/maps"
"github.com/grafana/grafana/pkg/util/xorm"
@@ -802,6 +803,15 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L
return result, nextToken, err
}
func matchersMatchLabels(matchers labels.Matchers, lbls map[string]string) bool {
for _, m := range matchers {
if !m.Matches(lbls[m.Name]) {
return false
}
}
return true
}
// nolint:gocyclo
func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.ListAlertRulesExtendedQuery) (q *xorm.Session, groupsSet map[string]struct{}, err error) {
q = sess.Table("alert_rule")
@@ -920,6 +930,13 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis
}
}
if len(query.LabelMatchers) > 0 {
q, err = st.filterByLabelMatchers(query.LabelMatchers, q)
if err != nil {
return nil, groupsSet, err
}
}
// FIXME: record is nullable but we don't save it as null when it's nil
switch query.RuleType {
case ngmodels.RuleTypeFilterAlerting:
@@ -967,6 +984,11 @@ func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesE
return nil, false
}
}
if len(query.LabelMatchers) > 0 { // remove false-positive hits from the result
if !matchersMatchLabels(query.LabelMatchers, converted.Labels) {
return nil, false
}
}
// MySQL (and potentially other databases) can use case-insensitive comparison.
// This code makes sure we return groups that only exactly match the filter.
if groupsSet != nil {
@@ -1355,6 +1377,23 @@ func (st DBstore) filterWithPrometheusRuleDefinition(value bool, sess *xorm.Sess
), nil
}
// filterByLabelMatchers adds filtering for equality and inequality label matchers.
// Returns error if regex matchers are passed.
func (st DBstore) filterByLabelMatchers(matchers labels.Matchers, sess *xorm.Session) (*xorm.Session, error) {
for _, m := range matchers {
if m.Type != labels.MatchEqual && m.Type != labels.MatchNotEqual {
return nil, fmt.Errorf("matcher %q %s %q is not supported", m.Name, m.Type, m.Value)
}
sql, args, err := buildLabelMatcherCondition(st.SQLStore.GetDialect(), "labels", m)
if err != nil {
return nil, err
}
sess = sess.And(sql, args...)
}
return sess, nil
}
func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) {
// fetch entire rules because Update method requires it because it copies rules to version table
rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{
@@ -0,0 +1,51 @@
package store
import (
"fmt"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
// buildLabelMatcherCondition builds SQL for a label matcher with Prometheus semantics.
// For MySQL/PostgreSQL, it uses JSON functions, and
// for SQLite, it uses GLOB patterns to find matching labels.
func buildLabelMatcherCondition(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) {
if dialect.DriverName() == migrator.SQLite {
return buildLabelMatcherGlob(column, m)
}
return buildLabelMatcherJSON(dialect, column, m)
}
func buildLabelMatcherGlob(column string, m *labels.Matcher) (string, []any, error) {
switch {
case m.Type == labels.MatchEqual && m.Value == "":
eqSQL, eqArgs, _ := globEquals(column, m.Name, "")
missingSQL, missingArgs, _ := globKeyMissing(column, m.Name)
return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil
case m.Type == labels.MatchEqual:
return globEquals(column, m.Name, m.Value)
case m.Type == labels.MatchNotEqual:
return globNotEquals(column, m.Name, m.Value)
default:
return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type)
}
}
func buildLabelMatcherJSON(dialect migrator.Dialect, column string, m *labels.Matcher) (string, []any, error) {
switch {
case m.Type == labels.MatchEqual && m.Value == "":
eqSQL, eqArgs := jsonEquals(dialect, column, m.Name, "")
missingSQL, missingArgs := jsonKeyMissing(dialect, column, m.Name)
return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil
case m.Type == labels.MatchEqual:
sql, args := jsonEquals(dialect, column, m.Name, m.Value)
return sql, args, nil
case m.Type == labels.MatchNotEqual:
sql, args := jsonNotEquals(dialect, column, m.Name, m.Value)
return sql, args, nil
default:
return "", nil, fmt.Errorf("unsupported matcher type: %v", m.Type)
}
}
@@ -0,0 +1,136 @@
package store
import (
"testing"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
func TestBuildLabelMatcherGlob(t *testing.T) {
tests := []struct {
name string
matcher *labels.Matcher
wantSQL string
wantArgs []any
wantErr bool
errContains string
}{
{
name: "MatchEqual with non-empty value",
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "labels GLOB ?",
wantArgs: []any{`*"team":"alerting"*`},
},
{
name: "MatchEqual with empty value (Prometheus semantics)",
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: `(labels GLOB ? OR labels NOT GLOB ?)`,
wantArgs: []any{`*"team":""*`, `*"team":*`},
},
{
name: "MatchNotEqual",
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "labels NOT GLOB ?",
wantArgs: []any{`*"team":"alerting"*`},
},
{
name: "unsupported matcher type",
matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"},
wantErr: true,
errContains: "unsupported matcher type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args, err := buildLabelMatcherGlob("labels", tt.matcher)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestBuildLabelMatcherJSON(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
matcher *labels.Matcher
wantSQL string
wantArgs []any
wantErr bool
errContains string
}{
{
name: "MySQL MatchEqual with non-empty value",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "MySQL MatchEqual with empty value",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)",
wantArgs: []any{"team", "", "team"},
},
{
name: "MySQL MatchNotEqual",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
name: "PostgreSQL MatchEqual with non-empty value",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "PostgreSQL MatchEqual with empty value",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)",
wantArgs: []any{"team", "", "team"},
},
{
name: "PostgreSQL MatchNotEqual",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
name: "unsupported matcher type",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchRegexp, Name: "team", Value: "alert.*"},
wantErr: true,
errContains: "unsupported matcher type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args, err := buildLabelMatcherJSON(tt.dialect, "labels", tt.matcher)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
@@ -13,6 +13,7 @@ import (
"github.com/benbjohnson/clock"
"github.com/google/uuid"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -2385,6 +2386,157 @@ func TestIntegration_ListAlertRules(t *testing.T) {
})
}
})
t.Run("filter by LabelMatchers", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
ruleLower := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"team": "alerting", "severity": "warning"}),
ruleGen.WithTitle("rule_lowercase")))
ruleUpper := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"team": "Alerting", "severity": "critical"}),
ruleGen.WithTitle("rule_uppercase")))
ruleSpecial := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"key": `value"with"quotes`}),
ruleGen.WithTitle("rule_special")))
ruleGlob := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"glob": "*[?]"}),
ruleGen.WithTitle("rule_glob")))
ruleSpecialChars := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"json": "line1\nline2\\end\"quote"}),
ruleGen.WithTitle("rule_special_chars")))
ruleEmpty := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"empty": ""}),
ruleGen.WithTitle("rule_empty")))
ruleNonempty := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"empty": "nonempty"}),
ruleGen.WithTitle("rule_nonempty")))
tc := []struct {
name string
labelMatchers labels.Matchers
expectedRules []*models.AlertRule
}{
{
name: "equality matcher is case-sensitive",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "alerting"); return m }(),
},
expectedRules: []*models.AlertRule{ruleLower},
},
{
name: "equality matcher matches uppercase when specified",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(),
},
expectedRules: []*models.AlertRule{ruleUpper},
},
{
name: "inequality matcher is case-sensitive",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(),
},
expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty},
},
{
name: "special characters in labels are handled correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher {
m, _ := labels.NewMatcher(labels.MatchEqual, "key", `value"with"quotes`)
return m
}(),
},
expectedRules: []*models.AlertRule{ruleSpecial},
},
{
name: "matcher with non-existent label returns no rules",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "nonexistent", "value"); return m }(),
},
expectedRules: []*models.AlertRule{},
},
{
name: "multiple matchers are ANDed",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "team", "Alerting"); return m }(),
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "severity", "critical"); return m }(),
},
expectedRules: []*models.AlertRule{ruleUpper},
},
{
name: "GLOB special characters are escaped correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "glob", "*[?]"); return m }(),
},
expectedRules: []*models.AlertRule{ruleGlob},
},
{
name: "JSON escape characters are handled correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher {
m, _ := labels.NewMatcher(labels.MatchEqual, "json", "line1\nline2\\end\"quote")
return m
}(),
},
expectedRules: []*models.AlertRule{ruleSpecialChars},
},
{
name: "empty string value matches correctly",
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(),
},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty},
},
{
name: "inequality matcher on non-existent label matches all rules",
labelMatchers: labels.Matchers{
func() *labels.Matcher {
m, _ := labels.NewMatcher(labels.MatchNotEqual, "nonexistent", "value")
return m
}(),
},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
LabelMatchers: tt.labelMatchers,
}
result, err := store.ListAlertRules(context.Background(), query)
require.NoError(t, err)
require.ElementsMatch(t, tt.expectedRules, result)
})
}
t.Run("regex matcher returns error from store", func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
LabelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchRegexp, "team", "alert.*"); return m }(),
},
}
_, err := store.ListAlertRules(context.Background(), query)
require.Error(t, err)
require.ErrorContains(t, err, "is not supported")
})
t.Run("not-regex matcher returns error from store", func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
LabelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotRegexp, "team", "alert.*"); return m }(),
},
}
_, err := store.ListAlertRules(context.Background(), query)
require.Error(t, err)
require.ErrorContains(t, err, "is not supported")
})
})
}
func TestIntegration_ListAlertRulesPaginated(t *testing.T) {
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"encoding/json"
"fmt"
"strings"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
// JSON functions for MySQL/PostgreSQL
func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) {
switch dialect.DriverName() {
case migrator.MySQL:
return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value}
case migrator.Postgres:
return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value}
default:
return "", nil
}
}
func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, []any) {
var jx string
switch dialect.DriverName() {
case migrator.MySQL:
jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column)
case migrator.Postgres:
jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column)
default:
return "", nil
}
return fmt.Sprintf("(%s IS NULL OR %s != ?)", jx, jx), []any{key, key, value}
}
func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) {
switch dialect.DriverName() {
case migrator.MySQL:
return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key}
case migrator.Postgres:
return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key}
default:
return "", nil
}
}
// GLOB functions for SQLite
func globEquals(column, key, value string) (string, []any, error) {
pattern, err := buildGlobPattern(key, value)
if err != nil {
return "", nil, err
}
return column + " GLOB ?", []any{"*" + pattern + "*"}, nil
}
func globNotEquals(column, key, value string) (string, []any, error) {
pattern, err := buildGlobPattern(key, value)
if err != nil {
return "", nil, err
}
return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil
}
func globKeyMissing(column, key string) (string, []any, error) {
pattern, err := buildGlobKeyPattern(key)
if err != nil {
return "", nil, err
}
return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil
}
// Search for `"key":"value"`
func buildGlobPattern(key, value string) (string, error) {
keyJSON, err := json.Marshal(key)
if err != nil {
return "", fmt.Errorf("failed to marshal key: %w", err)
}
valueJSON, err := json.Marshal(value)
if err != nil {
return "", fmt.Errorf("failed to marshal value: %w", err)
}
return escapeGlobPattern(fmt.Sprintf(`%s:%s`, string(keyJSON), string(valueJSON))), nil
}
// Search for `"key":`
func buildGlobKeyPattern(key string) (string, error) {
keyJSON, err := json.Marshal(key)
if err != nil {
return "", fmt.Errorf("failed to marshal key: %w", err)
}
return escapeGlobPattern(string(keyJSON) + ":"), nil
}
func escapeGlobPattern(pattern string) string {
pattern = strings.ReplaceAll(pattern, "[", "[[]")
pattern = strings.ReplaceAll(pattern, "*", "[*]")
pattern = strings.ReplaceAll(pattern, "?", "[?]")
return pattern
}
+185
View File
@@ -0,0 +1,185 @@
package store
import (
"testing"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/stretchr/testify/require"
)
func TestJsonEquals(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
column string
key string
value string
wantSQL string
wantArgs []any
}{
{
name: "MySQL",
dialect: migrator.NewMysqlDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "PostgreSQL",
dialect: migrator.NewPostgresDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?",
wantArgs: []any{"team", "alerting"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args := jsonEquals(tt.dialect, tt.column, tt.key, tt.value)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestJsonNotEquals(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
column string
key string
value string
wantSQL string
wantArgs []any
}{
{
name: "MySQL",
dialect: migrator.NewMysqlDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
name: "PostgreSQL",
dialect: migrator.NewPostgresDialect(),
column: "labels",
key: "team",
value: "alerting",
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args := jsonNotEquals(tt.dialect, tt.column, tt.key, tt.value)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestJsonKeyMissing(t *testing.T) {
tests := []struct {
name string
dialect migrator.Dialect
column string
key string
wantSQL string
wantArgs []any
}{
{
name: "MySQL",
dialect: migrator.NewMysqlDialect(),
column: "labels",
key: "team",
wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL",
wantArgs: []any{"team"},
},
{
name: "PostgreSQL",
dialect: migrator.NewPostgresDialect(),
column: "labels",
key: "team",
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL",
wantArgs: []any{"team"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, args := jsonKeyMissing(tt.dialect, tt.column, tt.key)
require.Equal(t, tt.wantSQL, sql)
require.Equal(t, tt.wantArgs, args)
})
}
}
func TestGlobEquals(t *testing.T) {
sql, args, err := globEquals("labels", "team", "alerting")
require.NoError(t, err)
require.Equal(t, "labels GLOB ?", sql)
require.Equal(t, []any{`*"team":"alerting"*`}, args)
}
func TestGlobNotEquals(t *testing.T) {
sql, args, err := globNotEquals("labels", "team", "alerting")
require.NoError(t, err)
require.Equal(t, "labels NOT GLOB ?", sql)
require.Equal(t, []any{`*"team":"alerting"*`}, args)
}
func TestGlobKeyMissing(t *testing.T) {
sql, args, err := globKeyMissing("labels", "team")
require.NoError(t, err)
require.Equal(t, "labels NOT GLOB ?", sql)
require.Equal(t, []any{`*"team":*`}, args)
}
func TestBuildGlobPattern(t *testing.T) {
tests := []struct {
name string
key string
value string
expected string
}{
{
name: "simple key-value",
key: "team",
value: "alerting",
expected: `"team":"alerting"`,
},
{
name: "empty value",
key: "empty",
value: "",
expected: `"empty":""`,
},
{
name: "special GLOB chars are escaped",
key: "key",
value: "*[?]",
expected: `"key":"[*][[][?]]"`,
},
{
name: "special chars are escaped",
key: "key",
value: "line1\nline2\\end\"quote",
expected: `"key":"line1\nline2\\end\"quote"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pattern, err := buildGlobPattern(tt.key, tt.value)
require.NoError(t, err)
require.Equal(t, tt.expected, pattern)
})
}
}
+15
View File
@@ -219,6 +219,7 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert
RuleUIDs: q.RuleUIDs,
ReceiverName: q.ReceiverName,
HasPrometheusRuleDefinition: q.HasPrometheusRuleDefinition,
LabelMatchers: q.LabelMatchers,
}
ruleList, err := f.listAlertRules(query)
@@ -355,6 +356,20 @@ func (f *RuleStore) listAlertRules(q *models.ListAlertRulesQuery) (models.RulesG
if q.ReceiverName != "" && (len(r.NotificationSettings) < 1 || r.NotificationSettings[0].Receiver != q.ReceiverName) {
continue
}
if len(q.LabelMatchers) > 0 {
matches := true
for _, m := range q.LabelMatchers {
if !m.Matches(r.Labels[m.Name]) {
matches = false
break
}
}
if !matches {
continue
}
}
copyR := models.CopyRule(r)
ruleList = append(ruleList, copyR)
}
+65 -4
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"testing"
"time"
@@ -364,8 +365,6 @@ func TestIntegrationPrometheusRules(t *testing.T) {
func TestIntegrationPrometheusRulesPagination(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
testinfra.SQLiteIntegrationTest(t)
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
@@ -388,23 +387,30 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) {
require.NoError(t, err)
// Create 3 rule groups with different numbers of rules
// Group 1: 5 rules, Group 2: 3 rules, Group 3: 2 rules (total: 10 rules)
// Group 1: 5 rules with team=backend
// Group 2: 3 rules with team=frontend
// Group 3: 2 rules with team=platform
for groupIdx := 1; groupIdx <= 3; groupIdx++ {
var rulesCount int
var team string
switch groupIdx {
case 1:
rulesCount = 5
team = "backend"
case 2:
rulesCount = 3
team = "frontend"
case 3:
rulesCount = 2
team = "platform"
}
rules := make([]apimodels.PostableExtendedRuleNode, rulesCount)
for i := 0; i < rulesCount; i++ {
rules[i] = apimodels.PostableExtendedRuleNode{
ApiRuleNode: &apimodels.ApiRuleNode{
For: &interval,
For: &interval,
Labels: map[string]string{"team": team},
},
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: fmt.Sprintf("rule-%d-%d", groupIdx, i+1),
@@ -514,6 +520,61 @@ func TestIntegrationPrometheusRulesPagination(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, result.Data.RuleGroups, 0, "should return no groups")
})
t.Run("with rule_matcher filter returns only matching rules", func(t *testing.T) {
matcher := url.QueryEscape(`{"name":"team","value":"frontend","isRegex":false,"isEqual":true}`)
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Should only return group-2 (team=frontend, 3 rules)
foundGroups := []string{}
total := 0
for _, group := range result.Data.RuleGroups {
foundGroups = append(foundGroups, group.Name)
total += len(group.Rules)
}
require.Equal(t, []string{"group-2"}, foundGroups)
require.Equal(t, 3, total)
})
t.Run("with rule_matcher regex filter", func(t *testing.T) {
// Filter with regex team=~plat.* (should match group-3 with team=platform)
matcher := url.QueryEscape(`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`)
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_matcher=%s", grafanaListedAddr, matcher)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Should only return group-3 (team=platform matches plat.*)
foundGroups := []string{}
total := 0
for _, group := range result.Data.RuleGroups {
foundGroups = append(foundGroups, group.Name)
total += len(group.Rules)
}
require.Equal(t, []string{"group-3"}, foundGroups)
require.Equal(t, 2, total)
})
}
func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
@@ -47,6 +47,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
title?: string;
searchGroupName?: string;
type?: 'alerting' | 'recording';
ruleMatchers?: string[];
};
export const prometheusApi = alertingApi.injectEndpoints({
@@ -102,6 +103,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
datasources,
searchGroupName,
dashboardUid,
ruleMatchers,
}) => ({
url: `api/prometheus/grafana/api/v1/rules`,
params: {
@@ -120,6 +122,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
'search.rule_name': title,
'search.rule_group': searchGroupName,
dashboard_uid: dashboardUid,
rule_matcher: ruleMatchers,
},
}),
providesTags: (_result, _error, { folderUid, groupName, ruleName }) => {
@@ -1,9 +1,12 @@
import { testWithFeatureToggles } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto';
import { mockGrafanaPromAlertingRule, mockPromRecordingRule } from '../../mocks';
import { RuleHealth } from '../../search/rulesSearchParser';
import { pluginMeta, pluginMetaToPluginConfig } from '../../testSetup/plugins';
import { SupportedPlugin } from '../../types/pluginBridges';
import { Annotation } from '../../utils/constants';
import { getDatasourceAPIUid } from '../../utils/datasource';
import { getFilter } from '../../utils/search';
@@ -416,19 +419,40 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.groupMatches(group)).toBe(true);
});
it('should include ruleMatchers in backend filter when labels are provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(backendFilter.ruleMatchers).toBeDefined();
expect(backendFilter.ruleMatchers).toHaveLength(1);
expect(backendFilter.ruleMatchers).toEqual([
'{"name":"severity","value":"critical","isRegex":false,"isEqual":true}',
]);
});
it('should still apply other frontend filters', () => {
const rule = mockGrafanaPromAlertingRule({
// Set up test plugin as installed
config.apps[SupportedPlugin.Slo] = pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]);
const regularRule = mockGrafanaPromAlertingRule({
name: 'High CPU Usage',
labels: { severity: 'critical', team: 'ops' },
alerts: [],
});
// Label filter should still work on frontend
const { frontendFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] }));
expect(frontendFilter.ruleMatches(rule)).toBe(false);
const pluginRule = mockGrafanaPromAlertingRule({
name: 'Plugin Rule',
labels: { __grafana_origin: `plugin/${SupportedPlugin.Slo}` },
alerts: [],
});
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(frontendFilter2.ruleMatches(rule)).toBe(true);
// Plugins filter should still work on frontend
const { frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' }));
// Non-plugin rules should pass through
expect(frontendFilter.ruleMatches(regularRule)).toBe(true);
// Plugin-provided rules should be filtered out
expect(frontendFilter.ruleMatches(pluginRule)).toBe(false);
});
});
@@ -681,20 +705,7 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.groupMatches(group)).toBe(true);
});
it('should still apply always-frontend filters (labels, namespace)', () => {
const rule = mockGrafanaPromAlertingRule({
name: 'High CPU Usage',
labels: { severity: 'critical' },
alerts: [],
});
// Labels filter should still work
const { frontendFilter: labelFilter } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] }));
expect(labelFilter.ruleMatches(rule)).toBe(false);
const { frontendFilter: labelFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(labelFilter2.ruleMatches(rule)).toBe(true);
it('should still apply always-frontend filters (namespace)', () => {
// Namespace filter should still work
const group: PromRuleGroupDTO = {
name: 'Test Group',
@@ -791,9 +802,13 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
});
it('should return false for labels (handled by backend when feature toggle is enabled)', () => {
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false);
});
it('should return true for client-side only filters', () => {
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true);
});
it('should return false for backend-only filters (state, health, contactPoint)', () => {
@@ -816,12 +831,13 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: frontend-handled filters
// Should return true for: frontend-handled filters (labels, namespace, plugins)
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true);
});
});
@@ -840,12 +856,13 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: always-frontend filters only (namespace, labels)
// Should return true for: always-frontend filters only (namespace, plugins)
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true);
// Should return false for: backend-handled dataSourceNames when feature toggles are enabled
// Should return false for: backend-handled filters when both feature toggles are enabled
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false);
});
});
});
@@ -1,8 +1,11 @@
import { attempt, isError } from 'lodash';
import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { GrafanaPromRulesOptions } from '../../api/prometheusApi';
import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../../featureToggles';
import { RulesFilter } from '../../search/rulesSearchParser';
import { parseMatcher } from '../../utils/matchers';
import { buildTitleSearch, normalizeFilterState } from './filterNormalization';
import {
@@ -75,6 +78,12 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
hasInvalidDataSourceNames = datasourceUids.length === 0;
}
// Convert labels to JSON-encoded matchers for backend filtering
const ruleMatchersBackendFilter: string[] | undefined =
ruleFilterConfig.labels || normalizedFilterState.labels.length === 0
? undefined
: labelMatchersToBackendFormat(normalizedFilterState.labels);
const backendFilter: GrafanaPromRulesOptions = {
state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [],
health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [],
@@ -85,6 +94,7 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
dashboardUid: ruleFilterConfig.dashboardUid ? undefined : normalizedFilterState.dashboardUid,
searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName,
datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids,
ruleMatchers: ruleMatchersBackendFilter,
};
return {
@@ -115,7 +125,7 @@ function buildGrafanaFilterConfigs() {
ruleState: null,
ruleType: useBackendFilters || useFullyCompatibleBackendFilters ? null : ruleTypeFilter,
dataSourceNames: useBackendFilters || useFullyCompatibleBackendFilters ? null : dataSourceNamesFilter,
labels: labelsFilter,
labels: useBackendFilters ? null : labelsFilter,
ruleHealth: null,
dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter,
plugins: pluginsFilter,
@@ -129,3 +139,21 @@ function buildGrafanaFilterConfigs() {
return { ruleFilterConfig, groupFilterConfig };
}
/**
* Converts label matchers to JSON-encoded strings for backend filtering.
* Invalid matchers are logged and filtered out.
*/
function labelMatchersToBackendFormat(labels: string[]): string[] {
return labels.reduce<string[]>((acc, label) => {
const result = attempt(() => JSON.stringify(parseMatcher(label)));
if (isError(result)) {
console.warn('Failed to parse label matcher:', label, result);
} else {
acc.push(result);
}
return acc;
}, []);
}
@@ -74,6 +74,7 @@ describe('paginationLimits', () => {
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -86,7 +87,6 @@ describe('paginationLimits', () => {
it.each<Partial<RulesFilter>>([
{ namespace: 'production' },
{ labels: ['severity=critical'] },
{ ruleState: PromAlertingRuleState.Firing, namespace: 'production' },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
@@ -157,6 +157,7 @@ describe('paginationLimits', () => {
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -167,7 +168,7 @@ describe('paginationLimits', () => {
}
);
it.each<Partial<RulesFilter>>([{ namespace: 'production' }, { labels: ['severity=critical'] }])(
it.each<Partial<RulesFilter>>([{ namespace: 'production' }])(
'should return large limits for both when frontend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));