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:
Konrad Lalik
2025-10-16 08:06:14 +02:00
committed by GitHub
parent 85d9c84ebc
commit a9dc2994e1
7 changed files with 350 additions and 59 deletions
@@ -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}`;
}