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
This commit is contained in:
+37
-7
@@ -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
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+125
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+154
-46
@@ -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],
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user