From 8c9ba97642f83b1a7073675e591de3c3c41b9161 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Thu, 21 Mar 2024 14:11:18 +0100 Subject: [PATCH] Alerting: Support PromQL-style matchers (#84672) --- .../alerting/unified/useRouteGroupsMatcher.ts | 2 - .../alerting/unified/utils/amroutes.ts | 8 +- .../alerting/unified/utils/matchers.test.ts | 75 ++++++++++++++ .../alerting/unified/utils/matchers.ts | 99 ++++++++++++++----- 4 files changed, 156 insertions(+), 28 deletions(-) diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts index a48d3e4c611..421afe78bde 100644 --- a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts @@ -51,8 +51,6 @@ export function useRouteGroupsMatcher() { useEffect(() => { const { disposeWorker } = loadWorker(); return disposeWorker; - - return () => null; }, []); const getRouteGroupsMap = useCallback( diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index 0802276d9f1..e8f875d14b3 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -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, diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index 9f879789a22..c10b03749b6 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -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(); + }); +}); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index c24c15d8f78..2cac197f8d9 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -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];