Alerting: Add position-based matching for identical alert rules (#112407)
* Add rule index as a part of the rule matching process * Refactor rule matching to use a dedicated RulePositionHash type - Introduced a new `rulePositionHash.ts` file to define a branded type for rule positions. - Updated rule matching logic to utilize the new `createRulePositionHash` function for generating rule position hashes. - Adjusted related components and tests to ensure consistent handling of rule indices and positions.
This commit is contained in:
@@ -191,24 +191,29 @@ export function RulerBasedGroupRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageItems.map((rulerRule) => {
|
||||
{pageItems.map((rulerRule, index) => {
|
||||
// If rules are indistinguishable by name, labels, annotations, and query, we need to use the index to disambiguate
|
||||
const promRule = matches.get(rulerRule);
|
||||
|
||||
return promRule ? (
|
||||
<DataSourceRuleListItem
|
||||
key={hashRule(promRule)}
|
||||
rule={promRule}
|
||||
rulerRule={rulerRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
application={application}
|
||||
actions={
|
||||
<RuleActionsButtons rule={rulerRule} promRule={promRule} groupIdentifier={groupIdentifier} compact />
|
||||
}
|
||||
showLocation={false}
|
||||
/>
|
||||
) : (
|
||||
if (promRule) {
|
||||
return (
|
||||
<DataSourceRuleListItem
|
||||
key={`${hashRule(promRule)}-${index}`}
|
||||
rule={promRule}
|
||||
rulerRule={rulerRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
application={application}
|
||||
actions={
|
||||
<RuleActionsButtons rule={rulerRule} promRule={promRule} groupIdentifier={groupIdentifier} compact />
|
||||
}
|
||||
showLocation={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RuleOperationListItem
|
||||
key={getRuleName(rulerRule)}
|
||||
key={`${getRuleName(rulerRule)}-${index}`}
|
||||
name={getRuleName(rulerRule)}
|
||||
namespace={namespace.name}
|
||||
group={groupName}
|
||||
@@ -219,18 +224,20 @@ export function RulerBasedGroupRules({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{promOnlyRules.map((rule) => (
|
||||
<RuleOperationListItem
|
||||
key={rule.name}
|
||||
name={rule.name}
|
||||
namespace={namespace.name}
|
||||
group={groupName}
|
||||
rulesSource={groupIdentifier.rulesSource}
|
||||
application={application}
|
||||
operation="deleting"
|
||||
showLocation={false}
|
||||
/>
|
||||
))}
|
||||
{promOnlyRules.map((rule, index) => {
|
||||
return (
|
||||
<RuleOperationListItem
|
||||
key={`${rule.name}-${index}`}
|
||||
name={rule.name}
|
||||
namespace={namespace.name}
|
||||
group={groupName}
|
||||
rulesSource={groupIdentifier.rulesSource}
|
||||
application={application}
|
||||
operation="deleting"
|
||||
showLocation={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<li aria-selected="false" role="treeitem" className={styles.loadMoreWrapper}>
|
||||
<LoadMoreButton onClick={loadMore} />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { DataSourceRuleGroupIdentifier, Rule } from 'app/types/unified-alerting';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { isCloudRulerGroup } from '../utils/rules';
|
||||
@@ -10,20 +8,18 @@ import { isCloudRulerGroup } from '../utils/rules';
|
||||
import { DataSourceRuleListItem } from './DataSourceRuleListItem';
|
||||
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
|
||||
import { RuleActionsSkeleton } from './components/RuleActionsSkeleton';
|
||||
import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator';
|
||||
import { getMatchingRulerRule } from './ruleMatching';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
|
||||
interface DataSourceRuleLoaderProps {
|
||||
rule: Rule;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
ruleWithOrigin: PromRuleWithOrigin;
|
||||
}
|
||||
|
||||
export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
|
||||
rule,
|
||||
groupIdentifier,
|
||||
}: DataSourceRuleLoaderProps) {
|
||||
export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({ ruleWithOrigin }: DataSourceRuleLoaderProps) {
|
||||
const { rule, groupIdentifier } = ruleWithOrigin;
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
|
||||
const { data: dsFeatures } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
|
||||
@@ -43,8 +39,8 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
|
||||
return;
|
||||
}
|
||||
|
||||
return getMatchingRulerRule(rulerRuleGroup, rule);
|
||||
}, [rulerRuleGroup, rule]);
|
||||
return getMatchingRulerRule(rulerRuleGroup, ruleWithOrigin);
|
||||
}, [rulerRuleGroup, ruleWithOrigin]);
|
||||
|
||||
// 1. get the rule from the ruler API with "ruleWithLocation"
|
||||
// 1.1 skip this if this datasource does not have a ruler
|
||||
|
||||
@@ -162,7 +162,7 @@ function FilterViewResults({ filterState }: FilterViewProps) {
|
||||
/>
|
||||
);
|
||||
case 'datasource':
|
||||
return <DataSourceRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
return <DataSourceRuleLoader key={key} ruleWithOrigin={ruleWithOrigin} />;
|
||||
default:
|
||||
return (
|
||||
<UnknownRuleListItem
|
||||
@@ -212,7 +212,9 @@ function getGrafanaRuleKey(ruleWithOrigin: GrafanaRuleWithOrigin) {
|
||||
function getDataSourceRuleKey(ruleWithOrigin: PromRuleWithOrigin) {
|
||||
const {
|
||||
rule,
|
||||
rulePositionHash,
|
||||
groupIdentifier: { rulesSource, namespace, groupName },
|
||||
} = ruleWithOrigin;
|
||||
return `${rulesSource.name}-${namespace.name}-${groupName}-${rule.name}-${rule.type}-${hashRule(rule)}`;
|
||||
|
||||
return `${rulesSource.name}-${namespace.name}-${groupName}-${rule.name}-${rule.type}-${hashRule(rule)}-${rulePositionHash}`;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getExternalRulesSources,
|
||||
isSupportedExternalRulesSourceType,
|
||||
} from '../../utils/datasource';
|
||||
import { RulePositionHash, createRulePositionHash } from '../rulePositionHash';
|
||||
|
||||
import { groupFilter, ruleFilter } from './filters';
|
||||
import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator';
|
||||
@@ -43,6 +44,22 @@ export interface PromRuleWithOrigin {
|
||||
rule: PromRuleDTO;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
origin: 'datasource';
|
||||
/**
|
||||
* Position hash encoding both the rule's index and the total number of rules in the group.
|
||||
* Format: "<index>:<totalRules>" (e.g., "0:3", "1:5")
|
||||
*
|
||||
* This is used as a tiebreaker when multiple identical rules exist in a group and need to be
|
||||
* matched against their counterparts in another rule source (e.g., matching Prometheus rules
|
||||
* to Ruler API rules). The hash ensures that identical rules are matched by their position
|
||||
* only when both groups have the same structure (same number of rules).
|
||||
*
|
||||
* @example
|
||||
* // Two identical alerts in different positions
|
||||
* Rule at position 0 in a 3-rule group: rulePositionHash = "0:3"
|
||||
* Rule at position 1 in a 3-rule group: rulePositionHash = "1:3"
|
||||
* // These won't match rules in a 2-rule group (e.g., "0:2") even if identical
|
||||
*/
|
||||
rulePositionHash: RulePositionHash;
|
||||
}
|
||||
|
||||
interface GetIteratorResult {
|
||||
@@ -99,9 +116,9 @@ export function useFilteredRulesIteratorProvider() {
|
||||
concatMap((groups) =>
|
||||
groups
|
||||
.filter((group) => groupFilter(group, normalizedFilterState))
|
||||
.flatMap((group) => group.rules.map((rule) => ({ group, rule })))
|
||||
.flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index })))
|
||||
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState))
|
||||
.map(({ group, rule }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule))
|
||||
.map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index))
|
||||
),
|
||||
catchError(() => empty())
|
||||
);
|
||||
@@ -166,7 +183,8 @@ function getRulesSourcesFromFilter(filter: RulesFilter): DataSourceRulesSourceId
|
||||
function mapRuleToRuleWithOrigin(
|
||||
rulesSource: DataSourceRulesSourceIdentifier,
|
||||
group: PromRuleGroupDTO,
|
||||
rule: PromRuleDTO
|
||||
rule: PromRuleDTO,
|
||||
ruleIndex: number
|
||||
): PromRuleWithOrigin {
|
||||
return {
|
||||
rule,
|
||||
@@ -177,6 +195,7 @@ function mapRuleToRuleWithOrigin(
|
||||
groupOrigin: 'datasource',
|
||||
},
|
||||
origin: 'datasource',
|
||||
rulePositionHash: createRulePositionHash(ruleIndex, group.rules.length),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
import { PromRuleDTO, RulerCloudRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { mockPromRecordingRule } from '../mocks';
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
|
||||
import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator';
|
||||
import { getMatchingPromRule, getMatchingRulerRule, matchRulesGroup } from './ruleMatching';
|
||||
import { RulePositionHash, createRulePositionHash } from './rulePositionHash';
|
||||
|
||||
// Helper to create PromRuleWithOrigin mock
|
||||
function createPromRuleWithOrigin(rule: PromRuleDTO, ruleIndex: number, totalRules: number): PromRuleWithOrigin {
|
||||
return {
|
||||
rule,
|
||||
groupIdentifier: {
|
||||
rulesSource: { uid: 'test-ds', name: 'test-datasource', ruleSourceType: 'datasource' },
|
||||
namespace: { name: 'test-namespace' },
|
||||
groupName: 'test-group',
|
||||
groupOrigin: 'datasource',
|
||||
},
|
||||
origin: 'datasource',
|
||||
rulePositionHash: createRulePositionHash(ruleIndex, totalRules),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create RulerRuleWithRulePosition mock
|
||||
function createRulerRuleWithPosition(
|
||||
rule: RulerCloudRuleDTO,
|
||||
ruleIndex: number,
|
||||
totalRules: number
|
||||
): RulerCloudRuleDTO & { rulePositionHash: RulePositionHash } {
|
||||
return {
|
||||
...rule,
|
||||
rulePositionHash: createRulePositionHash(ruleIndex, totalRules),
|
||||
};
|
||||
}
|
||||
|
||||
describe('getMatchingRulerRule', () => {
|
||||
it('should match rule by unique name', () => {
|
||||
@@ -11,8 +42,9 @@ describe('getMatchingRulerRule', () => {
|
||||
|
||||
// Create a matching prom rule with same name
|
||||
const promRule = alertingFactory.prometheus.rule.build({ name: 'test-rule' });
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 1);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
expect(match).toBe(rulerRule);
|
||||
});
|
||||
|
||||
@@ -30,8 +62,9 @@ describe('getMatchingRulerRule', () => {
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 1);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -55,8 +88,9 @@ describe('getMatchingRulerRule', () => {
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
expect(match).toBe(rulerRule1);
|
||||
});
|
||||
|
||||
@@ -83,8 +117,9 @@ describe('getMatchingRulerRule', () => {
|
||||
annotations: { summary: 'test' },
|
||||
query: 'up == 1',
|
||||
});
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
expect(match).toBe(rulerRule1);
|
||||
});
|
||||
|
||||
@@ -111,8 +146,9 @@ describe('getMatchingRulerRule', () => {
|
||||
annotations: { summary: 'other' },
|
||||
query: 'up == 2',
|
||||
});
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -141,10 +177,120 @@ max by (service) (up{env="staging"}) * 0.8`,
|
||||
labels: {},
|
||||
query: `max by (service) (up{env="production"}) or max by (service) (up{env="staging"}) * 0.8`,
|
||||
});
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRule);
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
expect(match).toBe(rulerRuleWithComments);
|
||||
});
|
||||
|
||||
it('should match prometheus rule against one of two identical ruler rules', () => {
|
||||
// Create two identical ruler alerting rules
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'KubeJobFailed',
|
||||
expr: `kube_job_failed{job!=\"\"} > 0\n`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Job failed to complete.' },
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'KubeJobFailed',
|
||||
expr: `kube_job_failed{job!=\"\"} > 0\n`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Job failed to complete.' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
|
||||
|
||||
// Create corresponding prometheus rule at position 0 (should match rulerRule1)
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'KubeJobFailed',
|
||||
query: `kube_job_failed{job!=\"\"} > 0`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Job failed to complete.' },
|
||||
});
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
// Should match the first ruler rule because position hash matches (0:2)
|
||||
expect(match).toBeDefined();
|
||||
expect(match).toEqual(rulerRule1);
|
||||
});
|
||||
|
||||
it('should match second identical rule when position hash indicates index 1', () => {
|
||||
// Create three identical ruler alerting rules to test matching at different positions
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'HighMemoryUsage',
|
||||
expr: `memory_usage > 90`,
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'High memory' },
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'HighMemoryUsage',
|
||||
expr: `memory_usage > 90`,
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'High memory' },
|
||||
});
|
||||
const rulerRule3 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'HighMemoryUsage',
|
||||
expr: `memory_usage > 90`,
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'High memory' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] });
|
||||
|
||||
// Create prometheus rule at position 1 (should match rulerRule2, the middle one)
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'HighMemoryUsage',
|
||||
query: `memory_usage > 90`,
|
||||
labels: { severity: 'critical' },
|
||||
annotations: { summary: 'High memory' },
|
||||
});
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 1, 3);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
// Should match the second ruler rule because position hash is "1:3"
|
||||
expect(match).toBeDefined();
|
||||
expect(match).toBe(rulerRule2);
|
||||
// Verify it's NOT matching the first or third rule
|
||||
expect(match).not.toBe(rulerRule1);
|
||||
expect(match).not.toBe(rulerRule3);
|
||||
});
|
||||
|
||||
it('should NOT match when group sizes differ even with identical rules', () => {
|
||||
// Create a ruler group with 3 identical rules
|
||||
const rulerRule1 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'DiskSpaceLow',
|
||||
expr: `disk_free < 10`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Low disk space' },
|
||||
});
|
||||
const rulerRule2 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'DiskSpaceLow',
|
||||
expr: `disk_free < 10`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Low disk space' },
|
||||
});
|
||||
const rulerRule3 = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'DiskSpaceLow',
|
||||
expr: `disk_free < 10`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Low disk space' },
|
||||
});
|
||||
const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] });
|
||||
|
||||
// Create prometheus rule with position hash indicating 2 rules total (but ruler has 3)
|
||||
const promRule = alertingFactory.prometheus.rule.build({
|
||||
name: 'DiskSpaceLow',
|
||||
query: `disk_free < 10`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Low disk space' },
|
||||
});
|
||||
// Position hash "0:2" indicates prom group has 2 rules, but ruler group has 3
|
||||
const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
|
||||
|
||||
const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
|
||||
// Should NOT match because position hash "0:2" doesn't match any ruler rule
|
||||
// (ruler rules would have hashes "0:3", "1:3", "2:3")
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchingPromRule', () => {
|
||||
@@ -155,8 +301,9 @@ describe('getMatchingPromRule', () => {
|
||||
|
||||
// Create a matching ruler rule with same name
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'test-rule' });
|
||||
const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 1);
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
expect(match).toBe(promRule);
|
||||
});
|
||||
|
||||
@@ -174,8 +321,9 @@ describe('getMatchingPromRule', () => {
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 1);
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -199,8 +347,9 @@ describe('getMatchingPromRule', () => {
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'test' },
|
||||
});
|
||||
const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
expect(match).toBe(promRule1);
|
||||
});
|
||||
|
||||
@@ -227,8 +376,9 @@ describe('getMatchingPromRule', () => {
|
||||
annotations: { summary: 'test' },
|
||||
expr: 'up == 1',
|
||||
});
|
||||
const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
expect(match).toBe(promRule1);
|
||||
});
|
||||
|
||||
@@ -255,8 +405,9 @@ describe('getMatchingPromRule', () => {
|
||||
annotations: { summary: 'other' },
|
||||
expr: 'up == 2',
|
||||
});
|
||||
const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
expect(match).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -285,10 +436,42 @@ or
|
||||
# Fall back to staging for missing production metrics
|
||||
max by (service) (up{env="staging"}) * 0.8`,
|
||||
});
|
||||
const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRule);
|
||||
const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
expect(match).toBe(promRuleWithoutComments);
|
||||
});
|
||||
|
||||
it('should match ruler rule against one of two identical prometheus rules', () => {
|
||||
// Create two identical prometheus alerting rules
|
||||
const promRule1 = alertingFactory.prometheus.rule.build({
|
||||
name: 'KubeJobFailed',
|
||||
query: `kube_job_failed{job!=\"\"} > 0`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Job failed to complete.' },
|
||||
});
|
||||
const promRule2 = alertingFactory.prometheus.rule.build({
|
||||
name: 'KubeJobFailed',
|
||||
query: `kube_job_failed{job!=\"\"} > 0`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Job failed to complete.' },
|
||||
});
|
||||
const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
|
||||
|
||||
// Create corresponding ruler rule at position 0 (should match promRule1)
|
||||
const rulerRule = alertingFactory.ruler.alertingRule.build({
|
||||
alert: 'KubeJobFailed',
|
||||
expr: `kube_job_failed{job!=\"\"} > 0\n`,
|
||||
labels: { severity: 'warning' },
|
||||
annotations: { summary: 'Job failed to complete.' },
|
||||
});
|
||||
const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
|
||||
|
||||
const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
// Should match the first prometheus rule because position hash matches (0:2)
|
||||
expect(match).toBeDefined();
|
||||
expect(match).toEqual(promRule1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchRulesGroup', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Rule } from 'app/types/unified-alerting';
|
||||
import {
|
||||
PromRuleDTO,
|
||||
PromRuleGroupDTO,
|
||||
@@ -10,7 +9,15 @@ import {
|
||||
import { getPromRuleFingerprint, getRulerRuleFingerprint } from '../utils/rule-id';
|
||||
import { getRuleName } from '../utils/rules';
|
||||
|
||||
export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>, rule: Rule) {
|
||||
import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator';
|
||||
import { RulePositionHash, createRulePositionHash } from './rulePositionHash';
|
||||
|
||||
export function getMatchingRulerRule(
|
||||
rulerRuleGroup: RulerRuleGroupDTO<RulerCloudRuleDTO>,
|
||||
promRuleWithOrigin: PromRuleWithOrigin
|
||||
) {
|
||||
const { rule, rulePositionHash } = promRuleWithOrigin;
|
||||
|
||||
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
|
||||
const rulesByName = rulerRuleGroup.rules.filter((r) => getRuleName(r) === rule.name);
|
||||
if (rulesByName.length === 1) {
|
||||
@@ -35,10 +42,47 @@ export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO<RulerClou
|
||||
return rulesByLabelsAndAnnotationsAndQuery[0];
|
||||
}
|
||||
|
||||
// If there are still multiple identical matches, use position hash to disambiguate
|
||||
// This only works if both groups have the same structure (same number of rules in the same order)
|
||||
if (rulesByLabelsAndAnnotationsAndQuery.length > 1 && rulePositionHash) {
|
||||
for (const candidateRule of rulesByLabelsAndAnnotationsAndQuery) {
|
||||
const rulerRuleIndex = rulerRuleGroup.rules.indexOf(candidateRule);
|
||||
const rulerPositionHash = createRulePositionHash(rulerRuleIndex, rulerRuleGroup.rules.length);
|
||||
|
||||
// Match if position hashes are identical
|
||||
if (rulerPositionHash === rulePositionHash) {
|
||||
return candidateRule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO<PromRuleDTO>, rule: RulerCloudRuleDTO) {
|
||||
type RulerRuleWithRulePosition = RulerCloudRuleDTO & {
|
||||
/**
|
||||
* Position hash encoding both the rule's index and the total number of rules in the group.
|
||||
* Format: "<index>:<totalRules>" (e.g., "0:3", "1:5")
|
||||
*
|
||||
* This is used to disambiguate between identical Ruler rules when matching them against
|
||||
* Prometheus rules. The hash ensures rules are matched by position only when both groups
|
||||
* have the same structure (same number of rules).
|
||||
*
|
||||
* @example
|
||||
* // Matching identical rules by position
|
||||
* Ruler rule at position 0 in a 2-rule group: rulePositionHash = "0:2"
|
||||
* Prometheus rule at position 0 in a 2-rule group: rulePositionHash = "0:2" → Match!
|
||||
* Prometheus rule at position 0 in a 3-rule group: rulePositionHash = "0:3" → No match
|
||||
*/
|
||||
rulePositionHash: RulePositionHash;
|
||||
};
|
||||
|
||||
export function getMatchingPromRule(
|
||||
promRuleGroup: PromRuleGroupDTO<PromRuleDTO>,
|
||||
rulerRuleWithPosition: RulerRuleWithRulePosition
|
||||
) {
|
||||
const { rulePositionHash, ...rule } = rulerRuleWithPosition;
|
||||
|
||||
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
|
||||
const rulesByName = promRuleGroup.rules.filter((r) => r.name === getRuleName(rule));
|
||||
if (rulesByName.length === 1) {
|
||||
@@ -63,6 +107,20 @@ export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO<PromRuleDTO>
|
||||
return rulesByLabelsAndAnnotationsAndQuery[0];
|
||||
}
|
||||
|
||||
// If there are still multiple identical matches, use position hash to disambiguate
|
||||
// This only works if both groups have the same structure (same number of rules in the same order)
|
||||
if (rulesByLabelsAndAnnotationsAndQuery.length > 1 && rulePositionHash) {
|
||||
for (const candidateRule of rulesByLabelsAndAnnotationsAndQuery) {
|
||||
const promRuleIndex = promRuleGroup.rules.indexOf(candidateRule);
|
||||
const promPositionHash = createRulePositionHash(promRuleIndex, promRuleGroup.rules.length);
|
||||
|
||||
// Match if position hashes are identical
|
||||
if (promPositionHash === rulePositionHash) {
|
||||
return candidateRule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -76,10 +134,16 @@ export function matchRulesGroup(
|
||||
promGroup: PromRuleGroupDTO<PromRuleDTO>
|
||||
): GroupMatchingResult {
|
||||
const matchingResult = rulerGroup.rules.reduce(
|
||||
(acc, rulerRule) => {
|
||||
(acc, rulerRule, index) => {
|
||||
const { matches, unmatchedPromRules } = acc;
|
||||
|
||||
const promRule = getMatchingPromRule(promGroup, rulerRule);
|
||||
// Create ruler rule with position hash
|
||||
const rulerRuleWithPosition: RulerRuleWithRulePosition = {
|
||||
...rulerRule,
|
||||
rulePositionHash: createRulePositionHash(index, rulerGroup.rules.length),
|
||||
};
|
||||
|
||||
const promRule = getMatchingPromRule(promGroup, rulerRuleWithPosition);
|
||||
if (promRule) {
|
||||
matches.set(rulerRule, promRule);
|
||||
unmatchedPromRules.delete(promRule);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A branded type representing a rule's position within a group.
|
||||
* Format: "<index>:<totalRules>" (e.g., "0:3", "1:5")
|
||||
*
|
||||
* This is used to disambiguate between identical rules when matching them across
|
||||
* different rule sources (e.g., matching Prometheus rules to Ruler API rules).
|
||||
* The hash ensures rules are matched by position only when both groups have the
|
||||
* same structure (same number of rules).
|
||||
*
|
||||
* @example
|
||||
* // Matching identical rules by position
|
||||
* Rule at position 0 in a 2-rule group: "0:2"
|
||||
* Rule at position 1 in a 3-rule group: "1:3"
|
||||
* // These won't match rules in differently-sized groups even if identical
|
||||
*/
|
||||
export type RulePositionHash = `${number}:${number}`;
|
||||
|
||||
export function createRulePositionHash(ruleIndex: number, totalRules: number): RulePositionHash {
|
||||
return `${ruleIndex}:${totalRules}`;
|
||||
}
|
||||
Reference in New Issue
Block a user