Alerting: Support PromQL-style matchers (#84672)
This commit is contained in:
@@ -51,8 +51,6 @@ export function useRouteGroupsMatcher() {
|
||||
useEffect(() => {
|
||||
const { disposeWorker } = loadWorker();
|
||||
return disposeWorker;
|
||||
|
||||
return () => null;
|
||||
}, []);
|
||||
|
||||
const getRouteGroupsMap = useCallback(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form';
|
||||
|
||||
import { matcherToMatcherField } from './alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
import { normalizeMatchers, parseMatcher, quoteWithEscape, unquoteWithUnescape } from './matchers';
|
||||
import { normalizeMatchers, parseMatcherToArray, quoteWithEscape, unquoteWithUnescape } from './matchers';
|
||||
import { findExistingRoute } from './routeTree';
|
||||
import { isValidPrometheusDuration, safeParseDurationstr } from './time';
|
||||
|
||||
@@ -94,9 +94,13 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo
|
||||
|
||||
const objectMatchers =
|
||||
route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? [];
|
||||
|
||||
const matchers =
|
||||
route.matchers
|
||||
?.map((matcher) => matcherToMatcherField(parseMatcher(matcher)))
|
||||
?.flatMap((matcher) => {
|
||||
// parse the matcher to an array of matchers, PromQL-style matchers can contain more than one matcher (in a matcher, yes it's confusing)
|
||||
return parseMatcherToArray(matcher).flatMap(matcherToMatcherField);
|
||||
})
|
||||
.map(({ name, operator, value }) => ({
|
||||
name: unquoteWithUnescape(name),
|
||||
operator,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmana
|
||||
|
||||
import {
|
||||
getMatcherQueryParams,
|
||||
isPromQLStyleMatcher,
|
||||
matcherToObjectMatcher,
|
||||
normalizeMatchers,
|
||||
parseMatcher,
|
||||
parsePromQLStyleMatcher,
|
||||
parseQueryParamMatchers,
|
||||
quoteWithEscape,
|
||||
unquoteWithUnescape,
|
||||
@@ -44,6 +48,7 @@ describe('Unified Alerting matchers', () => {
|
||||
|
||||
describe('normalizeMatchers', () => {
|
||||
const eq = MatcherOperator.equal;
|
||||
const neq = MatcherOperator.notEqual;
|
||||
|
||||
it('should work for object_matchers', () => {
|
||||
const route: Route = { object_matchers: [['foo', eq, 'bar']] };
|
||||
@@ -65,6 +70,24 @@ describe('Unified Alerting matchers', () => {
|
||||
['foo', MatcherOperator.equal, 'bar'],
|
||||
]);
|
||||
});
|
||||
it('should work with PromQL style matchers', () => {
|
||||
const route: Route = {
|
||||
matchers: ['{ foo=bar, baz!=qux }'],
|
||||
};
|
||||
expect(normalizeMatchers(route)).toEqual([
|
||||
['foo', eq, 'bar'],
|
||||
['baz', neq, 'qux'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMatcher', () => {
|
||||
it('should be able to parse a simple matcher', () => {
|
||||
expect(parseMatcher('foo=bar')).toStrictEqual({ name: 'foo', value: 'bar', isRegex: false, isEqual: true });
|
||||
});
|
||||
it('should throw when parsing PromQL-style matcher', () => {
|
||||
expect(() => parseMatcher('{ foo=bar }')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,3 +124,55 @@ describe('unquoteWithUnescape', () => {
|
||||
expect(unquoted).toBe('un\\"quo\\\\ted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPromQLStyleMatcher', () => {
|
||||
it('should detect promQL style matcher', () => {
|
||||
expect(isPromQLStyleMatcher('{ foo=bar }')).toBe(true);
|
||||
expect(isPromQLStyleMatcher('foo=bar')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matcherToObjectMatcher', () => {
|
||||
test.each([
|
||||
{ matcher: { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, expected: ['foo', '=', 'bar'] },
|
||||
{ matcher: { name: 'foo', value: 'bar', isRegex: true, isEqual: true }, expected: ['foo', '=~', 'bar'] },
|
||||
{ matcher: { name: 'foo', value: 'bar', isRegex: true, isEqual: false }, expected: ['foo', '!~', 'bar'] },
|
||||
{ matcher: { name: 'foo', value: 'bar', isRegex: false, isEqual: false }, expected: ['foo', '!=', 'bar'] },
|
||||
])('.matcherToObjectMatcher($matcher)', ({ matcher, expected }) => {
|
||||
expect(matcherToObjectMatcher(matcher)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePromQLStyleMatcher', () => {
|
||||
it('should decode PromQL style matcher', () => {
|
||||
expect(parsePromQLStyleMatcher('{ foo="bar"}')).toStrictEqual([
|
||||
{
|
||||
name: 'foo',
|
||||
value: 'bar',
|
||||
isEqual: true,
|
||||
isRegex: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should split only on comma when not used as a label key or value', () => {
|
||||
expect(parsePromQLStyleMatcher('{ "key1,key2"="value1,value2"}')).toStrictEqual([
|
||||
{
|
||||
name: 'key1,key2',
|
||||
value: 'value1,value2',
|
||||
isEqual: true,
|
||||
isRegex: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove empty matchers from array', () => {
|
||||
expect(parsePromQLStyleMatcher('{ foo=bar, }')).toStrictEqual([
|
||||
{ name: 'foo', value: 'bar', isEqual: true, isRegex: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw when not using correct syntax', () => {
|
||||
expect(() => parsePromQLStyleMatcher('foo="bar"')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { uniqBy } from 'lodash';
|
||||
/**
|
||||
* Functions in this file are used by the routeGroupsMatcher.worker.ts file.
|
||||
* This is a web worker that matches active alert instances to a policy in the notification policy tree.
|
||||
*
|
||||
* Please keep the references to other files here to a minimum, if we reference a file that uses GrafanaBootData from `window` the worker will fail to load.
|
||||
*/
|
||||
|
||||
import { compact, uniqBy } from 'lodash';
|
||||
|
||||
import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
@@ -11,10 +18,18 @@ const matcherOperators = [
|
||||
MatcherOperator.equal,
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a single matcher, examples:
|
||||
* foo="bar" => { name: foo, value: bar, isRegex: false, isEqual: true }
|
||||
* bar!~baz => { name: bar, value: baz, isRegex: true, isEqual: false }
|
||||
*/
|
||||
export function parseMatcher(matcher: string): Matcher {
|
||||
if (matcher.startsWith('{') && matcher.endsWith('}')) {
|
||||
throw new Error(`PromQL matchers not supported yet, sorry! PromQL matcher found: ${matcher}`);
|
||||
throw new Error(
|
||||
'this function does not support PromQL-style matcher syntax, call parsePromQLStyleMatcher() instead'
|
||||
);
|
||||
}
|
||||
|
||||
const operatorsFound = matcherOperators
|
||||
.map((op): [MatcherOperator, number] => [op, matcher.indexOf(op)])
|
||||
.filter(([_, idx]) => idx > -1)
|
||||
@@ -38,6 +53,34 @@ export function parseMatcher(matcher: string): Matcher {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This function combines parseMatcher and parsePromQLStyleMatcher, always returning an array of Matcher[] regardless of input syntax
|
||||
*/
|
||||
export function parseMatcherToArray(matcher: string): Matcher[] {
|
||||
return isPromQLStyleMatcher(matcher) ? parsePromQLStyleMatcher(matcher) : [parseMatcher(matcher)];
|
||||
}
|
||||
|
||||
/**
|
||||
* This function turns a PromQL-style matchers like { foo="bar", bar!=baz } in to an array of Matchers
|
||||
*/
|
||||
export function parsePromQLStyleMatcher(matcher: string): Matcher[] {
|
||||
if (!isPromQLStyleMatcher(matcher)) {
|
||||
throw new Error('not a PromQL style matcher');
|
||||
}
|
||||
|
||||
// split by `,` but not when it's used as a label value
|
||||
const commaUnlessQuoted = /,(?=(?:[^"]*"[^"]*")*[^"]*$)/;
|
||||
const parts = matcher.replace(/^\{/, '').replace(/\}$/, '').trim().split(commaUnlessQuoted);
|
||||
|
||||
return compact(parts)
|
||||
.flatMap(parseMatcher)
|
||||
.map((matcher) => ({
|
||||
...matcher,
|
||||
name: unquoteWithUnescape(matcher.name),
|
||||
value: unquoteWithUnescape(matcher.value),
|
||||
}));
|
||||
}
|
||||
|
||||
// Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[]
|
||||
export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] {
|
||||
const parsedMatchers = matcherPairs.filter((x) => !!x.trim()).map((x) => parseMatcher(x));
|
||||
@@ -65,47 +108,32 @@ export const getMatcherQueryParams = (labels: Labels) => {
|
||||
* this function will normalize all of the different ways to define matchers in to a single one.
|
||||
*/
|
||||
export const normalizeMatchers = (route: Route): ObjectMatcher[] => {
|
||||
const matchers: ObjectMatcher[] = [];
|
||||
let routeMatchers: ObjectMatcher[] = [];
|
||||
|
||||
if (route.matchers) {
|
||||
route.matchers.forEach((matcher) => {
|
||||
const { name, value, isEqual, isRegex } = parseMatcher(matcher);
|
||||
let operator = MatcherOperator.equal;
|
||||
|
||||
if (isEqual && isRegex) {
|
||||
operator = MatcherOperator.regex;
|
||||
}
|
||||
if (!isEqual && isRegex) {
|
||||
operator = MatcherOperator.notRegex;
|
||||
}
|
||||
if (isEqual && !isRegex) {
|
||||
operator = MatcherOperator.equal;
|
||||
}
|
||||
if (!isEqual && !isRegex) {
|
||||
operator = MatcherOperator.notEqual;
|
||||
}
|
||||
|
||||
matchers.push([name, operator, value]);
|
||||
const parsedMatchers = parseMatcherToArray(matcher).map(matcherToObjectMatcher);
|
||||
routeMatchers = routeMatchers.concat(parsedMatchers);
|
||||
});
|
||||
}
|
||||
|
||||
if (route.object_matchers) {
|
||||
matchers.push(...route.object_matchers);
|
||||
routeMatchers.push(...route.object_matchers);
|
||||
}
|
||||
|
||||
if (route.match_re) {
|
||||
Object.entries(route.match_re).forEach(([label, value]) => {
|
||||
matchers.push([label, MatcherOperator.regex, value]);
|
||||
routeMatchers.push([label, MatcherOperator.regex, value]);
|
||||
});
|
||||
}
|
||||
|
||||
if (route.match) {
|
||||
Object.entries(route.match).forEach(([label, value]) => {
|
||||
matchers.push([label, MatcherOperator.equal, value]);
|
||||
routeMatchers.push([label, MatcherOperator.equal, value]);
|
||||
});
|
||||
}
|
||||
|
||||
return matchers;
|
||||
return routeMatchers;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -144,6 +172,29 @@ export const matcherFormatter = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function isPromQLStyleMatcher(input: string): boolean {
|
||||
return input.startsWith('{') && input.endsWith('}');
|
||||
}
|
||||
|
||||
export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher {
|
||||
const operator = matcherToOperator(matcher);
|
||||
return [matcher.name, operator, matcher.value];
|
||||
}
|
||||
|
||||
function matcherToOperator(matcher: Matcher): MatcherOperator {
|
||||
if (matcher.isEqual) {
|
||||
if (matcher.isRegex) {
|
||||
return MatcherOperator.regex;
|
||||
} else {
|
||||
return MatcherOperator.equal;
|
||||
}
|
||||
} else if (matcher.isRegex) {
|
||||
return MatcherOperator.notRegex;
|
||||
} else {
|
||||
return MatcherOperator.notEqual;
|
||||
}
|
||||
}
|
||||
|
||||
export type MatcherFormatter = keyof typeof matcherFormatter;
|
||||
|
||||
export type Label = [string, string];
|
||||
|
||||
Reference in New Issue
Block a user