Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Alerting: bug fix for regex matching in Alerts page
  • Loading branch information
laurenashleigh committed Nov 4, 2025
commit 936557f03c3e0aa146fff853992fd1124bf53efc
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -56,6 +57,7 @@ export const triageScene = new EmbeddedSceneWithContext({
filters: [],
baseFilters: [],
layout: 'combobox',
expressionBuilder: prometheusExpressionBuilder,
}),
],
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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('');
});

it('should handle undefined values gracefully', () => {
const filters: AdHocFilterWithLabels[] = [{ key: 'alertname', operator: '=', value: undefined }];
expect(prometheusExpressionBuilder(filters)).toBe('alertname=""');
});

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"');
});
});
});
Original file line number Diff line number Diff line change
@@ -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, '\\$&');
}
Loading