From f4b8f2d038b5f200371aef366182dbc93dd1ce2a Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 2 Dec 2025 18:41:51 +0100 Subject: [PATCH] Alerting: Filter alerts by data source on the back end (search) (#114717) --- .../ngalert/api/prometheus/api_prometheus.go | 8 ++ pkg/services/ngalert/models/alert_rule.go | 4 + pkg/services/ngalert/models/testing.go | 16 ++++ pkg/services/ngalert/store/alert_rule.go | 17 ++++ pkg/services/ngalert/store/alert_rule_test.go | 78 +++++++++++++++++++ 5 files changed, 123 insertions(+) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index d07e07d41fe..412a0795469 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -444,6 +444,7 @@ type paginationContext struct { panelID int64 ruleGroups []string receiverName string + dataSourceUIDs []string title string searchRuleGroup string ruleType ngmodels.RuleTypeFilter @@ -483,6 +484,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert PanelID: ctx.panelID, RuleGroups: ctx.ruleGroups, ReceiverName: ctx.receiverName, + DataSourceUIDs: ctx.dataSourceUIDs, SearchTitle: ctx.title, SearchRuleGroup: ctx.searchRuleGroup, }, @@ -737,6 +739,9 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt searchRuleGroup := opts.Query.Get("search.rule_group") span.SetAttributes(attribute.Bool("search_rule_group_set", searchRuleGroup != "")) + dataSourceUIDs := opts.Query["datasource_uid"] + span.SetAttributes(attribute.Bool("datasource_uid_set", len(dataSourceUIDs) > 0)) + var ruleType ngmodels.RuleTypeFilter switch ngmodels.RuleType(opts.Query.Get("rule_type")) { case ngmodels.RuleTypeAlerting: @@ -792,6 +797,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleGroups: ruleGroups, receiverName: receiverName, title: title, + dataSourceUIDs: dataSourceUIDs, searchRuleGroup: searchRuleGroup, ruleType: ruleType, ruleNamesSet: ruleNamesSet, @@ -899,6 +905,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru receiverName := opts.Query.Get("receiver_name") title := opts.Query.Get("search.rule_name") + dataSourceUIDs := opts.Query["datasource_uid"] searchRuleGroup := opts.Query.Get("search.rule_group") alertRuleQuery := ngmodels.ListAlertRulesQuery{ @@ -911,6 +918,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru ReceiverName: receiverName, SearchTitle: title, SearchRuleGroup: searchRuleGroup, + DataSourceUIDs: dataSourceUIDs, } ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery) if err != nil { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index e6177843e8a..7ee223e8165 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -985,6 +985,10 @@ type ListAlertRulesQuery struct { ReceiverName string TimeIntervalName string + + // DataSourceUIDs allows searching for alert rules using data sources + // that match any of the given UIDs exactly (case sensitive). + DataSourceUIDs []string // SearchTitle allows searching for alert rules that contain // the given string in their title (case insensitive) SearchTitle string diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 6fb624f52a5..d2fa78db913 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -410,6 +410,22 @@ func (a *AlertRuleMutators) WithDashboardAndPanel(dashboardUID *string, panelID } } +// WithDataSourceUID takes a list of UIDs. It adds the nth UID to the nth query in the alert (same index). +// If there are not enough queries, it adds an empty one with the given data source UID. +func (a *AlertRuleMutators) WithDataSourceUID(dsUIDs ...string) AlertRuleMutator { + return func(rule *AlertRule) { + for i, uid := range dsUIDs { + if i >= len(rule.Data) { + rule.Data = append(rule.Data, AlertQuery{ + DatasourceUID: uid, + }) + continue + } + rule.Data[i].DatasourceUID = uid + } + } +} + // WithUniqueUID returns AlertRuleMutator that generates a random UID if it is among UIDs known by the instance of mutator. // NOTE: two instances of the mutator do not share known UID. // Example #1 reuse mutator instance: diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 6cc007b4a44..285a24d0b81 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -796,6 +796,7 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L return result, nextToken, err } +// 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") if query.OrgID >= 0 { @@ -868,6 +869,22 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if len(query.DataSourceUIDs) > 0 { + orConditions := make([]string, 0, len(query.DataSourceUIDs)) + orParams := make([]interface{}, 0, len(query.DataSourceUIDs)) + for _, dsUID := range query.DataSourceUIDs { + // The 'data' column holds the alert definition as JSON. The data source's UID is in the 'datasourceUid' field. + // Instead of trying to parse that JSON, we can do a simple text search. + // All alert rules go through PreSave(), which normalizes the JSON data using json.Marshal(). + pattern := fmt.Sprintf(`"datasourceUid":"%s"`, dsUID) + sql, param := st.SQLStore.GetDialect().LikeOperator("data", true, pattern, true) + orConditions = append(orConditions, sql) + orParams = append(orParams, param) + } + + q = q.And("("+strings.Join(orConditions, " OR ")+")", orParams...) + } + if query.SearchTitle != "" { words := strings.Fields(query.SearchTitle) if len(words) > 0 { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 5a4b1947181..93f6f9e57a4 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2170,6 +2170,84 @@ func TestIntegration_ListAlertRules(t *testing.T) { } }) + t.Run("filter by DataSourceUIDs", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + + // Create rules with different data sources. + const ( + uid1 = "uid-1" + uid2 = "uid-2" + uid3 = "uid-3" + rule1UID = "rule-1" + rule2UID = "rule-2" + rule3UID = "rule-3" + rule4UID = "rule-4" + rule5UID = "rule-5" + rule6UID = "rule-6" + ) + + createRule(t, store, ruleGen.With(models.RuleGen.WithUID(rule1UID), models.RuleGen.WithDataSourceUID(uid1))) + createRule(t, store, ruleGen.With(models.RuleGen.WithUID(rule2UID), models.RuleMuts.WithDataSourceUID(uid2))) + createRule(t, store, ruleGen.With(models.RuleGen.WithUID(rule3UID), models.RuleMuts.WithDataSourceUID(uid3))) + createRule(t, store, ruleGen.With(models.RuleGen.WithUID(rule4UID), models.RuleGen.WithDataSourceUID(uid1, uid2))) + createRule(t, store, ruleGen.With(models.RuleGen.WithUID(rule5UID), models.RuleMuts.WithDataSourceUID(uid2, uid3))) + createRule(t, store, ruleGen.With(models.RuleGen.WithUID(rule6UID), models.RuleMuts.WithDataSourceUID(uid1, uid2, uid3))) + + tc := []struct { + name string + dsUIDs []string + expectedUIDs []string + }{ + { + name: "searching for uid-1 returns rules using it", + dsUIDs: []string{uid1}, + expectedUIDs: []string{rule1UID, rule4UID, rule6UID}, + }, + { + name: "searching for uid-1 and uid-2 returns rules using them", + dsUIDs: []string{uid1, uid2}, + expectedUIDs: []string{rule1UID, rule2UID, rule4UID, rule5UID, rule6UID}, + }, + { + name: "searching for uid-1, uid-2, and uid-3 returns all rules", + dsUIDs: []string{uid1, uid2, uid3}, + expectedUIDs: []string{rule1UID, rule2UID, rule3UID, rule4UID, rule5UID, rule6UID}, + }, + { + name: "searching for a non-existing UID returns no rules", + dsUIDs: []string{"non-existing"}, + }, + { + name: "searching for uid-1 and a non-existing UID returns the rules using uid-1", + dsUIDs: []string{"non-existing", uid1}, + expectedUIDs: []string{rule1UID, rule4UID, rule6UID}, + }, + { + name: "no data source filter should return all rules", + expectedUIDs: []string{rule1UID, rule2UID, rule3UID, rule4UID, rule5UID, rule6UID}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + DataSourceUIDs: tt.dsUIDs, + } + result, err := store.ListAlertRules(context.Background(), query) + require.NoError(t, err) + + got := make([]string, 0, len(result)) + for _, r := range result { + got = append(got, r.UID) + } + require.ElementsMatch(t, tt.expectedUIDs, got) + }) + } + }) + t.Run("filter by SearchTitle", func(t *testing.T) { sqlStore := db.InitTestDB(t) folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())