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:
Lauren
2025-11-05 09:24:48 +00:00
committed by GitHub
parent 98ec655f33
commit 1bd5b29963
3 changed files with 196 additions and 0 deletions
@@ -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, '\\$&');
}