mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Prometheus: Improve handling of special chars in label values (#96067)
* fix: handling of special chars * docs: add clarity * fix: escaping * refactor: put changes behind new feature toggle * docs: use consistent comment style * refactor: rename feature toggle for brevity * use single quotes * fix unit tests * remove redundant json entry * fix: keep all changes behind feature toggle * fix: support builder mode * fix: don't escape when using regex operators * fix: code mode label values completions with special chars * refactor: remove unneeded changes * move feature toggle up so new changes from main won't conflict with ours * fix: escape label values in metric select scene * refactor: ensure changes are behind feature toggle --------- Co-authored-by: ismail simsek <ismailsimsek09@gmail.com>
This commit is contained in:
parent
c9c77a6a6c
commit
721c50a304
@ -222,6 +222,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `userStorageAPI` | Enables the user storage API |
|
||||
| `dashboardSchemaV2` | Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code. |
|
||||
| `playlistsWatcher` | Enables experimental watcher for playlists |
|
||||
| `prometheusSpecialCharsInLabelValues` | Adds support for quotes and special characters in label values for Prometheus queries |
|
||||
| `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode |
|
||||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
| `crashDetection` | Enables browser crash detection reporting to Faro. |
|
||||
|
@ -232,6 +232,7 @@ export interface FeatureToggles {
|
||||
playlistsWatcher?: boolean;
|
||||
passwordlessMagicLinkAuthentication?: boolean;
|
||||
exploreMetricsRelatedLogs?: boolean;
|
||||
prometheusSpecialCharsInLabelValues?: boolean;
|
||||
enableExtensionsAdminPage?: boolean;
|
||||
zipkinBackendMigration?: boolean;
|
||||
enableSCIM?: boolean;
|
||||
|
@ -274,3 +274,167 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat
|
||||
expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Label value completions', () => {
|
||||
let dataProvider: DataProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
dataProvider = {
|
||||
getAllMetricNames: jest.fn(),
|
||||
metricNamesToMetrics: jest.fn(),
|
||||
getHistory: jest.fn(),
|
||||
getLabelNames: jest.fn(),
|
||||
getLabelValues: jest.fn().mockResolvedValue(['value1', 'value"2', 'value\\3', "value'4"]),
|
||||
getSeriesLabels: jest.fn(),
|
||||
getSeriesValues: jest.fn(),
|
||||
monacoSettings: {
|
||||
setInputInRange: jest.fn(),
|
||||
inputInRange: '',
|
||||
suggestionsIncomplete: false,
|
||||
enableAutocompleteSuggestionsUpdate: jest.fn(),
|
||||
},
|
||||
metricNamesSuggestionLimit: 100,
|
||||
} as unknown as DataProvider;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusSpecialCharsInLabelValues: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not escape special characters when between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: true,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('value1');
|
||||
expect(completions[1].insertText).toBe('value"2');
|
||||
expect(completions[2].insertText).toBe('value\\3');
|
||||
expect(completions[3].insertText).toBe("value'4");
|
||||
});
|
||||
|
||||
it('should wrap in quotes but not escape special characters when not between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('"value1"');
|
||||
expect(completions[1].insertText).toBe('"value"2"');
|
||||
expect(completions[2].insertText).toBe('"value\\3"');
|
||||
expect(completions[3].insertText).toBe('"value\'4"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusSpecialCharsInLabelValues: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should escape special characters when between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: true,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('value1');
|
||||
expect(completions[1].insertText).toBe('value\\"2');
|
||||
expect(completions[2].insertText).toBe('value\\\\3');
|
||||
expect(completions[3].insertText).toBe("value'4");
|
||||
});
|
||||
|
||||
it('should wrap in quotes and escape special characters when not between quotes', async () => {
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
|
||||
expect(completions).toHaveLength(4);
|
||||
expect(completions[0].insertText).toBe('"value1"');
|
||||
expect(completions[1].insertText).toBe('"value\\"2"');
|
||||
expect(completions[2].insertText).toBe('"value\\\\3"');
|
||||
expect(completions[3].insertText).toBe('"value\'4"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('label value escaping edge cases', () => {
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusSpecialCharsInLabelValues: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty values', async () => {
|
||||
jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['']);
|
||||
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].insertText).toBe('""');
|
||||
});
|
||||
|
||||
it('should handle values with multiple special characters', async () => {
|
||||
jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['test"\\value']);
|
||||
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: true,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].insertText).toBe('test\\"\\\\value');
|
||||
});
|
||||
|
||||
it('should handle non-string values', async () => {
|
||||
jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue([123 as unknown as string]);
|
||||
|
||||
const situation: Situation = {
|
||||
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
|
||||
labelName: 'testLabel',
|
||||
betweenQuotes: false,
|
||||
otherLabels: [],
|
||||
};
|
||||
|
||||
const completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].insertText).toBe('"123"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -3,6 +3,7 @@ import UFuzzy from '@leeoniya/ufuzzy';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { prometheusRegularEscape } from '../../../datasource';
|
||||
import { escapeLabelValueInExactSelector } from '../../../language_utils';
|
||||
import { FUNCTIONS } from '../../../promql';
|
||||
|
||||
@ -208,10 +209,15 @@ async function getLabelValuesForMetricCompletions(
|
||||
return values.map((text) => ({
|
||||
type: 'LABEL_VALUE',
|
||||
label: text,
|
||||
insertText: betweenQuotes ? text : `"${text}"`, // FIXME: escaping strange characters?
|
||||
insertText: formatLabelValueForCompletion(text, betweenQuotes),
|
||||
}));
|
||||
}
|
||||
|
||||
function formatLabelValueForCompletion(value: string, betweenQuotes: boolean): string {
|
||||
const text = config.featureToggles.prometheusSpecialCharsInLabelValues ? prometheusRegularEscape(value) : value;
|
||||
return betweenQuotes ? text : `"${text}"`;
|
||||
}
|
||||
|
||||
export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
|
||||
switch (situation.type) {
|
||||
case 'IN_DURATION':
|
||||
|
@ -245,60 +245,118 @@ describe('PrometheusDatasource', () => {
|
||||
const DEFAULT_QUERY_EXPRESSION = 'metric{job="foo"} - metric';
|
||||
const target: PromQuery = { expr: DEFAULT_QUERY_EXPRESSION, refId: 'A' };
|
||||
|
||||
it('should not modify expression with no filters', async () => {
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION });
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
it('should not modify expression with no filters', async () => {
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION });
|
||||
});
|
||||
|
||||
it('should add filters to expression', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=',
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '!=',
|
||||
value: 'v2',
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: 'metric{job="foo", k1="v1", k2!="v2"} - metric{k1="v1", k2!="v2"}' });
|
||||
});
|
||||
|
||||
it('should add escaping if needed to regex filter expressions', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=~',
|
||||
value: 'v.*',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '=~',
|
||||
value: `v'.*`,
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({
|
||||
expr: `metric{job="foo", k1=~"v.*", k2=~"v\\\\'.*"} - metric{k1=~"v.*", k2=~"v\\\\'.*"}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should add filters to expression', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=',
|
||||
value: 'v1',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '!=',
|
||||
value: 'v2',
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: 'metric{job="foo", k1="v1", k2!="v2"} - metric{k1="v1", k2!="v2"}' });
|
||||
});
|
||||
it('should add escaping if needed to regex filter expressions', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=~',
|
||||
value: 'v.*',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '=~',
|
||||
value: `v'.*`,
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({
|
||||
expr: `metric{job="foo", k1=~"v.*", k2=~"v\\\\'.*"} - metric{k1=~"v.*", k2=~"v\\\\'.*"}`,
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
it('should not modify expression with no filters', async () => {
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION });
|
||||
});
|
||||
|
||||
it('should add escaping if needed to regex filter expressions', () => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'k1',
|
||||
operator: '=~',
|
||||
value: 'v.*',
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
operator: '=~',
|
||||
value: `v'.*`,
|
||||
},
|
||||
{
|
||||
key: 'k3',
|
||||
operator: '=~',
|
||||
value: `v".*`,
|
||||
},
|
||||
{
|
||||
key: 'k4',
|
||||
operator: '=~',
|
||||
value: `\\v.*`,
|
||||
},
|
||||
];
|
||||
ds.query({
|
||||
interval: '15s',
|
||||
range: getMockTimeRange(),
|
||||
filters,
|
||||
targets: [target],
|
||||
} as DataQueryRequest<PromQuery>);
|
||||
const [result] = fetchMockCalledWith(fetchMock);
|
||||
expect(result).toMatchObject({
|
||||
expr: `metric{job="foo", k1=~"v.*", k2=~"v'.*", k3=~"v\\".*", k4=~"\\\\v.*"} - metric{k1=~"v.*", k2=~"v'.*", k3=~"v\\".*", k4=~"\\\\v.*"}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -470,56 +528,126 @@ describe('PrometheusDatasource', () => {
|
||||
});
|
||||
|
||||
describe('Prometheus regular escaping', () => {
|
||||
it('should not escape non-string', () => {
|
||||
expect(prometheusRegularEscape(12)).toEqual(12);
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
it('should not escape non-string', () => {
|
||||
expect(prometheusRegularEscape(12)).toEqual(12);
|
||||
});
|
||||
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape single quotes', () => {
|
||||
expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass");
|
||||
});
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not escape simple string', () => {
|
||||
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
it("should escape '", () => {
|
||||
expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass");
|
||||
});
|
||||
it('should not escape non-string', () => {
|
||||
expect(prometheusRegularEscape(12)).toEqual(12);
|
||||
});
|
||||
|
||||
it('should escape \\', () => {
|
||||
expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass');
|
||||
});
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape multiple characters', () => {
|
||||
expect(prometheusRegularEscape("'looking'glass'")).toEqual("\\\\'looking\\\\'glass\\\\'");
|
||||
});
|
||||
it('should not escape complete label matcher', () => {
|
||||
expect(prometheusRegularEscape('job="grafana"')).toEqual('job="grafana"');
|
||||
expect(prometheusRegularEscape('job!="grafana"')).toEqual('job!="grafana"');
|
||||
expect(prometheusRegularEscape('job=~"grafana"')).toEqual('job=~"grafana"');
|
||||
expect(prometheusRegularEscape('job!~"grafana"')).toEqual('job!~"grafana"');
|
||||
});
|
||||
|
||||
it('should escape multiple different characters', () => {
|
||||
expect(prometheusRegularEscape("'loo\\king'glass'")).toEqual("\\\\'loo\\\\king\\\\'glass\\\\'");
|
||||
it('should not escape single quotes', () => {
|
||||
expect(prometheusRegularEscape("looking'glass")).toEqual("looking'glass");
|
||||
});
|
||||
|
||||
it('should escape double quotes', () => {
|
||||
expect(prometheusRegularEscape('looking"glass')).toEqual('looking\\"glass');
|
||||
});
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass');
|
||||
});
|
||||
|
||||
it('should handle complete label matchers with escaped content', () => {
|
||||
expect(prometheusRegularEscape('job="my\\"service"')).toEqual('job="my\\"service"');
|
||||
expect(prometheusRegularEscape('job="\\\\server"')).toEqual('job="\\\\server"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prometheus regexes escaping', () => {
|
||||
it('should not escape simple string', () => {
|
||||
expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass');
|
||||
expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass');
|
||||
expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass');
|
||||
expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass');
|
||||
});
|
||||
|
||||
it('should handle multiple special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?');
|
||||
});
|
||||
});
|
||||
|
||||
it('should escape $^*+?.()|\\', () => {
|
||||
expect(prometheusSpecialRegexEscape("looking'glass")).toEqual("looking\\\\'glass");
|
||||
expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass');
|
||||
expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass');
|
||||
expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass');
|
||||
expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass');
|
||||
expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass');
|
||||
expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass');
|
||||
expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass');
|
||||
expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass');
|
||||
expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass');
|
||||
expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass');
|
||||
expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass');
|
||||
expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass');
|
||||
expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass');
|
||||
expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass');
|
||||
});
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
it('should escape multiple special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?');
|
||||
it('should not escape strings without special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
});
|
||||
|
||||
it('should escape special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass');
|
||||
expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass');
|
||||
expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass');
|
||||
expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass');
|
||||
expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass');
|
||||
expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass');
|
||||
expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass');
|
||||
expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass');
|
||||
expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass');
|
||||
expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass');
|
||||
expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass');
|
||||
expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass');
|
||||
expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass');
|
||||
expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass');
|
||||
});
|
||||
|
||||
it('should escape double quotes with special regex escaping', () => {
|
||||
expect(prometheusSpecialRegexEscape('looking"glass')).toEqual('looking\\\\\\"glass');
|
||||
});
|
||||
|
||||
it('should handle multiple special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?');
|
||||
});
|
||||
|
||||
it('should handle mixed quotes and special characters', () => {
|
||||
expect(prometheusSpecialRegexEscape('+looking"$glass?')).toEqual('\\\\+looking\\\\\\"\\\\$glass\\\\?');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -548,9 +676,27 @@ describe('PrometheusDatasource', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('and value is a string', () => {
|
||||
it('should only escape single quotes', () => {
|
||||
expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", customVariable)).toEqual("abc\\\\'$^*{}[]+?.()|");
|
||||
describe('with prometheusSpecialCharsInLabelValues disabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = false;
|
||||
});
|
||||
|
||||
describe('and value is a string', () => {
|
||||
it('should escape single quotes', () => {
|
||||
expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", customVariable)).toEqual("abc\\\\'$^*{}[]+?.()|");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with prometheusSpecialCharsInLabelValues enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.prometheusSpecialCharsInLabelValues = true;
|
||||
});
|
||||
|
||||
describe('and value is a string', () => {
|
||||
it('should only escape double quotes and backslashes', () => {
|
||||
expect(ds.interpolateQueryExpr('abc\'"$^*{}[]+?.()|\\', customVariable)).toEqual('abc\'\\"$^*{}[]+?.()|\\\\');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1046,13 +1046,46 @@ export function extractRuleMappingFromGroups(groups: RawRecordingRules[]): RuleQ
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: these two functions are very similar to the escapeLabelValueIn* functions
|
||||
// NOTE: these two functions are similar to the escapeLabelValueIn* functions
|
||||
// in language_utils.ts, but they are not exactly the same algorithm, and we found
|
||||
// no way to reuse one in the another or vice versa.
|
||||
export function prometheusRegularEscape<T>(value: T) {
|
||||
return typeof value === 'string' ? value.replace(/\\/g, '\\\\').replace(/'/g, "\\\\'") : value;
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
|
||||
// if the string looks like a complete label matcher (e.g. 'job="grafana"' or 'job=~"grafana"'),
|
||||
// don't escape the encapsulating quotes
|
||||
if (/^\w+(=|!=|=~|!~)".*"$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/\\/g, '\\\\') // escape backslashes
|
||||
.replace(/"/g, '\\"'); // escape double quotes
|
||||
}
|
||||
|
||||
// classic behavior
|
||||
return value
|
||||
.replace(/\\/g, '\\\\') // escape backslashes
|
||||
.replace(/'/g, "\\\\'"); // escape single quotes
|
||||
}
|
||||
|
||||
export function prometheusSpecialRegexEscape<T>(value: T) {
|
||||
return typeof value === 'string' ? value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&') : value;
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\\\\\') // escape backslashes
|
||||
.replace(/"/g, '\\\\\\"') // escape double quotes
|
||||
.replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&'); // escape regex metacharacters
|
||||
}
|
||||
|
||||
// classic behavior
|
||||
return value
|
||||
.replace(/\\/g, '\\\\\\\\') // escape backslashes
|
||||
.replace(/[$^*{}\[\]+?.()|]/g, '\\\\$&'); // escape regex metacharacters
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/shared/LokiAndPromQueryModellerBase.ts
|
||||
import { Registry } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { prometheusRegularEscape } from '../../datasource';
|
||||
import { PromVisualQueryOperationCategory } from '../types';
|
||||
|
||||
import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef, VisualQueryModeller } from './types';
|
||||
@ -89,7 +91,13 @@ export abstract class LokiAndPromQueryModellerBase implements VisualQueryModelle
|
||||
expr += ', ';
|
||||
}
|
||||
|
||||
expr += `${filter.label}${filter.op}"${filter.value}"`;
|
||||
let labelValue = filter.value;
|
||||
const usingRegexOperator = filter.op === '=~' || filter.op === '!~';
|
||||
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues && !usingRegexOperator) {
|
||||
labelValue = prometheusRegularEscape(labelValue);
|
||||
}
|
||||
expr += `${filter.label}${filter.op}"${labelValue}"`;
|
||||
}
|
||||
|
||||
return expr + `}`;
|
||||
|
@ -1608,6 +1608,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "prometheusSpecialCharsInLabelValues",
|
||||
Description: "Adds support for quotes and special characters in label values for Prometheus queries",
|
||||
FrontendOnly: true,
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
},
|
||||
{
|
||||
Name: "enableExtensionsAdminPage",
|
||||
Description: "Enables the extension admin page regardless of development mode",
|
||||
|
@ -213,6 +213,7 @@ dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true
|
||||
playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
|
||||
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
|
||||
prometheusSpecialCharsInLabelValues,experimental,@grafana/observability-metrics,false,false,true
|
||||
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
|
||||
zipkinBackendMigration,GA,@grafana/oss-big-tent,false,false,false
|
||||
enableSCIM,experimental,@grafana/identity-access-team,false,false,false
|
||||
|
|
@ -863,6 +863,10 @@ const (
|
||||
// Display Related Logs in Explore Metrics
|
||||
FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs"
|
||||
|
||||
// FlagPrometheusSpecialCharsInLabelValues
|
||||
// Adds support for quotes and special characters in label values for Prometheus queries
|
||||
FlagPrometheusSpecialCharsInLabelValues = "prometheusSpecialCharsInLabelValues"
|
||||
|
||||
// FlagEnableExtensionsAdminPage
|
||||
// Enables the extension admin page regardless of development mode
|
||||
FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage"
|
||||
|
@ -2931,6 +2931,19 @@
|
||||
"codeowner": "@grafana/observability-metrics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prometheusSpecialCharsInLabelValues",
|
||||
"resourceVersion": "1734047568531",
|
||||
"creationTimestamp": "2024-12-12T23:52:48Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Adds support for quotes and special characters in label values for Prometheus queries",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-metrics",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prometheusUsesCombobox",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AdHocVariableFilter, RawTimeRange, Scope } from '@grafana/data';
|
||||
import { getPrometheusTime } from '@grafana/prometheus/src/language_utils';
|
||||
import { PromQueryModeller } from '@grafana/prometheus/src/querybuilder/PromQueryModeller';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
|
||||
import { limitOtelMatchTerms } from '../otel/util';
|
||||
@ -7,6 +8,8 @@ import { callSuggestionsApi, SuggestionsResponse } from '../utils';
|
||||
|
||||
const LIMIT_REACHED = 'results truncated due to limit';
|
||||
|
||||
const queryModeller = new PromQueryModeller();
|
||||
|
||||
export async function getMetricNames(
|
||||
dataSourceUid: string,
|
||||
timeRange: RawTimeRange,
|
||||
@ -31,7 +34,11 @@ export async function getMetricNamesWithoutScopes(
|
||||
instances: string[],
|
||||
limit?: number
|
||||
) {
|
||||
const matchTerms = adhocFilters.map((filter) => `${filter.key}${filter.operator}"${filter.value}"`);
|
||||
const matchTerms = config.featureToggles.prometheusSpecialCharsInLabelValues
|
||||
? adhocFilters.map((filter) =>
|
||||
removeBrackets(queryModeller.renderLabels([{ label: filter.key, op: filter.operator, value: filter.value }]))
|
||||
)
|
||||
: adhocFilters.map((filter) => `${filter.key}${filter.operator}"${filter.value}"`);
|
||||
let missingOtelTargets = false;
|
||||
|
||||
if (jobs.length > 0 && instances.length > 0) {
|
||||
@ -99,3 +106,8 @@ export async function getMetricNamesWithScopes(
|
||||
missingOtelTargets: false,
|
||||
};
|
||||
}
|
||||
|
||||
function removeBrackets(input: string): string {
|
||||
const match = input.match(/^\{(.*)\}$/); // extract the content inside the brackets
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { MetricFindValue } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AdHocFiltersVariable, ConstantVariable, CustomVariable, sceneGraph, SceneObject } from '@grafana/scenes';
|
||||
|
||||
import { DataTrail } from '../DataTrail';
|
||||
@ -121,6 +122,9 @@ export function getOtelResourcesObject(scene: SceneObject, firstQueryVal?: strin
|
||||
|
||||
// start with the deployment environment
|
||||
let allFilters = `deployment_environment${op}"${val}"`;
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
|
||||
allFilters = `deployment_environment${op}'${val}'`;
|
||||
}
|
||||
let allLabels = 'deployment_environment';
|
||||
|
||||
// add the other OTEL resource filters
|
||||
@ -129,7 +133,11 @@ export function getOtelResourcesObject(scene: SceneObject, firstQueryVal?: strin
|
||||
const op = otelFilters[i].operator;
|
||||
const labelValue = otelFilters[i].value;
|
||||
|
||||
allFilters += `,${labelName}${op}"${labelValue}"`;
|
||||
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
|
||||
allFilters += `,${labelName}${op}'${labelValue}'`;
|
||||
} else {
|
||||
allFilters += `,${labelName}${op}"${labelValue}"`;
|
||||
}
|
||||
|
||||
const addLabelToGroupLeft = labelName !== 'job' && labelName !== 'instance';
|
||||
|
||||
@ -167,8 +175,8 @@ export function limitOtelMatchTerms(
|
||||
let initialCharAmount = matchTerms.join(',').length;
|
||||
|
||||
// start to add values to the regex and start quote
|
||||
let jobsRegex = 'job=~"';
|
||||
let instancesRegex = 'instance=~"';
|
||||
let jobsRegex = `job=~'`;
|
||||
let instancesRegex = `instance=~'`;
|
||||
|
||||
// iterate through the jobs and instances,
|
||||
// count the chars as they are added,
|
||||
@ -206,8 +214,8 @@ export function limitOtelMatchTerms(
|
||||
}
|
||||
}
|
||||
// complete the quote after values have been added
|
||||
jobsRegex += '"';
|
||||
instancesRegex += '"';
|
||||
jobsRegex += `'`;
|
||||
instancesRegex += `'`;
|
||||
|
||||
return {
|
||||
missingOtelTargets,
|
||||
|
@ -164,8 +164,8 @@ describe('limitOtelMatchTerms', () => {
|
||||
const result = limitOtelMatchTerms(promMatchTerms, jobs, instances);
|
||||
|
||||
expect(result.missingOtelTargets).toEqual(true);
|
||||
expect(result.jobsRegex).toEqual('job=~"a"');
|
||||
expect(result.instancesRegex).toEqual('instance=~"d"');
|
||||
expect(result.jobsRegex).toEqual(`job=~'a'`);
|
||||
expect(result.instancesRegex).toEqual(`instance=~'d'`);
|
||||
});
|
||||
|
||||
it('should include | char in the count', () => {
|
||||
@ -189,8 +189,8 @@ describe('limitOtelMatchTerms', () => {
|
||||
const result = limitOtelMatchTerms(promMatchTerms, jobs, instances);
|
||||
|
||||
expect(result.missingOtelTargets).toEqual(true);
|
||||
expect(result.jobsRegex).toEqual('job=~"a|b"');
|
||||
expect(result.instancesRegex).toEqual('instance=~"d|e"');
|
||||
expect(result.jobsRegex).toEqual(`job=~'a|b'`);
|
||||
expect(result.instancesRegex).toEqual(`instance=~'d|e'`);
|
||||
});
|
||||
|
||||
it('should add all OTel job and instance matches if the character count is less that 2000', () => {
|
||||
@ -203,8 +203,8 @@ describe('limitOtelMatchTerms', () => {
|
||||
const result = limitOtelMatchTerms(promMatchTerms, jobs, instances);
|
||||
|
||||
expect(result.missingOtelTargets).toEqual(false);
|
||||
expect(result.jobsRegex).toEqual('job=~"job1|job2|job3|job4|job5"');
|
||||
expect(result.instancesRegex).toEqual('instance=~"instance1|instance2|instance3|instance4|instance5"');
|
||||
expect(result.jobsRegex).toEqual(`job=~'job1|job2|job3|job4|job5'`);
|
||||
expect(result.instancesRegex).toEqual(`instance=~'instance1|instance2|instance3|instance4|instance5'`);
|
||||
});
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user