Alerting: Bug fix for regex matching in Alerts page (#113400)
* Alerting: bug fix for regex matching in Alerts page * remove test
This commit is contained in:
@@ -16,6 +16,7 @@ import { EmbeddedSceneWithContext } from '@grafana/scenes-react';
|
||||
import { DATASOURCE_UID } from '../constants';
|
||||
|
||||
import { WorkbenchSceneObject } from './Workbench';
|
||||
import { prometheusExpressionBuilder } from './expressionBuilder';
|
||||
import { defaultTimeRange } from './utils';
|
||||
|
||||
const cursorSync = new behaviors.CursorSync({ key: 'triage-cursor-sync', sync: DashboardCursorSync.Crosshair });
|
||||
@@ -56,6 +57,7 @@ export const triageScene = new EmbeddedSceneWithContext({
|
||||
filters: [],
|
||||
baseFilters: [],
|
||||
layout: 'combobox',
|
||||
expressionBuilder: prometheusExpressionBuilder,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { AdHocFilterWithLabels } from '@grafana/scenes';
|
||||
|
||||
import { prometheusExpressionBuilder } from './expressionBuilder';
|
||||
|
||||
describe('prometheusExpressionBuilder', () => {
|
||||
it('should handle exact match operators', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=', value: 'foo' }];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo"');
|
||||
});
|
||||
|
||||
it('should handle exact not-match operators', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '!=', value: 'foo' }];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname!="foo"');
|
||||
});
|
||||
|
||||
it('should handle regex match operator without escaping metacharacters', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo.*' }];
|
||||
// Should NOT escape the .* regex pattern
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo.*"');
|
||||
});
|
||||
|
||||
it('should handle regex match with complex patterns', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'test[0-9]+' }];
|
||||
// Should preserve the regex pattern
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"test[0-9]+"');
|
||||
});
|
||||
|
||||
it('should handle regex not-match operator without escaping metacharacters', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '!~', value: 'foo.*' }];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname!~"foo.*"');
|
||||
});
|
||||
|
||||
it('should escape quotes in regex values', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo"bar.*' }];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo\\"bar.*"');
|
||||
});
|
||||
|
||||
it('should escape backslashes in regex values', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo\\bar.*' }];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo\\\\bar.*"');
|
||||
});
|
||||
|
||||
it('should handle multi-value equals operator', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [
|
||||
{ key: 'alertname', operator: '=|', value: 'foo', values: ['foo', 'bar', 'baz'] },
|
||||
];
|
||||
// Multi-value should escape regex metacharacters since we're building literal matches
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo|bar|baz"');
|
||||
});
|
||||
|
||||
it('should handle multi-value not-equals operator', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [
|
||||
{ key: 'alertname', operator: '!=|', value: 'foo', values: ['foo', 'bar', 'baz'] },
|
||||
];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname!~"foo|bar|baz"');
|
||||
});
|
||||
|
||||
it('should escape metacharacters in multi-value operators', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [
|
||||
{ key: 'alertname', operator: '=|', value: 'foo.*', values: ['foo.*', 'bar+'] },
|
||||
];
|
||||
// These should be escaped because we want literal matches
|
||||
// The backslashes themselves are escaped in the string literal
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo\\\\.\\\\*|bar\\\\+"');
|
||||
});
|
||||
|
||||
it('should handle multiple filters', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [
|
||||
{ key: 'alertname', operator: '=~', value: 'foo.*' },
|
||||
{ key: 'severity', operator: '=', value: 'critical' },
|
||||
{ key: 'team', operator: '!=', value: 'test' },
|
||||
];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo.*",severity="critical",team!="test"');
|
||||
});
|
||||
|
||||
it('should filter out non-applicable filters', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [
|
||||
{ key: 'alertname', operator: '=', value: 'foo' },
|
||||
{ key: 'severity', operator: '=', value: 'critical', nonApplicable: true },
|
||||
];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo"');
|
||||
});
|
||||
|
||||
it('should filter out hidden filters', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [
|
||||
{ key: 'alertname', operator: '=', value: 'foo' },
|
||||
{ key: 'severity', operator: '=', value: 'critical', hidden: true },
|
||||
];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo"');
|
||||
});
|
||||
|
||||
it('should escape special characters in exact match values', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=', value: 'foo"bar\nbaz' }];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname="foo\\"bar\\nbaz"');
|
||||
});
|
||||
|
||||
it('should handle empty filter array', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [];
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('');
|
||||
});
|
||||
|
||||
describe('reported bug test cases', () => {
|
||||
it('should match alerts starting with foo using foo.*', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo.*' }];
|
||||
// This should produce a valid regex that matches anything starting with foo
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo.*"');
|
||||
});
|
||||
|
||||
it('should match exact alert with regex operator', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'fooalert' }];
|
||||
// This should work for exact matches too (regex matching literal string)
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"fooalert"');
|
||||
});
|
||||
|
||||
it('should handle foo as a prefix pattern', () => {
|
||||
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=~', value: 'foo' }];
|
||||
// Just 'foo' as a regex should match anything containing 'foo'
|
||||
expect(prometheusExpressionBuilder(filters)).toBe('alertname=~"foo"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AdHocFilterWithLabels } from '@grafana/scenes';
|
||||
|
||||
/**
|
||||
* Custom expression builder for Prometheus that properly handles regex operators.
|
||||
* Unlike the default builder, this doesn't escape regex metacharacters when using =~ or !~
|
||||
* operators, allowing users to enter raw regex patterns.
|
||||
*/
|
||||
export function prometheusExpressionBuilder(filters: AdHocFilterWithLabels[]): string {
|
||||
const applicableFilters = filters.filter((f) => !f.nonApplicable && !f.hidden);
|
||||
return applicableFilters.map(renderFilter).join(',');
|
||||
}
|
||||
|
||||
// Map multi-value operators to their regex equivalents
|
||||
const MULTI_VALUE_OPERATOR_MAP: Record<string, string> = {
|
||||
'=|': '=~',
|
||||
'!=|': '!~',
|
||||
};
|
||||
|
||||
function renderFilter(filter: AdHocFilterWithLabels): string {
|
||||
const { key, operator: rawOperator, value, values } = filter;
|
||||
|
||||
// Transform multi-value operators to regex operators
|
||||
const operator = MULTI_VALUE_OPERATOR_MAP[rawOperator] ?? rawOperator;
|
||||
|
||||
// Determine the escaped value based on operator type
|
||||
const escapedValue = getEscapedValue(rawOperator, value, values);
|
||||
|
||||
return `${key}${operator}"${escapedValue}"`;
|
||||
}
|
||||
|
||||
function getEscapedValue(operator: string, value: string | undefined, values: string[] | undefined): string {
|
||||
// Multi-value operators: escape each value as literal and join with |
|
||||
if (operator === '=|' || operator === '!=|') {
|
||||
return values?.map(escapeAsLiteral).join('|') ?? '';
|
||||
}
|
||||
|
||||
// Regex operators: preserve regex metacharacters
|
||||
if (operator === '=~' || operator === '!~') {
|
||||
return escapeStringLiteral(value);
|
||||
}
|
||||
|
||||
// Exact match operators: escape string literal only
|
||||
return escapeStringLiteral(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a value for use in PromQL string literals.
|
||||
* Only escapes backslashes, newlines, and double quotes.
|
||||
* Does NOT escape regex metacharacters.
|
||||
*/
|
||||
function escapeStringLiteral(value: string | undefined): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a value for literal matching in multi-value selectors.
|
||||
* Escapes both string literal characters AND regex metacharacters.
|
||||
*/
|
||||
function escapeAsLiteral(value: string): string {
|
||||
return escapeStringLiteral(escapeRegexMetacharacters(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes regex metacharacters for literal matching.
|
||||
* Used when building multi-value selectors where each value should be matched literally.
|
||||
*/
|
||||
const RE2_METACHARACTERS = /[*+?()|\\.\[\]{}^$]/g;
|
||||
function escapeRegexMetacharacters(value: string): string {
|
||||
return value.replace(RE2_METACHARACTERS, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user