Alerting: Add compatibility between prometheus and ruler identifiers (#92496)

* Unify Prom and Ruler rules hash creation

* Add tests, refarct prom hash
pull/92733/head
Konrad Lalik 10 months ago committed by GitHub
parent 372d0acec8
commit e0950a1283
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
  2. 4
      public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.ts.snap
  3. 84
      public/app/features/alerting/unified/utils/rule-id.test.ts
  4. 84
      public/app/features/alerting/unified/utils/rule-id.ts

@ -31,6 +31,7 @@ import {
isCloudRulesSource, isCloudRulesSource,
isGrafanaRulesSource, isGrafanaRulesSource,
} from '../utils/datasource'; } from '../utils/datasource';
import { hashQuery } from '../utils/rule-id';
import { import {
isAlertingRule, isAlertingRule,
isAlertingRulerRule, isAlertingRulerRule,
@ -452,18 +453,6 @@ function isCombinedRuleEqualToPromRule(combinedRule: CombinedRule, rule: Rule, c
return false; return false;
} }
// there can be slight differences in how prom & ruler render a query, this will hash them accounting for the differences
function hashQuery(query: string) {
// one of them might be wrapped in parens
if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') {
query = query.slice(1, -1);
}
// whitespace could be added or removed
query = query.replace(/\s|\n/g, '');
// labels matchers can be reordered, so sort the enitre string, esentially comparing just the character counts
return query.split('').sort().join('');
}
/* /*
This hook returns combined Grafana rules. Optionally, it can filter rules by dashboard UID and panel ID. This hook returns combined Grafana rules. Optionally, it can filter rules by dashboard UID and panel ID.
*/ */

@ -1,5 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`hashRulerRule should hash alerting rule 1`] = `"1465866290"`; exports[`hashRulerRule should hash alerting rule 1`] = `"7317348"`;
exports[`hashRulerRule should hash recording rules 1`] = `"2044193757"`; exports[`hashRulerRule should hash recording rules 1`] = `"-447747460"`;

@ -1,15 +1,50 @@
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { RuleIdentifier } from 'app/types/unified-alerting'; import { AlertingRule, RecordingRule, RuleIdentifier } from 'app/types/unified-alerting';
import { import {
GrafanaAlertStateDecision, GrafanaAlertStateDecision,
GrafanaRuleDefinition, GrafanaRuleDefinition,
PromAlertingRuleState,
PromRuleType,
RulerAlertingRuleDTO, RulerAlertingRuleDTO,
RulerGrafanaRuleDTO, RulerGrafanaRuleDTO,
RulerRecordingRuleDTO, RulerRecordingRuleDTO,
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { hashRulerRule, parse, stringifyIdentifier, getRuleIdFromPathname } from './rule-id'; import { hashRulerRule, parse, stringifyIdentifier, getRuleIdFromPathname, hashRule, equal } from './rule-id';
const alertingRule = {
prom: {
name: 'cpu-over-90',
query: 'cpu_usage_seconds_total{job="integrations/node_exporter"} > 90',
labels: { type: 'cpu' },
annotations: { description: 'CPU usage too high' },
state: PromAlertingRuleState.Firing,
type: PromRuleType.Alerting,
health: 'ok',
} satisfies AlertingRule,
ruler: {
alert: 'cpu-over-90',
expr: 'cpu_usage_seconds_total{job="integrations/node_exporter"} > 90',
labels: { type: 'cpu' },
annotations: { description: 'CPU usage too high' },
} satisfies RulerAlertingRuleDTO,
};
const recordingRule = {
prom: {
name: 'instance:node_num_cpu:sum',
type: PromRuleType.Recording,
health: 'ok',
query: 'count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"})',
labels: { type: 'cpu' },
} satisfies RecordingRule,
ruler: {
record: 'instance:node_num_cpu:sum',
expr: 'count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"})',
labels: { type: 'cpu' },
} satisfies RulerRecordingRuleDTO,
};
describe('hashRulerRule', () => { describe('hashRulerRule', () => {
it('should not hash unknown rule types', () => { it('should not hash unknown rule types', () => {
@ -18,8 +53,9 @@ describe('hashRulerRule', () => {
expect(() => { expect(() => {
// @ts-ignore // @ts-ignore
hashRulerRule(unknownRule); hashRulerRule(unknownRule);
}).toThrowError(); }).toThrow('Only recording and alerting ruler rules can be hashed');
}); });
it('should hash recording rules', () => { it('should hash recording rules', () => {
const recordingRule: RulerRecordingRuleDTO = { const recordingRule: RulerRecordingRuleDTO = {
record: 'instance:node_num_cpu:sum', record: 'instance:node_num_cpu:sum',
@ -117,6 +153,48 @@ describe('hashRulerRule', () => {
}); });
}); });
describe('hashRule', () => {
it('should produce hashRulerRule compatible hashes for alerting rules', () => {
const promHash = hashRule(alertingRule.prom);
const rulerHash = hashRulerRule(alertingRule.ruler);
expect(promHash).toBe(rulerHash);
});
it('should produce hashRulerRule compatible hashes for recording rules', () => {
const promHash = hashRule(recordingRule.prom);
const rulerHash = hashRulerRule(recordingRule.ruler);
expect(promHash).toBe(rulerHash);
});
});
describe('equal', () => {
it('should return true for Prom and cloud identifiers with the same name, type, query and labels', () => {
const promIdentifier: RuleIdentifier = {
ruleSourceName: 'mimir-cloud',
namespace: 'cloud-alerts',
groupName: 'cpu-usage',
ruleName: alertingRule.prom.name,
ruleHash: hashRule(alertingRule.prom),
};
const cloudIdentifier: RuleIdentifier = {
ruleSourceName: 'mimir-cloud',
namespace: 'cloud-alerts',
groupName: 'cpu-usage',
ruleName: alertingRule.ruler.alert,
ruleHash: hashRulerRule(alertingRule.ruler),
};
const promToCloud = equal(promIdentifier, cloudIdentifier);
const cloudToProm = equal(cloudIdentifier, promIdentifier);
expect(promToCloud).toBe(true);
expect(cloudToProm).toBe(true);
});
});
describe('useRuleIdFromPathname', () => { describe('useRuleIdFromPathname', () => {
it('should return undefined when there is no id in params', () => { it('should return undefined when there is no id in params', () => {
const { result } = renderHook(() => { const { result } = renderHook(() => {

@ -10,7 +10,7 @@ import {
RuleIdentifier, RuleIdentifier,
RuleWithLocation, RuleWithLocation,
} from 'app/types/unified-alerting'; } from 'app/types/unified-alerting';
import { Annotations, Labels, RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { Annotations, Labels, PromRuleType, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { import {
@ -104,6 +104,28 @@ export function equal(a: RuleIdentifier, b: RuleIdentifier) {
); );
} }
// It might happen to compare Cloud and Prometheus identifiers for datasources with available Ruler API
// It happends when the Ruler API timeouts and the UI cannot create Cloud identifiers, so it creates a Prometheus identifier instead.
if (isCloudRuleIdentifier(a) && isPrometheusRuleIdentifier(b)) {
return (
a.groupName === b.groupName &&
a.namespace === b.namespace &&
a.ruleName === b.ruleName &&
a.rulerRuleHash === b.ruleHash &&
a.ruleSourceName === b.ruleSourceName
);
}
if (isPrometheusRuleIdentifier(a) && isCloudRuleIdentifier(b)) {
return (
a.groupName === b.groupName &&
a.namespace === b.namespace &&
a.ruleName === b.ruleName &&
a.ruleHash === b.rulerRuleHash &&
a.ruleSourceName === b.ruleSourceName
);
}
return false; return false;
} }
@ -219,41 +241,61 @@ function hash(value: string): number {
// this is used to identify rules, mimir / loki rules do not have a unique identifier // this is used to identify rules, mimir / loki rules do not have a unique identifier
export function hashRulerRule(rule: RulerRuleDTO): string { export function hashRulerRule(rule: RulerRuleDTO): string {
if (isGrafanaRulerRule(rule)) {
return rule.grafana_alert.uid;
}
const fingerprint = getRulerRuleFingerprint(rule);
return hash(JSON.stringify(fingerprint)).toString();
}
function getRulerRuleFingerprint(rule: RulerRuleDTO) {
if (isRecordingRulerRule(rule)) { if (isRecordingRulerRule(rule)) {
return hash(JSON.stringify([rule.record, rule.expr, hashLabelsOrAnnotations(rule.labels)])).toString(); return [rule.record, PromRuleType.Recording, hashQuery(rule.expr), hashLabelsOrAnnotations(rule.labels)];
} else if (isAlertingRulerRule(rule)) { }
return hash( if (isAlertingRulerRule(rule)) {
JSON.stringify([ return [
rule.alert, rule.alert,
rule.expr, PromRuleType.Alerting,
hashQuery(rule.expr),
hashLabelsOrAnnotations(rule.annotations), hashLabelsOrAnnotations(rule.annotations),
hashLabelsOrAnnotations(rule.labels), hashLabelsOrAnnotations(rule.labels),
]) ];
).toString();
} else if (isGrafanaRulerRule(rule)) {
return rule.grafana_alert.uid;
} else {
throw new Error('only recording and alerting ruler rules can be hashed');
} }
throw new Error('Only recording and alerting ruler rules can be hashed');
} }
export function hashRule(rule: Rule): string { export function hashRule(rule: Rule): string {
const fingerprint = getPromRuleFingerprint(rule);
return hash(JSON.stringify(fingerprint)).toString();
}
function getPromRuleFingerprint(rule: Rule) {
if (isRecordingRule(rule)) { if (isRecordingRule(rule)) {
return hash(JSON.stringify([rule.type, rule.query, hashLabelsOrAnnotations(rule.labels)])).toString(); return [rule.name, PromRuleType.Recording, hashQuery(rule.query), hashLabelsOrAnnotations(rule.labels)];
} }
if (isAlertingRule(rule)) { if (isAlertingRule(rule)) {
return hash( return [
JSON.stringify([ rule.name,
rule.type, PromRuleType.Alerting,
rule.query, hashQuery(rule.query),
hashLabelsOrAnnotations(rule.annotations), hashLabelsOrAnnotations(rule.annotations),
hashLabelsOrAnnotations(rule.labels), hashLabelsOrAnnotations(rule.labels),
]) ];
).toString();
} }
throw new Error('Only recording and alerting rules can be hashed');
}
throw new Error('only recording and alerting rules can be hashed'); // 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) {
// one of them might be wrapped in parens
if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') {
query = query.slice(1, -1);
}
// whitespace could be added or removed
query = query.replace(/\s|\n/g, '');
// labels matchers can be reordered, so sort the enitre string, esentially comparing just the character counts
return query.split('').sort().join('');
} }
export function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string { export function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {

Loading…
Cancel
Save