From 1bd5b2996344fafe81d498571f9c1ea288b12b83 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:24:48 +0000 Subject: [PATCH] Alerting: Bug fix for regex matching in Alerts page (#113400) * Alerting: bug fix for regex matching in Alerts page * remove test --- .../unified/triage/scene/TriageScene.tsx | 2 + .../triage/scene/expressionBuilder.test.ts | 121 ++++++++++++++++++ .../unified/triage/scene/expressionBuilder.ts | 73 +++++++++++ 3 files changed, 196 insertions(+) create mode 100644 public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts create mode 100644 public/app/features/alerting/unified/triage/scene/expressionBuilder.ts diff --git a/public/app/features/alerting/unified/triage/scene/TriageScene.tsx b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx index 0ac90d8a066..d5c7c96d71b 100644 --- a/public/app/features/alerting/unified/triage/scene/TriageScene.tsx +++ b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx @@ -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, }), ], }), diff --git a/public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts b/public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts new file mode 100644 index 00000000000..5ee5055eab6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/expressionBuilder.test.ts @@ -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"'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/triage/scene/expressionBuilder.ts b/public/app/features/alerting/unified/triage/scene/expressionBuilder.ts new file mode 100644 index 00000000000..ff3d01f73e4 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/expressionBuilder.ts @@ -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 = { + '=|': '=~', + '!=|': '!~', +}; + +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, '\\$&'); +}