Alerting: Fix rule matching when queries contain comments (#110933)

Update hashQuery function to handle comments in query
This commit is contained in:
Konrad Lalik
2025-09-11 12:47:48 +02:00
committed by GitHub
parent 4161f3a5ca
commit 2fc9b29cf8
2 changed files with 108 additions and 0 deletions
@@ -1,3 +1,4 @@
import { mockPromRecordingRule } from '../mocks';
import { alertingFactory } from '../mocks/server/db';
import { getMatchingPromRule, getMatchingRulerRule, matchRulesGroup } from './ruleMatching';
@@ -114,6 +115,36 @@ describe('getMatchingRulerRule', () => {
const match = getMatchingRulerRule(rulerGroup, promRule);
expect(match).toBeUndefined();
});
it('should match recording rule with comments in expr against prometheus rule without comments', () => {
// Create multiple ruler recording rules with same name to force query comparison
const rulerRuleWithComments = alertingFactory.ruler.recordingRule.build({
record: 'service_condition',
labels: {},
expr: `# Check production services first
max by (service) (up{env="production"})
or
# Fall back to staging for missing production metrics
max by (service) (up{env="staging"}) * 0.8`,
});
// Add another ruler rule with same name but different query to force comparison logic
const rulerRuleOther = alertingFactory.ruler.recordingRule.build({
record: 'service_condition',
labels: {},
expr: `up == 1`,
});
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRuleWithComments, rulerRuleOther] });
// Create corresponding prometheus rule with the same query but comments stripped and normalized
const promRule = mockPromRecordingRule({
name: 'service_condition',
labels: {},
query: `max by (service) (up{env="production"}) or max by (service) (up{env="staging"}) * 0.8`,
});
const match = getMatchingRulerRule(rulerGroup, promRule);
expect(match).toBe(rulerRuleWithComments);
});
});
describe('getMatchingPromRule', () => {
@@ -228,6 +259,36 @@ describe('getMatchingPromRule', () => {
const match = getMatchingPromRule(promGroup, rulerRule);
expect(match).toBeUndefined();
});
it('should match prometheus recording rule without comments against ruler rule with comments', () => {
// Create multiple prometheus recording rules with same name to force query comparison
const promRuleWithoutComments = mockPromRecordingRule({
name: 'service_condition',
labels: {},
query: `max by (service) (up{env="production"}) or max by (service) (up{env="staging"}) * 0.8`,
});
// Add another prometheus rule with same name but different query to force comparison logic
const promRuleOther = mockPromRecordingRule({
name: 'service_condition',
labels: {},
query: `up == 1`,
});
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRuleWithoutComments, promRuleOther] });
// Create corresponding ruler rule with comments in the expression
const rulerRule = alertingFactory.ruler.recordingRule.build({
record: 'service_condition',
labels: {},
expr: `# Check production services first
max by (service) (up{env="production"})
or
# Fall back to staging for missing production metrics
max by (service) (up{env="staging"}) * 0.8`,
});
const match = getMatchingPromRule(promGroup, rulerRule);
expect(match).toBe(promRuleWithoutComments);
});
});
describe('matchRulesGroup', () => {
@@ -340,4 +401,44 @@ describe('matchRulesGroup', () => {
expect(result.promOnlyRules).toHaveLength(1);
expect(result.promOnlyRules[0]).toBe(promRule3);
});
it('should match rules group where ruler rules have comments and prometheus rules do not', () => {
// Create ruler recording rules with comments - both have same name and empty labels like in real scenario
const rulerRule1 = alertingFactory.ruler.recordingRule.build({
record: 'service_condition',
labels: {},
expr: `(max by (environment, namespace, service, area) (service_condition{area_check!="", monitored="true"}))`,
});
const rulerRule2 = alertingFactory.ruler.recordingRule.build({
record: 'service_condition',
labels: {},
expr: `# Check production services first
max by (service) (up{env="production"})
or
# Fall back to staging for missing production metrics
max by (service) (up{env="staging"}) * 0.8`,
});
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
// Create corresponding prometheus rules without comments (normalized queries) - both have same name and empty labels
const promRule1 = mockPromRecordingRule({
name: 'service_condition',
labels: {},
query: `(max by (environment, namespace, service, area) (service_condition{area_check!="",monitored="true"}))`,
});
const promRule2 = mockPromRecordingRule({
name: 'service_condition',
labels: {},
query: `max by (service) (up{env="production"}) or max by (service) (up{env="staging"}) * 0.8`,
});
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
const result = matchRulesGroup(rulerGroup, promGroup);
// Both rules should be matched despite comments in ruler rules
expect(result.matches.size).toBe(2);
expect(result.matches.get(rulerRule1)).toBe(promRule1);
expect(result.matches.get(rulerRule2)).toBe(promRule2);
expect(result.promOnlyRules).toHaveLength(0);
});
});
@@ -307,6 +307,13 @@ export function getPromRuleFingerprint(rule: Rule, includeQuery: boolean) {
// there can be slight differences in how prom & ruler render a query, this will hash them accounting for the differences
export function hashQuery(query: string) {
// remove comments
query = query
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.join('\n');
// one of them might be wrapped in parens
if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') {
query = query.slice(1, -1);