diff --git a/.betterer.results b/.betterer.results index 8c565ab008f..fc658ccf0c8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4799,7 +4799,9 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], "public/app/features/explore/PrometheusListView/RawListItem.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], "public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], diff --git a/packages/grafana-prometheus/src/add_label_to_query.test.ts b/packages/grafana-prometheus/src/add_label_to_query.test.ts index 0936da9f726..a57f9294938 100644 --- a/packages/grafana-prometheus/src/add_label_to_query.test.ts +++ b/packages/grafana-prometheus/src/add_label_to_query.test.ts @@ -112,4 +112,24 @@ describe('addLabelToQuery()', () => { it('should not add ad-hoc filter bool operator', () => { expect(addLabelToQuery('ALERTS < bool 1', 'bar', 'baz')).toBe('ALERTS{bar="baz"} < bool 1'); }); + + it('should add a utf8 label', () => { + expect(addLabelToQuery('{"metric.name"}', 'cenk.erdem', 'muhabbet')).toBe( + '{"metric.name", "cenk.erdem"="muhabbet"}' + ); + + expect(addLabelToQuery('metric{label="val"}', 'cenk.erdem', 'muhabbet')).toBe( + 'metric{label="val", "cenk.erdem"="muhabbet"}' + ); + }); + + it('should not add a utf8 label when it is already applied', () => { + expect(addLabelToQuery('{"metric.name", "cenk.erdem"="muhabbet"}', 'cenk.erdem', 'muhabbet')).toBe( + '{"metric.name", "cenk.erdem"="muhabbet"}' + ); + + expect(addLabelToQuery('metric{label="val", "cenk.erdem"="muhabbet"}', 'cenk.erdem', 'muhabbet')).toBe( + 'metric{label="val", "cenk.erdem"="muhabbet"}' + ); + }); }); diff --git a/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts b/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts index 9dd51170263..0461458d612 100644 --- a/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts +++ b/packages/grafana-prometheus/src/querybuilder/PromQueryModeller.test.ts @@ -334,3 +334,82 @@ describe('PromQueryModeller', () => { ).toBe('cluster_namespace_slug_dialer_name <= bool 2'); }); }); + +describe('PromQueryModeller with utf8 support', () => { + const modeller = new PromQueryModeller(); + + it('should render nothing if there is nothing', () => { + expect( + modeller.renderQuery({ + metric: undefined, + labels: [], + operations: [], + }) + ).toBe(''); + + expect( + modeller.renderQuery({ + metric: '', + labels: [], + operations: [], + }) + ).toBe(''); + }); + + it('should render legacy metric name as usual', () => { + expect( + modeller.renderQuery({ + metric: 'not_a_utf8_metric', + labels: [], + operations: [], + }) + ).toBe('not_a_utf8_metric'); + }); + + it('can render utf8 metric name in curly braces', () => { + expect( + modeller.renderQuery({ + metric: 'a.utf8.metric', + labels: [], + operations: [], + }) + ).toBe('{"a.utf8.metric"}'); + }); + + it('can render utf8 metric name in curly braces with legacy labels', () => { + expect( + modeller.renderQuery({ + metric: 'a.utf8.metric', + labels: [ + { + label: 'label', + value: 'value', + op: '=', + }, + ], + operations: [], + }) + ).toBe('{"a.utf8.metric", label="value"}'); + }); + + it('can render utf8 metric name in curly braces with legacy and utf8 labels', () => { + expect( + modeller.renderQuery({ + metric: 'a.utf8.metric', + labels: [ + { + label: 'label', + value: 'value', + op: '=', + }, + { + label: 'utf8.label', + value: 'value', + op: '=', + }, + ], + operations: [], + }) + ).toBe('{"a.utf8.metric", label="value", "utf8.label"="value"}'); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/parsing.test.ts b/packages/grafana-prometheus/src/querybuilder/parsing.test.ts index ce2aaede3ac..0ed20246890 100644 --- a/packages/grafana-prometheus/src/querybuilder/parsing.test.ts +++ b/packages/grafana-prometheus/src/querybuilder/parsing.test.ts @@ -3,6 +3,80 @@ import { buildVisualQueryFromString } from './parsing'; import { PromOperationId, PromVisualQuery } from './types'; describe('buildVisualQueryFromString', () => { + describe('utf8 support', () => { + it('supports uts-8 label names', () => { + expect(buildVisualQueryFromString('{"glück:🍀.dot"="luck"} == 11')).toEqual({ + query: { + labels: [ + { + label: 'glück:🍀.dot', + op: '=', + value: 'luck', + }, + ], + metric: '', + operations: [ + { + id: PromOperationId.EqualTo, + params: [11, false], + }, + ], + }, + errors: [], + }); + }); + + it('supports uts-8 metric names', () => { + expect(buildVisualQueryFromString('{"I am a metric"}')).toEqual({ + query: { + labels: [], + metric: 'I am a metric', + operations: [], + }, + errors: [], + }); + }); + + it('supports uts-8 metric names with labels', () => { + expect(buildVisualQueryFromString('{"metric.name", label_field="label value"}')).toEqual({ + query: { + labels: [ + { + label: 'label_field', + op: '=', + value: 'label value', + }, + ], + metric: 'metric.name', + operations: [], + }, + errors: [], + }); + }); + + it('supports uts-8 metric names with utf8 labels', () => { + expect(buildVisualQueryFromString('{"metric.name", "glück:🍀.dot"="luck"} == 11')).toEqual({ + query: { + labels: [ + { + label: 'glück:🍀.dot', + op: '=', + value: 'luck', + }, + ], + metric: 'metric.name', + operations: [ + { + id: PromOperationId.EqualTo, + params: [11, false], + }, + ], + }, + errors: [], + }); + }); + }); + it('creates no errors for empty query', () => { expect(buildVisualQueryFromString('')).toEqual( noErrors({ @@ -246,6 +320,31 @@ describe('buildVisualQueryFromString', () => { ); }); + it('parses query with aggregation by utf8 labels', () => { + const visQuery = { + metric: 'metric_name', + labels: [ + { + label: 'instance', + op: '=', + value: 'internal:3000', + }, + ], + operations: [ + { + id: '__sum_by', + params: ['cluster', '"app.version"'], + }, + ], + }; + expect( + buildVisualQueryFromString('sum(metric_name{instance="internal:3000"}) by ("app.version", cluster)') + ).toEqual(noErrors(visQuery)); + expect( + buildVisualQueryFromString('sum by ("app.version", cluster)(metric_name{instance="internal:3000"})') + ).toEqual(noErrors(visQuery)); + }); + it('parses aggregation with params', () => { expect(buildVisualQueryFromString('topk(5, http_requests_total)')).toEqual( noErrors({ diff --git a/packages/grafana-prometheus/src/querybuilder/parsing.ts b/packages/grafana-prometheus/src/querybuilder/parsing.ts index 3e81d154d0e..a282990da83 100644 --- a/packages/grafana-prometheus/src/querybuilder/parsing.ts +++ b/packages/grafana-prometheus/src/querybuilder/parsing.ts @@ -12,6 +12,7 @@ import { GroupingLabels, Identifier, LabelName, + QuotedLabelName, MatchingModifierClause, MatchOp, NumberDurationLiteral, @@ -19,6 +20,7 @@ import { ParenExpr, parser, StringLiteral, + QuotedLabelMatcher, UnquotedLabelMatcher, VectorSelector, Without, @@ -145,9 +147,33 @@ export function handleExpression(expr: string, node: SyntaxNode, context: Contex break; } + case QuotedLabelName: { + // Usually we got the metric name above in the Identifier case. + // If we didn't get the name that's potentially we have it in curly braces as quoted string. + // It must be quoted because that's how utf8 metric names should be defined + // See proposal https://github.com/prometheus/proposals/blob/main/proposals/2023-08-21-utf8.md + if (visQuery.metric === '') { + const strLiteral = node.getChild(StringLiteral); + const quotedMetric = getString(expr, strLiteral); + visQuery.metric = quotedMetric.slice(1, -1); + } + break; + } + + case QuotedLabelMatcher: { + const quotedLabel = getLabel(expr, node, QuotedLabelName); + quotedLabel.label = quotedLabel.label.slice(1, -1); + visQuery.labels.push(quotedLabel); + const err = node.getChild(ErrorId); + if (err) { + context.errors.push(makeError(expr, err)); + } + break; + } + case UnquotedLabelMatcher: { // Same as MetricIdentifier should be just one per query. - visQuery.labels.push(getLabel(expr, node)); + visQuery.labels.push(getLabel(expr, node, LabelName)); const err = node.getChild(ErrorId); if (err) { context.errors.push(makeError(expr, err)); @@ -202,8 +228,12 @@ function isIntervalVariableError(node: SyntaxNode) { return node.prevSibling?.firstChild?.type.id === VectorSelector; } -function getLabel(expr: string, node: SyntaxNode): QueryBuilderLabelFilter { - const label = getString(expr, node.getChild(LabelName)); +function getLabel( + expr: string, + node: SyntaxNode, + labelType: typeof LabelName | typeof QuotedLabelName +): QueryBuilderLabelFilter { + const label = getString(expr, node.getChild(labelType)); const op = getString(expr, node.getChild(MatchOp)); const value = getString(expr, node.getChild(StringLiteral)).replace(/^["'`]|["'`]$/g, ''); return { @@ -281,7 +311,7 @@ function handleAggregation(expr: string, node: SyntaxNode, context: Context) { funcName = `__${funcName}_without`; } - labels.push(...getAllByType(expr, modifier, LabelName)); + labels.push(...getAllByType(expr, modifier, LabelName), ...getAllByType(expr, modifier, QuotedLabelName)); } const body = node.getChild(FunctionCallBody); diff --git a/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts b/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts index 1d06ed7edac..2300e221dfc 100644 --- a/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts +++ b/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts @@ -3,6 +3,7 @@ import { Registry } from '@grafana/data'; import { config } from '@grafana/runtime'; import { prometheusRegularEscape } from '../../datasource'; +import { isValidLegacyName, utf8Support } from '../../utf8_support'; import { PromVisualQueryOperationCategory } from '../types'; import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef, VisualQueryModeller } from './types'; @@ -97,14 +98,28 @@ export abstract class LokiAndPromQueryModellerBase implements VisualQueryModelle if (config.featureToggles.prometheusSpecialCharsInLabelValues && !usingRegexOperator) { labelValue = prometheusRegularEscape(labelValue); } - expr += `${filter.label}${filter.op}"${labelValue}"`; + expr += `${utf8Support(filter.label)}${filter.op}"${labelValue}"`; } return expr + `}`; } renderQuery(query: PromLokiVisualQuery, nested?: boolean) { - let queryString = `${query.metric ?? ''}${this.renderLabels(query.labels)}`; + let queryString = ''; + const labels = this.renderLabels(query.labels); + if (query.metric) { + if (isValidLegacyName(query.metric)) { + // This is a legacy metric, put outside the curl legacy_query{label="value"} + queryString = `${query.metric}${labels}`; + } else { + // This is a utf8 metric, put inside the curly and quotes {"utf8.metric", label="value"} + queryString = `{"${query.metric}"${labels.length > 0 ? `, ${labels.substring(1)}` : `}`}`; + } + } else { + // No metric just use labels {label="value"} + queryString = labels; + } + queryString = this.renderOperations(queryString, query.operations); if (!nested && this.hasBinaryOp(query) && Boolean(query.binaryQueries?.length)) { diff --git a/public/app/features/explore/PrometheusListView/RawListItem.tsx b/public/app/features/explore/PrometheusListView/RawListItem.tsx index 465cf895565..19945763aba 100644 --- a/public/app/features/explore/PrometheusListView/RawListItem.tsx +++ b/public/app/features/explore/PrometheusListView/RawListItem.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import { useCopyToClipboard } from 'react-use'; import { Field, GrafanaTheme2 } from '@grafana/data/'; +import { isValidLegacyName, utf8Support } from '@grafana/prometheus/src/utf8_support'; import { reportInteraction } from '@grafana/runtime/src'; import { IconButton, useStyles2 } from '@grafana/ui/'; @@ -94,6 +95,8 @@ function getQueryValues(allLabels: Pick { const { __name__, ...allLabels } = listItemData; + // We must know whether it is a utf8 metric name or not + const isLegacyMetric = isValidLegacyName(__name__); const [_, copyToClipboard] = useCopyToClipboard(); const displayLength = valueLabels?.length ?? totalNumberOfValues; const styles = useStyles2(getStyles, displayLength, isExpandedView); @@ -110,10 +113,12 @@ const RawListItem = ({ listItemData, listKey, totalNumberOfValues, valueLabels, }; // Convert the object back into a string - const stringRep = `${__name__}{${attributeValues.map((value) => { - // For histograms the string representation currently in this object is not directly queryable in all situations, leading to broken copied queries. Omitting the attribute from the copied result gives a query which returns all le values, which I assume to be a more common use case. - return `${value.key}="${transformCopyValue(value.value)}"`; - })}}`; + const stringRep = `${isLegacyMetric ? __name__ : ''}{${isLegacyMetric ? '' : `"${__name__}", `}${attributeValues.map( + (value) => { + // For histograms the string representation currently in this object is not directly queryable in all situations, leading to broken copied queries. Omitting the attribute from the copied result gives a query which returns all le values, which I assume to be a more common use case. + return `${utf8Support(value.key)}="${transformCopyValue(value.value)}"`; + } + )}}`; const hideFieldsWithoutValues = Boolean(valueLabels && valueLabels?.length); @@ -135,8 +140,13 @@ const RawListItem = ({ listItemData, listKey, totalNumberOfValues, valueLabels, - {__name__} + {isLegacyMetric && {__name__}} {`{`} + {!isLegacyMetric && __name__ !== '' && ( + + "{__name__}"{', '} + + )} {attributeValues.map((value, index) => (