diff --git a/devenv/docker/blocks/prometheus/recording.yml b/devenv/docker/blocks/prometheus/recording.yml index f45cdbeddf2..2b8ae3e477a 100644 --- a/devenv/docker/blocks/prometheus/recording.yml +++ b/devenv/docker/blocks/prometheus/recording.yml @@ -1,4 +1,18 @@ groups: + - name: TESTING_RULES + rules: + - record: test:rate5m + expr: rate(ismail_prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + labels: + identifier: special_idx + - record: agent:test:rate5m + expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + labels: + identifier: special_idx + - record: agent:test:rate5m_agg_ + expr: rate(prometheus_tsdb_reloads_total_agg_{job="aggregated"}[5m]) + labels: + identifier: special_idx - name: RECORDING_RULES rules: - record: instance_path:requests:rate5m @@ -9,6 +23,14 @@ groups: expr: rate(prometheus_tsdb_reloads_failures_total{job="prometheus"}[5m]) - record: instance_path:reloads:rate5m expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + - record: test:rate5m + expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + - record: test:rate5m_agg_ + expr: rate(prometheus_tsdb_reloads_total_agg_{job="aggregated"}[5m]) + - record: agent:test:rate5m + expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + - record: agent:test:rate5m_agg_ + expr: rate(prometheus_tsdb_reloads_total_agg_{job="aggregated"}[5m]) - record: instance_path:request_failures_per_requests:ratio_rate5m expr: |2 instance_path:reloads_failures:rate5m{job="prometheus"} diff --git a/packages/grafana-prometheus/src/query_hints.test.ts b/packages/grafana-prometheus/src/query_hints.test.ts index 5aabdbe7bd4..af2e9eee01a 100644 --- a/packages/grafana-prometheus/src/query_hints.test.ts +++ b/packages/grafana-prometheus/src/query_hints.test.ts @@ -8,6 +8,7 @@ import { getQueryHints, getQueryLabelsForRuleName, getRecordingRuleIdentifierIdx, + isRuleInQuery, SUM_HINT_THRESHOLD_COUNT, } from './query_hints'; import { buildVisualQueryFromString } from './querybuilder/parsing'; @@ -365,6 +366,92 @@ describe('getExpandRulesHints', () => { }, ]); }); + + it('should return expand rule hint, when given query include a non-unique rule name', () => { + const extractedMapping: RuleQueryMapping = { + 'duration:p95': [ + { + query: 'expanded_duration_p95{}', + labels: {}, + }, + { + query: 'expanded_duration_p95_aggregated{}', + labels: { + span_name: '__aggregated__', + }, + }, + ], + 'duration:p95:upper_threshold': [ + { + query: 'expanded_duration_p95_upper_threshold{}', + labels: {}, + }, + ], + }; + const query = 'sum(rate(duration:p95:upper_threshold{label="foo"}[5m])) by(bar)'; + const hints = getExpandRulesHints(query, extractedMapping); + expect(hints).toEqual([ + { + type: 'EXPAND_RULES', + label: 'Query contains recording rules.', + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: { + 'duration:p95:upper_threshold': { + expandedQuery: 'expanded_duration_p95_upper_threshold{}', + }, + }, + }, + }, + }, + ]); + }); + + it('should return expand rule hint, when given query include a non-unique rule name - second case', () => { + const extractedMapping: RuleQueryMapping = { + 'duration:p95': [ + { + query: 'expanded_duration_p95{}', + labels: {}, + }, + { + query: 'expanded_duration_p95_aggregated{}', + labels: { + span_name: '__aggregated__', + }, + }, + ], + 'upper_threshold:duration:p95': [ + { + query: 'expanded_duration_p95_upper_threshold{}', + labels: {}, + }, + ], + }; + const query = 'sum(rate(upper_threshold:duration:p95{label="foo"}[5m])) by(bar)'; + const hints = getExpandRulesHints(query, extractedMapping); + expect(hints).toEqual([ + { + type: 'EXPAND_RULES', + label: 'Query contains recording rules.', + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: { + 'upper_threshold:duration:p95': { + expandedQuery: 'expanded_duration_p95_upper_threshold{}', + }, + }, + }, + }, + }, + ]); + }); }); describe('getRecordingRuleIdentifierIdx', () => { @@ -505,3 +592,73 @@ describe('getQueryLabelsForRuleName', () => { expect(result).toEqual(expected); }); }); + +describe('ruleInQuery', () => { + it('should return true when ruleName is present in the query', () => { + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m])', 'http_requests_total')).toBe(true); + }); + + it('should return false when ruleName is not present in the query', () => { + expect(isRuleInQuery('rate(cpu_usage{instance="localhost"}[5m])', 'http_requests_total')).toBe(false); + }); + + it('should return true for ruleName at the start of the query', () => { + expect(isRuleInQuery('http_requests_total{job="api"}', 'http_requests_total')).toBe(true); + }); + + it('should return true for ruleName at the end of the query', () => { + expect(isRuleInQuery('sum by (instance) (http_requests_total)', 'http_requests_total')).toBe(true); + expect(isRuleInQuery('sum(http_requests_total)', 'http_requests_total')).toBe(true); + expect(isRuleInQuery('http_requests_total', 'http_requests_total')).toBe(true); + }); + + it('should return true when ruleName is followed by spaces', () => { + expect(isRuleInQuery('http_requests_total { job="api" }', 'http_requests_total')).toBe(true); + }); + + it('should return false when ruleName is a substring of another metric', () => { + expect(isRuleInQuery('rate(http_requests_total_new{job="api"}[5m])', 'http_requests_total')).toBe(false); + }); + + it('should return false for escaped ruleName usage', () => { + expect(isRuleInQuery('rate(\"http_requests_total\"{job="api"}[5m])', 'http_requests_total')).toBe(false); + }); + + it('should return false when query is empty', () => { + expect(isRuleInQuery('', 'http_requests_total')).toBe(false); + }); + + it('should return false when ruleName is an empty string', () => { + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m])', '')).toBe(false); + }); + + it('should return false when both query and ruleName are empty', () => { + expect(isRuleInQuery('', '')).toBe(false); + }); + + it('should return true when used with binary operations', () => { + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m]) + my:rule', 'my:rule')).toBe(true); + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m])+my:rule', 'my:rule')).toBe(true); + }); + + it('should return true when ruleName is inside nested functions', () => { + expect(isRuleInQuery('sum(rate(http_requests_total[5m]))', 'http_requests_total')).toBe(true); + }); + + it('should return false when ruleName is part of a label value', () => { + expect(isRuleInQuery('http_requests_total_bytes{rule="http_requests_total"}', 'http_requests_total')).toBe(false); + }); + + it('should return true when ruleName contains special characters like colon', () => { + expect(isRuleInQuery('rate(my_namespace:http_requests_total[5m])', 'my_namespace:http_requests_total')).toBe(true); + }); + + it('should return false when ruleName appears within string literals', () => { + expect( + isRuleInQuery( + 'label_replace(http_requests_total, "label", "value", "instance", "http_requests_total")', + 'http_requests_total' + ) + ).toBe(false); + }); +}); diff --git a/packages/grafana-prometheus/src/query_hints.ts b/packages/grafana-prometheus/src/query_hints.ts index 3fcdd66e0d2..73c3e90960e 100644 --- a/packages/grafana-prometheus/src/query_hints.ts +++ b/packages/grafana-prometheus/src/query_hints.ts @@ -175,10 +175,19 @@ export function getInitHints(datasource: PrometheusDatasource): QueryHint[] { return hints; } +export function isRuleInQuery(query: string, ruleName: string) { + if (!query || !ruleName) { + return false; + } + + const getRuleRegex = new RegExp(`(? { - if (query.search(ruleName) === -1) { + if (!isRuleInQuery(query, ruleName)) { return acc; } diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx index 9ad1b409456..f6ea9df0e1c 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx @@ -7,7 +7,6 @@ import { useStyles2 } from '@grafana/ui'; import { PromQueryField } from '../../components/PromQueryField'; import { PromQueryEditorProps } from '../../components/types'; -import { QueryEditorHints } from '../shared/QueryEditorHints'; import { PromQueryBuilderExplained } from './PromQueryBuilderExplained'; @@ -35,7 +34,6 @@ export function PromQueryCodeEditor(props: PromQueryCodeEditorProps) { app={app} /> {showExplain && } - ); }