Prometheus: Support utf8 metric/label/label value suggestions in code mode (#98253)

* utf8 metrics for prometheus devenv

* introduce utf8 support

* completions and suggestions

* don't wrap the utf8 label in quotes

* linting

* support series endpoint
pull/98274/head
ismail simsek 4 months ago committed by GitHub
parent 862c0ce9b5
commit 4c0fa629da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 44
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts
  2. 3
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts
  3. 1
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts
  4. 125
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.test.ts
  5. 190
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts
  6. 149
      packages/grafana-prometheus/src/language_provider.test.ts
  7. 27
      packages/grafana-prometheus/src/language_provider.ts
  8. 52
      packages/grafana-prometheus/src/language_utils.test.ts
  9. 5
      packages/grafana-prometheus/src/language_utils.ts
  10. 35
      packages/grafana-prometheus/src/utf8_support.test.ts
  11. 81
      packages/grafana-prometheus/src/utf8_support.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<Completion[]> {
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(

@ -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),
};
});

@ -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

@ -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,
});
});
});

@ -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<Label[]>((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],
]);
const metricName = getMetricName(labelMatchersNode, text);
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,
};
// we are probably in a situation without a metric name
return {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
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 metricName = getNodeText(metricNameNode, text);
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',
metricName,
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,30 +467,74 @@ function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number)
}
}
const metricNameNode = walk(node, [
['parent', VectorSelector],
['firstChild', Identifier],
]);
// 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);
if (metricNameNode === null) {
// we are probably in a situation without a metric name.
return {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels,
};
}
return {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels,
betweenQuotes: false,
...(metricName ? { metricName } : {}),
};
}
const metricName = getNodeText(metricNameNode, text);
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',
metricName,
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],
]);
if (legacyMetricNameNode) {
return getNodeText(legacyMetricNameNode, 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],
]);
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.
// NOTE: this might be too slow, might need to optimize it
// (ideas: we do not need to go into every subtree, based on from/to)
@ -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);

@ -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,34 @@ 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,
start: fromPrometheusTimeString,
},
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,
@ -587,6 +625,49 @@ describe('Language completion provider', () => {
});
});
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', () => {
it('issues metadata requests when lookup is not disabled', async () => {
const datasource: PrometheusDatasource = {
@ -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');
});
});

@ -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<string[]> => {
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<string[]> => {
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;
}

@ -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'],
},
});
});
});

@ -478,7 +478,10 @@ export function extractLabelMatchers(tokens: Array<string | Token>): 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 {

@ -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);
});
});

@ -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;
};
Loading…
Cancel
Save