diff --git a/.betterer.results b/.betterer.results index e5aaea82878..03fd5401eb2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -451,9 +451,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], "packages/grafana-prometheus/src/language_provider.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/packages/grafana-prometheus/src/datasource.test.ts b/packages/grafana-prometheus/src/datasource.test.ts index 8132d670aae..39bf02561ed 100644 --- a/packages/grafana-prometheus/src/datasource.test.ts +++ b/packages/grafana-prometheus/src/datasource.test.ts @@ -34,7 +34,14 @@ import { fetchMockCalledWith, getMockTimeRange, } from './test/__mocks__/datasource'; -import { PromApplication, PrometheusCacheLevel, PromOptions, PromQuery, PromQueryRequest } from './types'; +import { + PromApplication, + PrometheusCacheLevel, + PromOptions, + PromQuery, + PromQueryRequest, + RawRecordingRules, +} from './types'; const fetchMock = jest.fn().mockReturnValue(of(createDefaultPromResponse())); @@ -395,7 +402,7 @@ describe('PrometheusDatasource', () => { }); it('returns a mapping for recording rules only', () => { - const groups = [ + const groups: RawRecordingRules[] = [ { rules: [ { @@ -415,7 +422,50 @@ describe('PrometheusDatasource', () => { }, ]; const mapping = extractRuleMappingFromGroups(groups); - expect(mapping).toEqual({ 'job:http_inprogress_requests:sum': 'sum(http_inprogress_requests) by (job)' }); + expect(mapping).toEqual({ + 'job:http_inprogress_requests:sum': [{ query: 'sum(http_inprogress_requests) by (job)' }], + }); + }); + + it('should extract rules with same name respecting its labels', () => { + const groups: RawRecordingRules[] = [ + { + name: 'nameOfTheGroup:uid11', + file: 'the_file_123', + rules: [ + { + name: 'metric_5m', + query: 'super_duper_query', + labels: { + uuid: 'uuid111', + }, + type: 'recording', + }, + ], + }, + { + name: 'nameOfTheGroup:uid22', + file: 'the_file_456', + rules: [ + { + name: 'metric_5m', + query: 'another_super_duper_query', + labels: { + uuid: 'uuid222', + }, + type: 'recording', + }, + ], + }, + ]; + + const mapping = extractRuleMappingFromGroups(groups); + expect(mapping['metric_5m']).toBeDefined(); + expect(mapping['metric_5m'].length).toEqual(2); + expect(mapping['metric_5m'][0].query).toEqual('super_duper_query'); + expect(mapping['metric_5m'][0].labels).toEqual({ uuid: 'uuid111' }); + expect(mapping['metric_5m'][1].query).toEqual('another_super_duper_query'); + expect(mapping['metric_5m'][1].labels).toEqual({ uuid: 'uuid222' }); }); }); diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 3296a8da674..7c3dee6a3ea 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -68,6 +68,8 @@ import { PromOptions, PromQuery, PromQueryRequest, + RawRecordingRules, + RuleQueryMapping, } from './types'; import { PrometheusVariableSupport } from './variables'; @@ -81,7 +83,7 @@ export class PrometheusDatasource implements DataSourceWithQueryImportSupport, DataSourceWithQueryExportSupport { type: string; - ruleMappings: { [index: string]: string }; + ruleMappings: RuleQueryMapping; hasIncrementalQuery: boolean; url: string; id: number; @@ -773,7 +775,7 @@ export class PrometheusDatasource } case 'EXPAND_RULES': { if (action.options) { - expression = expandRecordingRules(expression, action.options); + expression = expandRecordingRules(expression, action.options as any); } break; } @@ -969,18 +971,22 @@ export function alignRange( }; } -export function extractRuleMappingFromGroups(groups: any[]) { - return groups.reduce( +export function extractRuleMappingFromGroups(groups: RawRecordingRules[]): RuleQueryMapping { + return groups.reduce( (mapping, group) => group.rules - .filter((rule: any) => rule.type === 'recording') - .reduce( - (acc: { [key: string]: string }, rule: any) => ({ - ...acc, - [rule.name]: rule.query, - }), - mapping - ), + .filter((rule) => rule.type === 'recording') + .reduce((acc, rule) => { + // retrieve existing record + const existingRule = acc[rule.name] ?? []; + // push a new query with labels + existingRule.push({ + query: rule.query, + labels: rule.labels, + }); + acc[rule.name] = existingRule; + return acc; + }, mapping), {} ); } diff --git a/packages/grafana-prometheus/src/language_utils.test.ts b/packages/grafana-prometheus/src/language_utils.test.ts index 949e6489af1..a0ac8fcb808 100644 --- a/packages/grafana-prometheus/src/language_utils.test.ts +++ b/packages/grafana-prometheus/src/language_utils.test.ts @@ -153,52 +153,65 @@ describe('expandRecordingRules()', () => { }); it('does not modify recording rules name in label values', () => { - expect(expandRecordingRules('{__name__="metric"} + bar', { metric: 'foo', bar: 'super' })).toBe( - '{__name__="metric"} + super' - ); + expect( + expandRecordingRules('{__name__="metric"} + bar', { + metric: { expandedQuery: 'foo' }, + bar: { expandedQuery: 'super' }, + }) + ).toBe('{__name__="metric"} + super'); }); it('returns query with expanded recording rules', () => { - expect(expandRecordingRules('metric', { metric: 'foo' })).toBe('foo'); - expect(expandRecordingRules('metric + metric', { metric: 'foo' })).toBe('foo + foo'); - expect(expandRecordingRules('metric{}', { metric: 'foo' })).toBe('foo{}'); - expect(expandRecordingRules('metric[]', { metric: 'foo' })).toBe('foo[]'); - expect(expandRecordingRules('metric + foo', { metric: 'foo', foo: 'bar' })).toBe('foo + bar'); + expect(expandRecordingRules('metric', { metric: { expandedQuery: 'foo' } })).toBe('foo'); + expect(expandRecordingRules('metric + metric', { metric: { expandedQuery: 'foo' } })).toBe('foo + foo'); + expect(expandRecordingRules('metric{}', { metric: { expandedQuery: 'foo' } })).toBe('foo{}'); + expect(expandRecordingRules('metric[]', { metric: { expandedQuery: 'foo' } })).toBe('foo[]'); + expect( + expandRecordingRules('metric + foo', { + metric: { expandedQuery: 'foo' }, + foo: { expandedQuery: 'bar' }, + }) + ).toBe('foo + bar'); }); it('returns query with labels with expanded recording rules', () => { expect( - expandRecordingRules('metricA{label1="value1"} / metricB{label2="value2"}', { metricA: 'fooA', metricB: 'fooB' }) + expandRecordingRules('metricA{label1="value1"} / metricB{label2="value2"}', { + metricA: { expandedQuery: 'fooA' }, + metricB: { expandedQuery: 'fooB' }, + }) ).toBe('fooA{label1="value1"} / fooB{label2="value2"}'); expect( expandRecordingRules('metricA{label1="value1",label2="value,2"}', { - metricA: 'rate(fooA[])', + metricA: { expandedQuery: 'rate(fooA[])' }, }) ).toBe('rate(fooA{label1="value1", label2="value,2"}[])'); expect( expandRecordingRules('metricA{label1="value1"} / metricB{label2="value2"}', { - metricA: 'rate(fooA[])', - metricB: 'rate(fooB[])', + metricA: { expandedQuery: 'rate(fooA[])' }, + metricB: { expandedQuery: 'rate(fooB[])' }, }) ).toBe('rate(fooA{label1="value1"}[]) / rate(fooB{label2="value2"}[])'); expect( expandRecordingRules('metricA{label1="value1",label2="value2"} / metricB{label3="value3"}', { - metricA: 'rate(fooA[])', - metricB: 'rate(fooB[])', + metricA: { expandedQuery: 'rate(fooA[])' }, + metricB: { expandedQuery: 'rate(fooB[])' }, }) ).toBe('rate(fooA{label1="value1", label2="value2"}[]) / rate(fooB{label3="value3"}[])'); }); it('expands the query even it is wrapped with parentheses', () => { expect( - expandRecordingRules('sum (metric{label1="value1"}) by (env)', { metric: 'foo{labelInside="valueInside"}' }) + expandRecordingRules('sum (metric{label1="value1"}) by (env)', { + metric: { expandedQuery: 'foo{labelInside="valueInside"}' }, + }) ).toBe('sum (foo{labelInside="valueInside", label1="value1"}) by (env)'); }); it('expands the query with regex match', () => { expect( expandRecordingRules('sum (metric{label1=~"/value1/(sa|sb)"}) by (env)', { - metric: 'foo{labelInside="valueInside"}', + metric: { expandedQuery: 'foo{labelInside="valueInside"}' }, }) ).toBe('sum (foo{labelInside="valueInside", label1=~"/value1/(sa|sb)"}) by (env)'); }); @@ -206,14 +219,49 @@ describe('expandRecordingRules()', () => { it('ins:metric:per{pid="val-42", comp="api"}', () => { const query = `aaa:111{pid="val-42", comp="api"} + bbb:222{pid="val-42"}`; const mapping = { - 'aaa:111': - '(max without (mp) (targetMetric{device=~"/dev/(sda1|sdb)"}) / max without (mp) (targetMetric2{device=~"/dev/(sda1|sdb)"}))', - 'bbb:222': '(targetMetric2{device=~"/dev/(sda1|sdb)"})', + 'aaa:111': { + expandedQuery: + '(max without (mp) (targetMetric{device=~"/dev/(sda1|sdb)"}) / max without (mp) (targetMetric2{device=~"/dev/(sda1|sdb)"}))', + }, + 'bbb:222': { expandedQuery: '(targetMetric2{device=~"/dev/(sda1|sdb)"})' }, }; const expected = `(max without (mp) (targetMetric{device=~"/dev/(sda1|sdb)", pid="val-42", comp="api"}) / max without (mp) (targetMetric2{device=~"/dev/(sda1|sdb)", pid="val-42", comp="api"})) + (targetMetric2{device=~"/dev/(sda1|sdb)", pid="val-42"})`; const result = expandRecordingRules(query, mapping); expect(result).toBe(expected); }); + + it('when there is an identifier, identifier must be removed from expanded query', () => { + const query = `ins:metric:per{uuid="111", comp="api"}`; + const mapping = { + 'ins:metric:per': { + expandedQuery: 'targetMetric{device="some_device"}', + identifier: 'uuid', + identifierValue: '111', + }, + }; + const expected = `targetMetric{device="some_device", comp="api"}`; + const result = expandRecordingRules(query, mapping); + expect(result).toBe(expected); + }); + + it('when there is an identifier, identifier must be removed from complex expanded query', () => { + const query = `instance_path:requests:rate5m{uuid="111", four="tops"} + instance_path:requests:rate15m{second="album", uuid="222"}`; + const mapping = { + 'instance_path:requests:rate5m': { + expandedQuery: `rate(prometheus_http_requests_total{job="prometheus"}`, + identifier: 'uuid', + identifierValue: '111', + }, + 'instance_path:requests:rate15m': { + expandedQuery: `prom_http_requests_sum{job="prometheus"}`, + identifier: 'uuid', + identifierValue: '222', + }, + }; + const expected = `rate(prometheus_http_requests_total{job="prometheus", four="tops"} + prom_http_requests_sum{job="prometheus", second="album"}`; + const result = expandRecordingRules(query, mapping); + expect(result).toBe(expected); + }); }); describe('escapeLabelValueInExactSelector()', () => { diff --git a/packages/grafana-prometheus/src/language_utils.ts b/packages/grafana-prometheus/src/language_utils.ts index ed96e929702..922f79d80c3 100644 --- a/packages/grafana-prometheus/src/language_utils.ts +++ b/packages/grafana-prometheus/src/language_utils.ts @@ -16,7 +16,7 @@ import { import { addLabelToQuery } from './add_label_to_query'; import { SUGGESTIONS_LIMIT } from './language_provider'; import { PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './querybuilder/components/MetricSelect'; -import { PrometheusCacheLevel, PromMetricsMetadata, PromMetricsMetadataItem } from './types'; +import { PrometheusCacheLevel, PromMetricsMetadata, PromMetricsMetadataItem, RecordingRuleIdentifier } from './types'; export const processHistogramMetrics = (metrics: string[]) => { const resultSet: Set = new Set(); @@ -139,7 +139,7 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: str return { labelKeys, selector: selectorString }; } -export function expandRecordingRules(query: string, mapping: { [name: string]: string }): string { +export function expandRecordingRules(query: string, mapping: { [name: string]: RecordingRuleIdentifier }): string { const getRuleRegex = (ruleName: string) => new RegExp(`(\\s|\\(|^)(${ruleName})(\\s|$|\\(|\\[|\\{)`, 'ig'); // For each mapping key we iterate over the query and split them in parts. @@ -202,12 +202,13 @@ export function expandRecordingRules(query: string, mapping: { [name: string]: s // check if the mapping is there if (mapping[tsp]) { - const recordingRule = mapping[tsp]; + const { expandedQuery: recordingRule, identifierValue, identifier } = mapping[tsp]; // it is a recording rule. if the following is a label then apply it if (i + 1 !== tmpSplitParts.length && tmpSplitParts[i + 1].match(labelRegexp)) { // the next value in the loop is label. Let's apply labels to the metric labelFound = true; - const labels = tmpSplitParts[i + 1]; + const regexp = new RegExp(`(,)?(\\s)?(${identifier}=\\"${identifierValue}\\")(,)?(\\s)?`, 'g'); + const labels = tmpSplitParts[i + 1].replace(regexp, ''); const invalidLabelsRegex = /(\)\{|\}\{|\]\{)/; return addLabelsToExpression(recordingRule + labels, invalidLabelsRegex); } else { diff --git a/packages/grafana-prometheus/src/query_hints.test.ts b/packages/grafana-prometheus/src/query_hints.test.ts index b292271ffa3..34ceb13cf84 100644 --- a/packages/grafana-prometheus/src/query_hints.test.ts +++ b/packages/grafana-prometheus/src/query_hints.test.ts @@ -1,6 +1,17 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/query_hints.test.ts +import { QueryHint } from '@grafana/data'; +import { QueryBuilderLabelFilter } from '@grafana/experimental'; + import { PrometheusDatasource } from './datasource'; -import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from './query_hints'; +import { + getExpandRulesHints, + getQueryHints, + getQueryLabelsForRuleName, + getRecordingRuleIdentifierIdx, + SUM_HINT_THRESHOLD_COUNT, +} from './query_hints'; +import { buildVisualQueryFromString } from './querybuilder/parsing'; +import { RuleQueryMapping } from './types'; describe('getQueryHints()', () => { it('returns no hints for no series', () => { @@ -231,3 +242,268 @@ describe('getQueryHints()', () => { expect(hints!.length).toBe(0); }); }); + +describe('getExpandRulesHints', () => { + it('should return no hint when no rule is present in query', () => { + const extractedMapping: RuleQueryMapping = {}; + const hints = getExpandRulesHints('metric_5m', extractedMapping); + const expected: QueryHint[] = []; + expect(hints).toEqual(expected); + }); + + it('should return expand rule hint, single rules', () => { + const extractedMapping: RuleQueryMapping = { + metric_5m: [ + { + query: 'expanded_metric_query[5m]', + labels: {}, + }, + ], + metric_15m: [ + { + query: 'expanded_metric_query[15m]', + labels: {}, + }, + ], + }; + const query = `metric_5m`; + const hints = getExpandRulesHints('metric_5m', extractedMapping); + const expected = expect.arrayContaining([expect.objectContaining({ type: 'EXPAND_RULES' })]); + expect(hints).toEqual(expected); + expect(hints).toEqual([ + { + type: 'EXPAND_RULES', + label: 'Query contains recording rules.', + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: { + metric_5m: { + expandedQuery: 'expanded_metric_query[5m]', + }, + }, + }, + }, + }, + ]); + }); + + it('should return no expand rule hint, if the given query does not have a label', () => { + const extractedMapping: RuleQueryMapping = { + metric_5m: [ + { + query: 'expanded_metric_query_111[5m]', + labels: { + uuid: '111', + }, + }, + { + query: 'expanded_metric_query_222[5m]', + labels: { + uuid: '222', + }, + }, + ], + metric_15m: [ + { + query: 'expanded_metric_query[15m]', + labels: {}, + }, + ], + }; + const hints = getExpandRulesHints( + `sum(metric_5m{uuid="5m"} + metric_10m{uuid="10m"}) + metric_66m{uuid="66m"}`, + extractedMapping + ); + const expected = expect.arrayContaining([expect.objectContaining({ type: 'EXPAND_RULES_WARNING' })]); + expect(hints).toEqual(expected); + }); + + it('should return expand rule warning hint, if the given query *does* have a label', () => { + const extractedMapping: RuleQueryMapping = { + metric_5m: [ + { + query: 'expanded_metric_query_111[5m]', + labels: { + uuid: '111', + }, + }, + { + query: 'expanded_metric_query_222[5m]', + labels: { + uuid: '222', + }, + }, + ], + metric_15m: [ + { + query: 'expanded_metric_query[15m]', + labels: {}, + }, + ], + }; + const query = `metric_5m{uuid="111"}`; + const hints = getExpandRulesHints('metric_5m{uuid="111"}', extractedMapping); + expect(hints).toEqual([ + { + type: 'EXPAND_RULES', + label: 'Query contains recording rules.', + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: { + metric_5m: { + expandedQuery: 'expanded_metric_query_111[5m]', + identifier: 'uuid', + identifierValue: '111', + }, + }, + }, + }, + }, + ]); + }); +}); + +describe('getRecordingRuleIdentifierIdx', () => { + it('should return the matching identifier', () => { + const mapping: RuleQueryMapping[string] = [ + { + query: 'expanded_metric_query_111[5m]', + labels: { + uuid: '111', + }, + }, + { + query: 'expanded_metric_query_222[5m]', + labels: { + uuid: '222', + }, + }, + ]; + const ruleName = `metric_5m`; + const query = `metric_5m{uuid="111"}`; + const { idx, identifier, identifierValue, expandedQuery } = getRecordingRuleIdentifierIdx(query, ruleName, mapping); + expect(idx).toEqual(0); + expect(identifier).toEqual(`uuid`); + expect(identifierValue).toEqual('111'); + expect(expandedQuery).toEqual(`expanded_metric_query_111[5m]`); + }); + + it('should not return the matching identifier', () => { + const mapping: RuleQueryMapping[string] = [ + { + query: 'expanded_metric_query_111[5m]', + labels: { + uuid: '111', + }, + }, + { + query: 'expanded_metric_query_222[5m]', + labels: { + uuid: '222', + }, + }, + ]; + const ruleName = `metric_5m`; + const query = `metric_5m{uuid="999"}`; + const { idx } = getRecordingRuleIdentifierIdx(query, ruleName, mapping); + expect(idx).toEqual(-1); + }); + + it('should return the matching identifier index for a complex query', () => { + const mapping: RuleQueryMapping[string] = [ + { + query: 'expanded_metric_query_111[5m]', + labels: { + uuid: '111', + }, + }, + { + query: 'expanded_metric_query_222[5m]', + labels: { + uuid: '222', + }, + }, + ]; + const ruleName = `metric_55m`; + const query = `metric_5m{uuid="111"} + metric_55m{uuid="222"}`; + const { idx, identifier, identifierValue, expandedQuery } = getRecordingRuleIdentifierIdx(query, ruleName, mapping); + expect(idx).toEqual(1); + expect(identifier).toEqual(`uuid`); + expect(identifierValue).toEqual('222'); + expect(expandedQuery).toEqual(`expanded_metric_query_222[5m]`); + }); + + it('should return the matching identifier index for a complex query with binary operators', () => { + const mapping: RuleQueryMapping[string] = [ + { + query: 'expanded_metric_query_111[5m]', + labels: { + uuid: '111', + }, + }, + { + query: 'expanded_metric_query_222[5m]', + labels: { + uuid: '222', + }, + }, + { + query: 'expanded_metric_query_333[5m]', + labels: { + uuid: '333', + }, + }, + ]; + const ruleName = `metric_5m`; + const query = `metric_7n{} + (metric_5m{uuid="333"} + metric_55m{uuid="222"})`; + const { idx, identifier, identifierValue, expandedQuery } = getRecordingRuleIdentifierIdx(query, ruleName, mapping); + expect(idx).toEqual(2); + expect(identifier).toEqual(`uuid`); + expect(identifierValue).toEqual('333'); + expect(expandedQuery).toEqual(`expanded_metric_query_333[5m]`); + }); +}); + +describe('getQueryLabelsForRuleName', () => { + it('should return labels for the metric name', () => { + const metricName = `metric_5m`; + const query = `metric_5m{uuid="111"}`; + const { query: visualQuery } = buildVisualQueryFromString(query); + const result = getQueryLabelsForRuleName(metricName, visualQuery); + const expected: QueryBuilderLabelFilter[] = [{ label: 'uuid', op: '=', value: '111' }]; + expect(result).toEqual(expected); + }); + + it('should return labels from a query with binary operations', () => { + const metricName = `metric_5m`; + const query = `metric_55m{uuid="222"} + metric_33m{uuid="333"} + metric_5m{uuid="111"}`; + const { query: visualQuery } = buildVisualQueryFromString(query); + const result = getQueryLabelsForRuleName(metricName, visualQuery); + const expected: QueryBuilderLabelFilter[] = [{ label: 'uuid', op: '=', value: '111' }]; + expect(result).toEqual(expected); + }); + + it('should return labels from a query with binary operations with parentheses', () => { + const metricName = `metric_5m`; + const query = `(metric_55m{uuid="222"} + metric_33m{uuid="333"}) + metric_5m{uuid="111"}`; + const { query: visualQuery } = buildVisualQueryFromString(query); + const result = getQueryLabelsForRuleName(metricName, visualQuery); + const expected: QueryBuilderLabelFilter[] = [{ label: 'uuid', op: '=', value: '111' }]; + expect(result).toEqual(expected); + }); + + it('should return labels from a query for the first metricName match', () => { + const metricName = `metric_5m`; + const query = `(metric_55m{uuid="222"} + metric_33m{uuid="333"}) + metric_5m{uuid="999"} + metric_5m{uuid="555"}`; + const { query: visualQuery } = buildVisualQueryFromString(query); + const result = getQueryLabelsForRuleName(metricName, visualQuery); + const expected: QueryBuilderLabelFilter[] = [{ label: 'uuid', op: '=', value: '999' }]; + expect(result).toEqual(expected); + }); +}); diff --git a/packages/grafana-prometheus/src/query_hints.ts b/packages/grafana-prometheus/src/query_hints.ts index f75a52b2f8a..15fb2fdede2 100644 --- a/packages/grafana-prometheus/src/query_hints.ts +++ b/packages/grafana-prometheus/src/query_hints.ts @@ -4,7 +4,10 @@ import { size } from 'lodash'; import { QueryFix, QueryHint } from '@grafana/data'; import { PrometheusDatasource } from './datasource'; -import { PromMetricsMetadata } from './types'; +import { buildVisualQueryFromString } from './querybuilder/parsing'; +import { QueryBuilderLabelFilter } from './querybuilder/shared/types'; +import { PromVisualQuery } from './querybuilder/types'; +import { PromMetricsMetadata, RecordingRuleIdentifier, RuleQueryMapping } from './types'; /** * Number of time series results needed before starting to suggest sum aggregation hints @@ -168,31 +171,8 @@ export function getQueryHints(query: string, series?: unknown[], datasource?: Pr // Check for recording rules expansion if (datasource && datasource.ruleMappings) { - const mapping = datasource.ruleMappings; - const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { - if (query.search(ruleName) > -1) { - return { - ...acc, - [ruleName]: mapping[ruleName], - }; - } - return acc; - }, {}); - if (size(mappingForQuery) > 0) { - const label = 'Query contains recording rules.'; - hints.push({ - type: 'EXPAND_RULES', - label, - fix: { - label: 'Expand rules', - action: { - type: 'EXPAND_RULES', - query, - options: mappingForQuery, - }, - }, - }); - } + const expandQueryHints = getExpandRulesHints(query, datasource.ruleMappings); + hints.push(...expandQueryHints); } if (series && series.length >= SUM_HINT_THRESHOLD_COUNT) { @@ -230,6 +210,126 @@ export function getInitHints(datasource: PrometheusDatasource): QueryHint[] { return hints; } +export function getExpandRulesHints(query: string, mapping: RuleQueryMapping): QueryHint[] { + const hints: QueryHint[] = []; + const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { + if (query.search(ruleName) === -1) { + return acc; + } + + if (mapping[ruleName].length > 1) { + const { idx, expandedQuery, identifier, identifierValue } = getRecordingRuleIdentifierIdx( + query, + ruleName, + mapping[ruleName] + ); + + // No identifier detected add warning + if (idx === -1) { + hints.push({ + type: 'EXPAND_RULES_WARNING', + label: + 'We found multiple recording rules that match in this query. To expand the recording rule, add an identifier label/value.', + }); + return acc; + } else { + // Identifier found. + return { + ...acc, + [ruleName]: { + expandedQuery, + identifier, + identifierValue, + }, + }; + } + } else { + return { + ...acc, + [ruleName]: { + expandedQuery: mapping[ruleName][0].query, + }, + }; + } + }, {}); + + if (size(mappingForQuery) > 0) { + const label = 'Query contains recording rules.'; + hints.push({ + type: 'EXPAND_RULES', + label, + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: mappingForQuery, + }, + }, + }); + } + + return hints; +} + +export function getRecordingRuleIdentifierIdx( + queryStr: string, + ruleName: string, + mapping: RuleQueryMapping[string] +): RecordingRuleIdentifier & { idx: number } { + const { query } = buildVisualQueryFromString(queryStr); + const queryMetricLabels: QueryBuilderLabelFilter[] = getQueryLabelsForRuleName(ruleName, query); + if (queryMetricLabels.length === 0) { + return { idx: -1, identifier: '', identifierValue: '', expandedQuery: '' }; + } + + let uuidLabel = ''; + let uuidLabelValue = ''; + let uuidLabelIdx = -1; + + queryMetricLabels.forEach((qml) => { + if (uuidLabelIdx === -1 && qml.label.search('uuid') !== -1) { + uuidLabel = qml.label; + uuidLabelValue = qml.value; + } + }); + + mapping.forEach((mp, idx) => { + if (mp.labels) { + Object.entries(mp.labels).forEach(([key, value]) => { + if (uuidLabelIdx === -1 && key === uuidLabel && value === uuidLabelValue) { + uuidLabelIdx = idx; + } + }); + } + }); + + return { + idx: uuidLabelIdx, + identifier: uuidLabel, + identifierValue: uuidLabelValue, + expandedQuery: mapping[uuidLabelIdx]?.query ?? '', + }; +} + +// returns the labels of matching metric +// metricName is the ruleName in query +export function getQueryLabelsForRuleName(metricName: string, query: PromVisualQuery): QueryBuilderLabelFilter[] { + if (query.metric === metricName) { + return query.labels; + } else { + if (query.binaryQueries) { + for (let i = 0; i < query.binaryQueries.length; i++) { + const labels = getQueryLabelsForRuleName(metricName, query.binaryQueries[i].query); + if (labels && labels.length > 0) { + return labels; + } + } + } + return []; + } +} + function getQueryTokens(query: string) { return ( Array.from(query.matchAll(/\$?[a-zA-Z_:][a-zA-Z0-9_:]*/g)) diff --git a/packages/grafana-prometheus/src/types.ts b/packages/grafana-prometheus/src/types.ts index beb9fb07a63..a8b20b64e87 100644 --- a/packages/grafana-prometheus/src/types.ts +++ b/packages/grafana-prometheus/src/types.ts @@ -141,3 +141,48 @@ export type StandardPromVariableQuery = { query: string; refId: string; }; + +// Rules that we fetch from Prometheus +export type RawRecordingRules = { + name: string; + file: string; + rules: Rule[]; + interval?: number; + limit?: number; +}; + +// A single recording rule with its labels and the query it represents +// In this object, there may be other fields but those are the ones we care for now +export type Rule = { + name: string; + query: string; + duration?: number; + labels?: Record; + annotations?: Record; + alerts?: AlertInfo[]; + type: 'alerting' | 'recording'; +}; + +export type AlertInfo = { + labels: Record; + annotations: Record; + state: string; + activeAt: string; + value: string; +}; + +// Extracted recording rules with labels +// We parse and extract the rules because +// there might be multiple rules with same name but different labels and queries +export type RuleQueryMapping = { + [key: string]: Array<{ + query: string; + labels?: Record; + }>; +}; + +export type RecordingRuleIdentifier = { + expandedQuery: string; + identifier?: string; + identifierValue?: string; +};