diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts index 1060c8df90a..ff1f0490560 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -1,11 +1,13 @@ // Core grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts import UFuzzy from '@leeoniya/ufuzzy'; +import { languages } from 'monaco-editor'; import { config } from '@grafana/runtime'; import { prometheusRegularEscape } from '../../../datasource'; import { escapeLabelValueInExactSelector } from '../../../language_utils'; import { FUNCTIONS } from '../../../promql'; +import { isValidLegacyName } from '../../../utf8_support'; import { DataProvider } from './data_provider'; import type { Label, Situation } from './situation'; @@ -14,10 +16,16 @@ import { NeverCaseError } from './util'; export type CompletionType = 'HISTORY' | 'FUNCTION' | 'METRIC_NAME' | 'DURATION' | 'LABEL_NAME' | 'LABEL_VALUE'; +// We cannot use languages.CompletionItemInsertTextRule.InsertAsSnippet because grafana-prometheus package isn't compatible +// It should first change the moduleResolution to bundler for TS to correctly resolve the types +// https://github.com/grafana/grafana/pull/96450 +const InsertAsSnippet = 4; + type Completion = { type: CompletionType; label: string; insertText: string; + insertTextRules?: languages.CompletionItemInsertTextRule; detail?: string; documentation?: string; triggerOnInsert?: boolean; @@ -29,6 +37,11 @@ const metricNamesSearch = { singleError: new UFuzzy({ intraMode: 1 }), }; +// Snippet Marker is telling monaco where to show the cursor and maybe a help text +// With help text example: ${1:labelName} +// labelName will be shown as selected. So user would know what to type next +const snippetMarker = '${1:}'; + interface MetricFilterOptions { metricNames: string[]; inputText: string; @@ -74,9 +87,16 @@ function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] return dataProvider.metricNamesToMetrics(metricNames).map((metric) => ({ type: 'METRIC_NAME', label: metric.name, - insertText: metric.name, detail: `${metric.name} : ${metric.type}`, documentation: metric.help, + ...(metric.isUtf8 + ? { + insertText: `{"${metric.name}"${snippetMarker}}`, + insertTextRules: InsertAsSnippet, + } + : { + insertText: metric.name, + }), })); } @@ -159,12 +179,22 @@ async function getLabelNamesForCompletions( dataProvider: DataProvider ): Promise { const labelNames = await getLabelNames(metric, otherLabels, dataProvider); - return labelNames.map((text) => ({ - type: 'LABEL_NAME', - label: text, - insertText: `${text}${suffix}`, - triggerOnInsert, - })); + return labelNames.map((text) => { + const isUtf8 = !isValidLegacyName(text); + return { + type: 'LABEL_NAME', + label: text, + ...(isUtf8 + ? { + insertText: `"${text}"${suffix}`, + insertTextRules: InsertAsSnippet, + } + : { + insertText: `${text}${suffix}`, + }), + triggerOnInsert, + }; + }); } async function getLabelNamesForSelectorCompletions( diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts index 48549442d64..b1baad9a770 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts @@ -3,6 +3,7 @@ import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below import PromQlLanguageProvider from '../../../language_provider'; import { PromQuery } from '../../../types'; +import { isValidLegacyName } from '../../../utf8_support'; export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete'; @@ -26,6 +27,7 @@ interface Metric { name: string; help: string; type: string; + isUtf8?: boolean; } export interface DataProviderParams { @@ -78,6 +80,7 @@ export class DataProvider { name: m, help: metaItem?.help ?? '', type: metaItem?.type ?? '', + isUtf8: !isValidLegacyName(m), }; }); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts index abc97de27ce..5108401b2fd 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts @@ -95,6 +95,7 @@ export function getCompletionProvider( kind: getMonacoCompletionItemKind(item.type, monaco), label: item.label, insertText: item.insertText, + insertTextRules: item.insertTextRules, detail: item.detail, documentation: item.documentation, sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts index 188eb333415..65e82cc1ca3 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts @@ -56,6 +56,7 @@ describe('situation', () => { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', metricName: 'something', otherLabels: [], + betweenQuotes: false, }); assertSituation('sum(something) by (^)', { @@ -79,34 +80,157 @@ describe('situation', () => { { name: 'three', value: 'val3', op: '=~' }, { name: 'four', value: 'val4', op: '!~' }, ], + betweenQuotes: false, }); assertSituation('{^}', { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels: [], + betweenQuotes: false, }); assertSituation('{one="val1",^}', { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels: [{ name: 'one', value: 'val1', op: '=' }], + betweenQuotes: false, }); // single-quoted label-values with escape assertSituation("{one='val\\'1',^}", { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels: [{ name: 'one', value: "val'1", op: '=' }], + betweenQuotes: false, }); // double-quoted label-values with escape assertSituation('{one="val\\"1",^}', { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels: [{ name: 'one', value: 'val"1', op: '=' }], + betweenQuotes: false, }); // backticked label-values with escape (the escape should not be interpreted) assertSituation('{one=`val\\"1`,^}', { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels: [{ name: 'one', value: 'val\\"1', op: '=' }], + betweenQuotes: false, + }); + }); + + describe('utf-8 metric name support', () => { + it('with utf8 metric name no label and no comma', () => { + assertSituation(`{"metric.name"^}`, null); + }); + + it('with utf8 metric name no label', () => { + assertSituation(`{"metric.name", ^}`, { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + metricName: 'metric.name', + otherLabels: [], + betweenQuotes: false, + }); + }); + + it('with utf8 metric name requesting utf8 labels in quotes', () => { + assertSituation(`{"metric.name", "^"}`, { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + metricName: 'metric.name', + otherLabels: [], + betweenQuotes: true, + }); + }); + + it('with utf8 metric name with a legacy label', () => { + assertSituation(`{"metric.name", label1="val", ^}`, { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + metricName: 'metric.name', + otherLabels: [{ name: 'label1', value: 'val', op: '=' }], + betweenQuotes: false, + }); + }); + + it('with utf8 metric name with a legacy label and no value', () => { + assertSituation(`{"metric.name", label1="^"}`, { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'metric.name', + labelName: 'label1', + betweenQuotes: true, + otherLabels: [], + }); + }); + + it('with utf8 metric name with a utf8 label and no value', () => { + assertSituation(`{"metric.name", "utf8.label"="^"}`, { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'metric.name', + labelName: '"utf8.label"', + betweenQuotes: true, + otherLabels: [], + }); + }); + + it('with utf8 metric name with a legacy label and utf8 label', () => { + assertSituation(`{"metric.name", label1="val", "utf8.label"="^"}`, { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'metric.name', + labelName: `"utf8.label"`, + betweenQuotes: true, + otherLabels: [{ name: 'label1', value: 'val', op: '=' }], + }); + }); + + it('with utf8 metric name with a utf8 label and legacy label', () => { + assertSituation(`{"metric.name", "utf8.label"="val", label1="^"}`, { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'metric.name', + labelName: `label1`, + betweenQuotes: true, + otherLabels: [{ name: '"utf8.label"', value: 'val', op: '=' }], + }); + }); + + it('with utf8 metric name with grouping', () => { + assertSituation(`sum by (^)(rate({"metric.name", label1="val"}[1m]))`, { + type: 'IN_GROUPING', + metricName: 'metric.name', + otherLabels: [], + }); + }); + }); + + it('utf-8 label support', () => { + assertSituation(`metric{"label": "^"}`, null); + + assertSituation(`metric{"label with space": "^"}`, null); + + assertSituation(`metric{"label_🤖": "^"}`, null); + + assertSituation(`metric{"Spaß": "^"}`, null); + + assertSituation(`{"metric", "Spaß": "^"}`, null); + + assertSituation('something{"job"=^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: '"job"', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('something{"job📈"=^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: '"job📈"', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('something{"job with space"=^,host="h1"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + metricName: 'something', + labelName: '"job with space"', + betweenQuotes: false, + otherLabels: [{ name: 'host', value: 'h1', op: '=' }], }); }); @@ -194,6 +318,7 @@ describe('situation', () => { { name: 'three', value: 'val3', op: '=~' }, { name: 'four', value: 'val4', op: '!~' }, ], + betweenQuotes: false, }); }); }); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts index c7420522b4c..7b1bbac866a 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts @@ -18,6 +18,8 @@ import { NumberDurationLiteralInDurationContext, parser, PromQL, + QuotedLabelMatcher, + QuotedLabelName, StringLiteral, UnquotedLabelMatcher, VectorSelector, @@ -35,8 +37,10 @@ type NodeTypeId = | typeof GroupingLabels | typeof Identifier | typeof UnquotedLabelMatcher + | typeof QuotedLabelMatcher | typeof LabelMatchers | typeof LabelName + | typeof QuotedLabelName | typeof PromQL | typeof StringLiteral | typeof VectorSelector @@ -80,8 +84,10 @@ function walk(node: SyntaxNode, path: Path): SyntaxNode | null { return current; } -function getNodeText(node: SyntaxNode, text: string): string { - return text.slice(node.from, node.to); +function getNodeText(node: SyntaxNode, text: string, utf8?: boolean): string { + const nodeFrom = utf8 ? node.from + 1 : node.from; + const nodeTo = utf8 ? node.to - 1 : node.to; + return text.slice(nodeFrom, nodeTo); } function parsePromQLStringLiteral(text: string): string { @@ -140,6 +146,8 @@ export type Situation = type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME'; metricName?: string; otherLabels: Label[]; + // utf8 labels must be in quotes + betweenQuotes: boolean; } | { type: 'IN_GROUPING'; @@ -170,6 +178,10 @@ const RESOLVERS: Resolver[] = [ path: [LabelMatchers, VectorSelector], fun: resolveLabelKeysWithEquals, }, + { + path: [StringLiteral, QuotedLabelName, LabelMatchers, VectorSelector], + fun: resolveUtf8LabelKeysWithEquals, + }, { path: [PromQL], fun: resolveTopLevel, @@ -182,6 +194,10 @@ const RESOLVERS: Resolver[] = [ path: [StringLiteral, UnquotedLabelMatcher], fun: resolveLabelMatcher, }, + { + path: [StringLiteral, QuotedLabelMatcher], + fun: resolveQuotedLabelMatcher, + }, { path: [ERROR_NODE_NAME, BinaryExpr, PromQL], fun: resolveTopLevel, @@ -190,6 +206,10 @@ const RESOLVERS: Resolver[] = [ path: [ERROR_NODE_NAME, UnquotedLabelMatcher], fun: resolveLabelMatcher, }, + { + path: [ERROR_NODE_NAME, QuotedLabelMatcher], + fun: resolveQuotedLabelMatcher, + }, { path: [ERROR_NODE_NAME, NumberDurationLiteralInDurationContext, MatrixSelector], fun: resolveDurations, @@ -217,11 +237,13 @@ function getLabelOp(opNode: SyntaxNode): LabelOperator | null { } function getLabel(labelMatcherNode: SyntaxNode, text: string): Label | null { - if (labelMatcherNode.type.id !== UnquotedLabelMatcher) { + const allowedMatchers = new Set([UnquotedLabelMatcher, QuotedLabelMatcher]); + if (!allowedMatchers.has(labelMatcherNode.type.id)) { return null; } - const nameNode = walk(labelMatcherNode, [['firstChild', LabelName]]); + const nameNode = + walk(labelMatcherNode, [['firstChild', LabelName]]) ?? walk(labelMatcherNode, [['firstChild', QuotedLabelName]]); if (nameNode === null) { return null; @@ -254,8 +276,17 @@ function getLabels(labelMatchersNode: SyntaxNode, text: string): Label[] { return []; } - const labelNodes = labelMatchersNode.getChildren(UnquotedLabelMatcher); - return labelNodes.map((ln) => getLabel(ln, text)).filter(notEmpty); + const matchers = [UnquotedLabelMatcher, QuotedLabelMatcher]; + + return matchers.reduce((acc, matcher) => { + labelMatchersNode.getChildren(matcher).forEach((ln) => { + const label = getLabel(ln, text); + if (notEmpty(label)) { + acc.push(label); + } + }); + return acc; + }, []); } function getNodeChildren(node: SyntaxNode): SyntaxNode[] { @@ -299,12 +330,20 @@ function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): return null; } - const metricIdNode = getNodeInSubtree(bodyNode, Identifier); - if (metricIdNode === null) { + const metricIdNode = getNodeInSubtree(bodyNode, Identifier) ?? getNodeInSubtree(bodyNode, StringLiteral); + + if (!metricIdNode) { return null; } - const metricName = getNodeText(metricIdNode, text); + // Let's check whether it's a utf8 metric. + // A utf8 metric must be a StringLiteral and its parent must be a QuotedLabelName + if (metricIdNode.type.id === StringLiteral && metricIdNode.parent?.type.id !== QuotedLabelName) { + return null; + } + + const metricName = getNodeText(metricIdNode, text, metricIdNode.type.id === StringLiteral); + return { type: 'IN_GROUPING', metricName, @@ -341,29 +380,54 @@ function resolveLabelMatcher(node: SyntaxNode, text: string, pos: number): Situa // we need to remove "our" label from all-labels, if it is in there const otherLabels = allLabels.filter((label) => label.name !== labelName); - const metricNameNode = walk(labelMatchersNode, [ - ['parent', VectorSelector], - ['firstChild', Identifier], - ]); - - if (metricNameNode === null) { - // we are probably in a situation without a metric name - return { - type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', - labelName, - betweenQuotes: inStringNode, - otherLabels, - }; - } - - const metricName = getNodeText(metricNameNode, text); + const metricName = getMetricName(labelMatchersNode, text); + // we are probably in a situation without a metric name return { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', - metricName, labelName, betweenQuotes: inStringNode, otherLabels, + ...(metricName ? { metricName } : {}), + }; +} + +function resolveQuotedLabelMatcher(node: SyntaxNode, text: string, pos: number): Situation | null { + // we can arrive here in two situation. `node` is either: + // - a StringNode (like in `{"job"="^"}`) + // - or an error node (like in `{"job"=^}`) + const inStringNode = !node.type.isError; + + const parent = walk(node, [['parent', QuotedLabelMatcher]]); + if (parent === null) { + return null; + } + + const labelNameNode = walk(parent, [['firstChild', QuotedLabelName]]); + if (labelNameNode === null) { + return null; + } + + const labelName = getNodeText(labelNameNode, text); + + const labelMatchersNode = walk(parent, [['parent', LabelMatchers]]); + if (labelMatchersNode === null) { + return null; + } + + // now we need to find the other names + const allLabels = getLabels(labelMatchersNode, text); + + // we need to remove "our" label from all-labels, if it is in there + const otherLabels = allLabels.filter((label) => label.name !== labelName); + const metricName = getMetricName(parent.parent!, text); + + return { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName, + betweenQuotes: inStringNode, + otherLabels, + ...(metricName ? { metricName } : {}), }; } @@ -388,7 +452,7 @@ function resolveDurations(node: SyntaxNode, text: string, pos: number): Situatio function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number): Situation | null { // next false positive: // `something{a="1"^}` - const child = walk(node, [['firstChild', UnquotedLabelMatcher]]); + let child = walk(node, [['firstChild', UnquotedLabelMatcher]]); if (child !== null) { // means the label-matching part contains at least one label already. // @@ -403,28 +467,72 @@ function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number) } } - const metricNameNode = walk(node, [ + // next false positive: + // `{"utf8.metric"^}` + child = walk(node, [['firstChild', QuotedLabelName]]); + if (child !== null) { + // means the label-matching part contains a utf8 metric. + // + // in this case, we will need to have a `,` character at the end, + // to be able to suggest adding the next label. + // the area between the end-of-the-child-node and the cursor-pos + // must contain a `,` in this case. + const textToCheck = text.slice(child.to, pos); + + if (!textToCheck.includes(',')) { + return null; + } + } + + const otherLabels = getLabels(node, text); + const metricName = getMetricName(node, text); + + return { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels, + betweenQuotes: false, + ...(metricName ? { metricName } : {}), + }; +} + +function resolveUtf8LabelKeysWithEquals(node: SyntaxNode, text: string, pos: number): Situation | null { + const otherLabels = getLabels(node, text); + const metricName = node.parent?.parent ? getMetricName(node.parent.parent, text) : null; + + return { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels, + betweenQuotes: true, + ...(metricName ? { metricName } : {}), + }; +} + +function getMetricName(node: SyntaxNode, text: string): string | null { + // Legacy Metric metric_name{label="value"} + const legacyMetricNameNode = walk(node, [ ['parent', VectorSelector], ['firstChild', Identifier], ]); - const otherLabels = getLabels(node, text); - - if (metricNameNode === null) { - // we are probably in a situation without a metric name. - return { - type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', - otherLabels, - }; + if (legacyMetricNameNode) { + return getNodeText(legacyMetricNameNode, text); } - const metricName = getNodeText(metricNameNode, text); + // check for a utf-8 metric + // utf-8 metric {"metric.name", label="value"} + const utf8MetricNameNode = walk(node, [ + ['parent', VectorSelector], + ['firstChild', LabelMatchers], + ['firstChild', QuotedLabelName], + ['firstChild', StringLiteral], + ]); - return { - type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', - metricName, - otherLabels, - }; + if (utf8MetricNameNode) { + return getNodeText(utf8MetricNameNode, text, true); + } + + // no metric name + return null; } // we find the first error-node in the tree that is at the cursor-position. @@ -461,10 +569,10 @@ export function getSituation(text: string, pos: number): Situation | null { } /** - PromQL - Expr - VectorSelector - LabelMatchers + PromQL + Expr + VectorSelector + LabelMatchers */ const tree = parser.parse(text); diff --git a/packages/grafana-prometheus/src/language_provider.test.ts b/packages/grafana-prometheus/src/language_provider.test.ts index c9ed7dc2b14..48ed7276693 100644 --- a/packages/grafana-prometheus/src/language_provider.test.ts +++ b/packages/grafana-prometheus/src/language_provider.test.ts @@ -4,7 +4,7 @@ import { AbstractLabelOperator, dateTime, TimeRange } from '@grafana/data'; import { DEFAULT_SERIES_LIMIT } from './components/PrometheusMetricsBrowser'; import { Label } from './components/monaco-query-field/monaco-completion-provider/situation'; import { PrometheusDatasource } from './datasource'; -import LanguageProvider from './language_provider'; +import LanguageProvider, { removeQuotesIfExist } from './language_provider'; import { getClientCacheDurationInMinutes, getPrometheusTime, getRangeSnapInterval } from './language_utils'; import { PrometheusCacheLevel, PromQuery } from './types'; @@ -120,7 +120,13 @@ describe('Language completion provider', () => { const labelName = 'job'; const labelValue = 'grafana'; - getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]); + getSeriesLabels(`{${labelName}="${labelValue}"}`, [ + { + name: labelName, + value: labelValue, + op: '=', + }, + ] as Label[]); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( `/api/v1/labels`, @@ -145,7 +151,13 @@ describe('Language completion provider', () => { const labelName = 'job'; const labelValue = 'grafana'; - getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]); + getSeriesLabels(`{${labelName}="${labelValue}"}`, [ + { + name: labelName, + value: labelValue, + op: '=', + }, + ] as Label[]); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( '/api/v1/series', @@ -174,7 +186,13 @@ describe('Language completion provider', () => { const labelName = 'job'; const labelValue = 'grafana'; - getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]); + getSeriesLabels(`{${labelName}="${labelValue}"}`, [ + { + name: labelName, + value: labelValue, + op: '=', + }, + ] as Label[]); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( `/api/v1/labels`, @@ -569,14 +587,14 @@ describe('Language completion provider', () => { it('should interpolate variable in series', () => { const languageProvider = new LanguageProvider({ ...defaultDatasource, - interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'), + interpolateString: (string: string) => string.replace(/\$/g, 'interpolated_'), } as PrometheusDatasource); const fetchLabelValues = languageProvider.fetchLabelValues; const requestSpy = jest.spyOn(languageProvider, 'request'); fetchLabelValues('$job'); expect(requestSpy).toHaveBeenCalled(); expect(requestSpy).toHaveBeenCalledWith( - '/api/v1/label/interpolated-job/values', + '/api/v1/label/interpolated_job/values', [], { end: toPrometheusTimeString, @@ -585,6 +603,69 @@ describe('Language completion provider', () => { undefined ); }); + + it('should fetch with encoded utf8 label', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'http.status:sum'), + } as PrometheusDatasource); + const fetchLabelValues = languageProvider.fetchLabelValues; + const requestSpy = jest.spyOn(languageProvider, 'request'); + fetchLabelValues('"http.status:sum"'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/label/U__http_2e_status:sum/values', + [], + { + end: toPrometheusTimeString, + start: fromPrometheusTimeString, + }, + undefined + ); + }); + }); + + describe('fetchSeriesValuesWithMatch', () => { + it('should fetch with encoded utf8 label', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + interpolateString: (string: string) => string.replace(/\$/g, 'http.status:sum'), + } as PrometheusDatasource); + const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; + const requestSpy = jest.spyOn(languageProvider, 'request'); + fetchSeriesValuesWithMatch('"http.status:sum"', '{__name__="a_utf8_http_requests_total"}'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/label/U__http_2e_status:sum/values', + [], + { + end: toPrometheusTimeString, + start: fromPrometheusTimeString, + 'match[]': '{__name__="a_utf8_http_requests_total"}', + }, + undefined + ); + }); + + it('should fetch without encoding for standard prometheus labels', () => { + const languageProvider = new LanguageProvider({ + ...defaultDatasource, + } as PrometheusDatasource); + const fetchSeriesValuesWithMatch = languageProvider.fetchSeriesValuesWithMatch; + const requestSpy = jest.spyOn(languageProvider, 'request'); + fetchSeriesValuesWithMatch('"http_status_sum"', '{__name__="a_utf8_http_requests_total"}'); + expect(requestSpy).toHaveBeenCalled(); + expect(requestSpy).toHaveBeenCalledWith( + '/api/v1/label/http_status_sum/values', + [], + { + end: toPrometheusTimeString, + start: fromPrometheusTimeString, + 'match[]': '{__name__="a_utf8_http_requests_total"}', + }, + undefined + ); + }); }); describe('disabled metrics lookup', () => { @@ -650,3 +731,59 @@ describe('Language completion provider', () => { }); }); }); + +describe('removeQuotesIfExist', () => { + it('removes quotes from a string with double quotes', () => { + const input = '"hello"'; + const result = removeQuotesIfExist(input); + expect(result).toBe('hello'); + }); + + it('returns the original string if it does not start and end with quotes', () => { + const input = 'hello'; + const result = removeQuotesIfExist(input); + expect(result).toBe('hello'); + }); + + it('returns the original string if it has mismatched quotes', () => { + const input = '"hello'; + const result = removeQuotesIfExist(input); + expect(result).toBe('"hello'); + }); + + it('removes quotes for strings with special characters inside quotes', () => { + const input = '"hello, world!"'; + const result = removeQuotesIfExist(input); + expect(result).toBe('hello, world!'); + }); + + it('removes quotes for strings with spaces inside quotes', () => { + const input = '" "'; + const result = removeQuotesIfExist(input); + expect(result).toBe(' '); + }); + + it('returns the original string for an empty string', () => { + const input = ''; + const result = removeQuotesIfExist(input); + expect(result).toBe(''); + }); + + it('returns the original string if the string only has a single quote character', () => { + const input = '"'; + const result = removeQuotesIfExist(input); + expect(result).toBe('"'); + }); + + it('handles strings with nested quotes correctly', () => { + const input = '"nested \"quotes\""'; + const result = removeQuotesIfExist(input); + expect(result).toBe('nested \"quotes\"'); + }); + + it('removes quotes from a numeric string wrapped in quotes', () => { + const input = '"12345"'; + const result = removeQuotesIfExist(input); + expect(result).toBe('12345'); + }); +}); diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts index 105c354dcae..749c579296e 100644 --- a/packages/grafana-prometheus/src/language_provider.ts +++ b/packages/grafana-prometheus/src/language_provider.ts @@ -29,6 +29,7 @@ import { import PromqlSyntax from './promql'; import { buildVisualQueryFromString } from './querybuilder/parsing'; import { PrometheusCacheLevel, PromMetricsMetadata, PromQuery } from './types'; +import { escapeForUtf8Support, isValidLegacyName } from './utf8_support'; const DEFAULT_KEYS = ['job', 'instance']; const EMPTY_SELECTOR = '{}'; @@ -208,7 +209,8 @@ export default class PromQlLanguageProvider extends LanguageProvider { fetchLabelValues = async (key: string): Promise => { const params = this.datasource.getAdjustedInterval(this.timeRange); const interpolatedName = this.datasource.interpolateString(key); - const url = `/api/v1/label/${interpolatedName}/values`; + const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName)); + const url = `/api/v1/label/${interpolatedAndEscapedName}/values`; const value = await this.request(url, [], params, this.getDefaultCacheHeaders()); return value ?? []; }; @@ -232,10 +234,11 @@ export default class PromQlLanguageProvider extends LanguageProvider { queries?.forEach((q) => { const visualQuery = buildVisualQueryFromString(q.expr); if (visualQuery.query.metric !== '') { - searchParams.append('match[]', visualQuery.query.metric); + const isUtf8Metric = !isValidLegacyName(visualQuery.query.metric); + searchParams.append('match[]', isUtf8Metric ? `{"${visualQuery.query.metric}"}` : visualQuery.query.metric); if (visualQuery.query.binaryQueries) { visualQuery.query.binaryQueries.forEach((bq) => { - searchParams.append('match[]', bq.query.metric); + searchParams.append('match[]', isUtf8Metric ? `{"${bq.query.metric}"}` : bq.query.metric); }); } } @@ -263,7 +266,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { getSeriesValues = async (labelName: string, selector: string): Promise => { if (!this.datasource.hasLabelsMatchAPISupport()) { const data = await this.getSeries(selector); - return data[labelName] ?? []; + return data[removeQuotesIfExist(labelName)] ?? []; } return await this.fetchSeriesValuesWithMatch(labelName, selector); }; @@ -297,7 +300,14 @@ export default class PromQlLanguageProvider extends LanguageProvider { requestOptions = undefined; } - const value = await this.request(`/api/v1/label/${interpolatedName}/values`, [], urlParams, requestOptions); + const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName ?? '')); + + const value = await this.request( + `/api/v1/label/${interpolatedAndEscapedName}/values`, + [], + urlParams, + requestOptions + ); return value ?? []; }; @@ -493,3 +503,10 @@ function isCancelledError(error: unknown): error is { } { return typeof error === 'object' && error !== null && 'cancelled' in error && error.cancelled === true; } + +// For utf8 labels we use quotes around the label +// While requesting the label values we must remove the quotes +export function removeQuotesIfExist(input: string): string { + const match = input.match(/^"(.*)"$/); // extract the content inside the quotes + return match?.[1] ?? input; +} diff --git a/packages/grafana-prometheus/src/language_utils.test.ts b/packages/grafana-prometheus/src/language_utils.test.ts index a0ac8fcb808..6c536879fc6 100644 --- a/packages/grafana-prometheus/src/language_utils.test.ts +++ b/packages/grafana-prometheus/src/language_utils.test.ts @@ -11,6 +11,7 @@ import { getPrometheusTime, getRangeSnapInterval, parseSelector, + processLabels, toPromLikeQuery, truncateResult, } from './language_utils'; @@ -565,3 +566,54 @@ describe('truncateResult', () => { expect(array[999]).toBe(999); }); }); + +describe('processLabels', () => { + it('export abstract query to expr', () => { + const labels: Array<{ [key: string]: string }> = [ + { label1: 'value1' }, + { label2: 'value2' }, + { label3: 'value3' }, + { label1: 'value1' }, + { label1: 'value1b' }, + ]; + + expect(processLabels(labels)).toEqual({ + keys: ['label1', 'label2', 'label3'], + values: { label1: ['value1', 'value1b'], label2: ['value2'], label3: ['value3'] }, + }); + }); + + it('dont wrap utf8 label values with quotes', () => { + const labels: Array<{ [key: string]: string }> = [ + { label1: 'value1' }, + { label2: 'value2' }, + { label3: 'value3 with space' }, + { label4: 'value4.with.dot' }, + ]; + + expect(processLabels(labels)).toEqual({ + keys: ['label1', 'label2', 'label3', 'label4'], + values: { + label1: ['value1'], + label2: ['value2'], + label3: [`value3 with space`], + label4: [`value4.with.dot`], + }, + }); + }); + + it('dont wrap utf8 labels with quotes', () => { + const labels: Array<{ [key: string]: string }> = [ + { 'label1 with space': 'value1' }, + { 'label2.with.dot': 'value2' }, + ]; + + expect(processLabels(labels)).toEqual({ + keys: ['label1 with space', 'label2.with.dot'], + values: { + 'label1 with space': ['value1'], + 'label2.with.dot': ['value2'], + }, + }); + }); +}); diff --git a/packages/grafana-prometheus/src/language_utils.ts b/packages/grafana-prometheus/src/language_utils.ts index 61d48edef1a..69e1a2ccad8 100644 --- a/packages/grafana-prometheus/src/language_utils.ts +++ b/packages/grafana-prometheus/src/language_utils.ts @@ -478,7 +478,10 @@ export function extractLabelMatchers(tokens: Array): AbstractLab export function getRangeSnapInterval( cacheLevel: PrometheusCacheLevel, range: TimeRange -): { start: string; end: string } { +): { + start: string; + end: string; +} { // Don't round the range if we're not caching if (cacheLevel === PrometheusCacheLevel.None) { return { diff --git a/packages/grafana-prometheus/src/utf8_support.test.ts b/packages/grafana-prometheus/src/utf8_support.test.ts new file mode 100644 index 00000000000..0c62733bddf --- /dev/null +++ b/packages/grafana-prometheus/src/utf8_support.test.ts @@ -0,0 +1,35 @@ +import { escapeForUtf8Support, utf8Support } from './utf8_support'; + +describe('utf8 support', () => { + it('should return utf8 labels wrapped in quotes', () => { + const labels = ['valid:label', 'metric_label', 'utf8 label with space 🤘', '']; + const expected = ['valid:label', 'metric_label', `"utf8 label with space 🤘"`, '']; + const supportedLabels = labels.map(utf8Support); + expect(supportedLabels).toEqual(expected); + }); +}); + +describe('applyValueEncodingEscaping', () => { + it('should return utf8 labels wrapped in quotes', () => { + const labels = [ + 'no:escaping_required', + 'mysystem.prod.west.cpu.load', + 'mysystem.prod.west.cpu.load_total', + 'http.status:sum', + 'my lovely_http.status:sum', + '花火', + 'label with 😱', + ]; + const expected = [ + 'no:escaping_required', + 'U__mysystem_2e_prod_2e_west_2e_cpu_2e_load', + 'U__mysystem_2e_prod_2e_west_2e_cpu_2e_load__total', + 'U__http_2e_status:sum', + 'U__my_20_lovely__http_2e_status:sum', + 'U___82b1__706b_', + 'U__label_20_with_20__1f631_', + ]; + const excapedLabels = labels.map(escapeForUtf8Support); + expect(excapedLabels).toEqual(expected); + }); +}); diff --git a/packages/grafana-prometheus/src/utf8_support.ts b/packages/grafana-prometheus/src/utf8_support.ts new file mode 100644 index 00000000000..386d41ba987 --- /dev/null +++ b/packages/grafana-prometheus/src/utf8_support.ts @@ -0,0 +1,81 @@ +export const utf8Support = (value: string) => { + if (value === '') { + return value; + } + const isLegacyLabel = isValidLegacyName(value); + if (isLegacyLabel) { + return value; + } + return `"${value}"`; +}; + +export const escapeForUtf8Support = (value: string) => { + const isLegacyLabel = isValidLegacyName(value); + if (isLegacyLabel) { + return value; + } + + let escaped = 'U__'; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + const codePoint = value.codePointAt(i); + + if (char === '_') { + escaped += '__'; + } else if (codePoint !== undefined && isValidLegacyRune(char, i)) { + escaped += char; + } else if (codePoint === undefined || !isValidCodePoint(codePoint)) { + escaped += '_FFFD_'; + } else { + escaped += '_'; + escaped += codePoint.toString(16); // Convert code point to hexadecimal + escaped += '_'; + } + + // Handle surrogate pairs for characters outside the Basic Multilingual Plane + if (codePoint !== undefined && codePoint > 0xffff) { + i++; // Skip the second half of the surrogate pair + } + } + + return escaped; +}; + +export const isValidLegacyName = (name: string): boolean => { + if (name.length === 0) { + return false; + } + + for (let i = 0; i < name.length; i++) { + const char = name[i]; + if (!isValidLegacyRune(char, i)) { + return false; + } + } + + return true; +}; + +// const labelNamePriorToUtf8Support = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/; +// instead of regex we use rune check (converted from prometheus code) +// https://github.com/prometheus/common/blob/main/model/metric.go#L426-L428 +const isValidLegacyRune = (char: string, index: number): boolean => { + const codePoint = char.codePointAt(0); + if (codePoint === undefined) { + return false; + } + + return ( + (codePoint >= 97 && codePoint <= 122) || // 'a' to 'z' + (codePoint >= 65 && codePoint <= 90) || // 'A' to 'Z' + codePoint === 95 || // '_' + codePoint === 58 || // ':' + (codePoint >= 48 && codePoint <= 57 && index > 0) // '0' to '9', but not at the start + ); +}; + +const isValidCodePoint = (codePoint: number): boolean => { + // Validate the code point for UTF-8 compliance if needed. + return codePoint >= 0 && codePoint <= 0x10ffff; +};