Browse Source

Explore: expand recording rules for queries

- load recording rules from prometheus
- map rule name to rule query
- query hint to detect recording rules in query
- click on hint fix expands rule name to query
David Kaltschmidt 7 năm trước cách đây
mục cha
commit
128a5d98e1

+ 4 - 0
public/app/containers/Explore/Explore.tsx

@@ -169,6 +169,10 @@ export class Explore extends React.Component<any, IExploreState> {
     const historyKey = `grafana.explore.history.${datasourceId}`;
     const history = store.getObject(historyKey, []);
 
+    if (datasource.init) {
+      datasource.init();
+    }
+
     this.setState(
       {
         datasource,

+ 41 - 1
public/app/containers/Explore/PromQueryField.jest.tsx

@@ -3,7 +3,7 @@ import Enzyme, { shallow } from 'enzyme';
 import Adapter from 'enzyme-adapter-react-16';
 import Plain from 'slate-plain-serializer';
 
-import PromQueryField from './PromQueryField';
+import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
 
 Enzyme.configure({ adapter: new Adapter() });
 
@@ -177,3 +177,43 @@ describe('PromQueryField typeahead handling', () => {
     });
   });
 });
+
+describe('groupMetricsByPrefix()', () => {
+  it('returns an empty group for no metrics', () => {
+    expect(groupMetricsByPrefix([])).toEqual([]);
+  });
+
+  it('returns options grouped by prefix', () => {
+    expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
+      {
+        value: 'foo',
+        children: [
+          {
+            value: 'foo_metric',
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('returns options without prefix as toplevel option', () => {
+    expect(groupMetricsByPrefix(['metric'])).toMatchObject([
+      {
+        value: 'metric',
+      },
+    ]);
+  });
+
+  it('returns recording rules grouped separately', () => {
+    expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
+      {
+        value: RECORDING_RULES_GROUP,
+        children: [
+          {
+            value: ':foo_metric:',
+          },
+        ],
+      },
+    ]);
+  });
+});

+ 19 - 1
public/app/containers/Explore/PromQueryField.tsx

@@ -28,6 +28,7 @@ const HISTORY_ITEM_COUNT = 5;
 const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
 const METRIC_MARK = 'metric';
 const PRISM_LANGUAGE = 'promql';
+export const RECORDING_RULES_GROUP = '__recording_rules__';
 
 export const wrapLabel = (label: string) => ({ label });
 export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
@@ -52,7 +53,22 @@ export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion
 }
 
 export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
-  return _.chain(metrics)
+  // Filter out recording rules and insert as first option
+  const ruleRegex = /:\w+:/;
+  const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
+  const rulesOption = {
+    label: 'Recording rules',
+    value: RECORDING_RULES_GROUP,
+    children: ruleNames
+      .slice()
+      .sort()
+      .map(name => ({ label: name, value: name })),
+  };
+
+  const options = ruleNames.length > 0 ? [rulesOption] : [];
+
+  const metricsOptions = _.chain(metrics)
+    .filter(metric => !ruleRegex.test(metric))
     .groupBy(metric => metric.split(delimiter)[0])
     .map((metricsForPrefix: string[], prefix: string): CascaderOption => {
       const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
@@ -65,6 +81,8 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
     })
     .sortBy('label')
     .value();
+
+  return [...options, ...metricsOptions];
 }
 
 export function willApplySuggestion(

+ 75 - 2
public/app/plugins/datasource/prometheus/datasource.ts

@@ -82,7 +82,7 @@ export function addLabelToQuery(query: string, key: string, value: string): stri
   return parts.join('');
 }
 
-export function determineQueryHints(series: any[]): any[] {
+export function determineQueryHints(series: any[], datasource?: any): any[] {
   const hints = series.map((s, i) => {
     const query: string = s.query;
     const index: number = s.responseIndex;
@@ -138,12 +138,56 @@ export function determineQueryHints(series: any[]): any[] {
       }
     }
 
+    // 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.';
+        return {
+          label,
+          index,
+          fix: {
+            label: 'Expand rules',
+            action: {
+              type: 'EXPAND_RULES',
+              query,
+              index,
+              mapping: mappingForQuery,
+            },
+          },
+        };
+      }
+    }
+
     // No hint found
     return null;
   });
   return hints;
 }
 
+export function extractRuleMappingFromGroups(groups: any[]) {
+  return groups.reduce(
+    (mapping, group) =>
+      group.rules.filter(rule => rule.type === 'recording').reduce(
+        (acc, rule) => ({
+          ...acc,
+          [rule.name]: rule.query,
+        }),
+        mapping
+      ),
+    {}
+  );
+}
+
 export function prometheusRegularEscape(value) {
   if (typeof value === 'string') {
     return value.replace(/'/g, "\\\\'");
@@ -162,6 +206,7 @@ export class PrometheusDatasource {
   type: string;
   editorSrc: string;
   name: string;
+  ruleMappings: { [index: string]: string };
   supportsExplore: boolean;
   supportMetrics: boolean;
   url: string;
@@ -189,6 +234,11 @@ export class PrometheusDatasource {
     this.queryTimeout = instanceSettings.jsonData.queryTimeout;
     this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
     this.resultTransformer = new ResultTransformer(templateSrv);
+    this.ruleMappings = {};
+  }
+
+  init() {
+    this.loadRules();
   }
 
   _request(url, data?, options?: any) {
@@ -312,7 +362,7 @@ export class PrometheusDatasource {
         result = [...result, ...series];
 
         if (queries[index].hinting) {
-          const queryHints = determineQueryHints(series);
+          const queryHints = determineQueryHints(series, this);
           hints = [...hints, ...queryHints];
         }
       });
@@ -525,6 +575,21 @@ export class PrometheusDatasource {
     return state;
   }
 
+  loadRules() {
+    this.metadataRequest('/api/v1/rules')
+      .then(res => res.data || res.json())
+      .then(body => {
+        const groups = _.get(body, ['data', 'groups']);
+        if (groups) {
+          this.ruleMappings = extractRuleMappingFromGroups(groups);
+        }
+      })
+      .catch(e => {
+        console.log('Rules API is experimental. Ignore next error.');
+        console.error(e);
+      });
+  }
+
   modifyQuery(query: string, action: any): string {
     switch (action.type) {
       case 'ADD_FILTER': {
@@ -536,6 +601,14 @@ export class PrometheusDatasource {
       case 'ADD_RATE': {
         return `rate(${query}[5m])`;
       }
+      case 'EXPAND_RULES': {
+        const mapping = action.mapping;
+        if (mapping) {
+          const ruleNames = Object.keys(mapping);
+          const rulesRegex = new RegExp(`(\\s|^)(${ruleNames.join('|')})(\\s|$|\\()`, 'ig');
+          return query.replace(rulesRegex, (match, pre, name, post) => mapping[name]);
+        }
+      }
       default:
         return query;
     }

+ 31 - 0
public/app/plugins/datasource/prometheus/specs/datasource.jest.ts

@@ -4,6 +4,7 @@ import q from 'q';
 import {
   alignRange,
   determineQueryHints,
+  extractRuleMappingFromGroups,
   PrometheusDatasource,
   prometheusSpecialRegexEscape,
   prometheusRegularEscape,
@@ -229,6 +230,36 @@ describe('PrometheusDatasource', () => {
     });
   });
 
+  describe('extractRuleMappingFromGroups()', () => {
+    it('returns empty mapping for no rule groups', () => {
+      expect(extractRuleMappingFromGroups([])).toEqual({});
+    });
+
+    it('returns a mapping for recording rules only', () => {
+      const groups = [
+        {
+          rules: [
+            {
+              name: 'HighRequestLatency',
+              query: 'job:request_latency_seconds:mean5m{job="myjob"} > 0.5',
+              type: 'alerting',
+            },
+            {
+              name: 'job:http_inprogress_requests:sum',
+              query: 'sum(http_inprogress_requests) by (job)',
+              type: 'recording',
+            },
+          ],
+          file: '/rules.yaml',
+          interval: 60,
+          name: 'example',
+        },
+      ];
+      const mapping = extractRuleMappingFromGroups(groups);
+      expect(mapping).toEqual({ 'job:http_inprogress_requests:sum': 'sum(http_inprogress_requests) by (job)' });
+    });
+  });
+
   describe('Prometheus regular escaping', () => {
     it('should not escape non-string', () => {
       expect(prometheusRegularEscape(12)).toEqual(12);