language_provider.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import { once, chain, difference } from 'lodash';
  2. import LRU from 'lru-cache';
  3. import Prism from 'prismjs';
  4. import { Value } from 'slate';
  5. import {
  6. AbstractLabelMatcher,
  7. AbstractLabelOperator,
  8. AbstractQuery,
  9. dateTime,
  10. HistoryItem,
  11. LanguageProvider,
  12. } from '@grafana/data';
  13. import { CompletionItem, CompletionItemGroup, SearchFunctionType, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
  14. import { PrometheusDatasource } from './datasource';
  15. import {
  16. addLimitInfo,
  17. extractLabelMatchers,
  18. fixSummariesMetadata,
  19. parseSelector,
  20. processHistogramMetrics,
  21. processLabels,
  22. roundSecToMin,
  23. toPromLikeQuery,
  24. } from './language_utils';
  25. import PromqlSyntax, { FUNCTIONS, RATE_RANGES } from './promql';
  26. import { PromMetricsMetadata, PromQuery } from './types';
  27. const DEFAULT_KEYS = ['job', 'instance'];
  28. const EMPTY_SELECTOR = '{}';
  29. const HISTORY_ITEM_COUNT = 5;
  30. const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
  31. // Max number of items (metrics, labels, values) that we display as suggestions. Prevents from running out of memory.
  32. export const SUGGESTIONS_LIMIT = 10000;
  33. const wrapLabel = (label: string): CompletionItem => ({ label });
  34. const setFunctionKind = (suggestion: CompletionItem): CompletionItem => {
  35. suggestion.kind = 'function';
  36. return suggestion;
  37. };
  38. export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
  39. const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
  40. const historyForItem = history.filter((h) => h.ts > cutoffTs && h.query === item.label);
  41. const count = historyForItem.length;
  42. const recent = historyForItem[0];
  43. let hint = `Queried ${count} times in the last 24h.`;
  44. if (recent) {
  45. const lastQueried = dateTime(recent.ts).fromNow();
  46. hint = `${hint} Last queried ${lastQueried}.`;
  47. }
  48. return {
  49. ...item,
  50. documentation: hint,
  51. };
  52. }
  53. function addMetricsMetadata(metric: string, metadata?: PromMetricsMetadata): CompletionItem {
  54. const item: CompletionItem = { label: metric };
  55. if (metadata && metadata[metric]) {
  56. item.documentation = getMetadataString(metric, metadata);
  57. }
  58. return item;
  59. }
  60. export function getMetadataString(metric: string, metadata: PromMetricsMetadata): string | undefined {
  61. if (!metadata[metric]) {
  62. return undefined;
  63. }
  64. const { type, help } = metadata[metric];
  65. return `${type.toUpperCase()}: ${help}`;
  66. }
  67. const PREFIX_DELIMITER_REGEX =
  68. /(="|!="|=~"|!~"|\{|\[|\(|\+|-|\/|\*|%|\^|\band\b|\bor\b|\bunless\b|==|>=|!=|<=|>|<|=|~|,)/;
  69. interface AutocompleteContext {
  70. history?: Array<HistoryItem<PromQuery>>;
  71. }
  72. export default class PromQlLanguageProvider extends LanguageProvider {
  73. histogramMetrics: string[];
  74. timeRange?: { start: number; end: number };
  75. metrics: string[];
  76. metricsMetadata?: PromMetricsMetadata;
  77. declare startTask: Promise<any>;
  78. datasource: PrometheusDatasource;
  79. labelKeys: string[] = [];
  80. declare labelFetchTs: number;
  81. /**
  82. * Cache for labels of series. This is bit simplistic in the sense that it just counts responses each as a 1 and does
  83. * not account for different size of a response. If that is needed a `length` function can be added in the options.
  84. * 10 as a max size is totally arbitrary right now.
  85. */
  86. private labelsCache = new LRU<string, Record<string, string[]>>({ max: 10 });
  87. constructor(datasource: PrometheusDatasource, initialValues?: Partial<PromQlLanguageProvider>) {
  88. super();
  89. this.datasource = datasource;
  90. this.histogramMetrics = [];
  91. this.timeRange = { start: 0, end: 0 };
  92. this.metrics = [];
  93. Object.assign(this, initialValues);
  94. }
  95. // Strip syntax chars so that typeahead suggestions can work on clean inputs
  96. cleanText(s: string) {
  97. const parts = s.split(PREFIX_DELIMITER_REGEX);
  98. const last = parts.pop()!;
  99. return last.trimLeft().replace(/"$/, '').replace(/^"/, '');
  100. }
  101. get syntax() {
  102. return PromqlSyntax;
  103. }
  104. request = async (url: string, defaultValue: any, params = {}): Promise<any> => {
  105. try {
  106. const res = await this.datasource.metadataRequest(url, params);
  107. return res.data.data;
  108. } catch (error) {
  109. console.error(error);
  110. }
  111. return defaultValue;
  112. };
  113. start = async (): Promise<any[]> => {
  114. if (this.datasource.lookupsDisabled) {
  115. return [];
  116. }
  117. // TODO #33976: make those requests parallel
  118. await this.fetchLabels();
  119. this.metrics = (await this.fetchLabelValues('__name__')) || [];
  120. await this.loadMetricsMetadata();
  121. this.histogramMetrics = processHistogramMetrics(this.metrics).sort();
  122. return [];
  123. };
  124. async loadMetricsMetadata() {
  125. this.metricsMetadata = fixSummariesMetadata(await this.request('/api/v1/metadata', {}));
  126. }
  127. getLabelKeys(): string[] {
  128. return this.labelKeys;
  129. }
  130. provideCompletionItems = async (
  131. { prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput,
  132. context: AutocompleteContext = {}
  133. ): Promise<TypeaheadOutput> => {
  134. const emptyResult: TypeaheadOutput = { suggestions: [] };
  135. if (!value) {
  136. return emptyResult;
  137. }
  138. // Local text properties
  139. const empty = value.document.text.length === 0;
  140. const selectedLines = value.document.getTextsAtRange(value.selection);
  141. const currentLine = selectedLines.size === 1 ? selectedLines.first().getText() : null;
  142. const nextCharacter = currentLine ? currentLine[value.selection.anchor.offset] : null;
  143. // Syntax spans have 3 classes by default. More indicate a recognized token
  144. const tokenRecognized = wrapperClasses.length > 3;
  145. // Non-empty prefix, but not inside known token
  146. const prefixUnrecognized = prefix && !tokenRecognized;
  147. // Prevent suggestions in `function(|suffix)`
  148. const noSuffix = !nextCharacter || nextCharacter === ')';
  149. // Prefix is safe if it does not immediately follow a complete expression and has no text after it
  150. const safePrefix = prefix && !text.match(/^[\]})\s]+$/) && noSuffix;
  151. // About to type next operand if preceded by binary operator
  152. const operatorsPattern = /[+\-*/^%]/;
  153. const isNextOperand = text.match(operatorsPattern);
  154. // Determine candidates by CSS context
  155. if (wrapperClasses.includes('context-range')) {
  156. // Suggestions for metric[|]
  157. return this.getRangeCompletionItems();
  158. } else if (wrapperClasses.includes('context-labels')) {
  159. // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
  160. return this.getLabelCompletionItems({ prefix, text, value, labelKey, wrapperClasses });
  161. } else if (wrapperClasses.includes('context-aggregation')) {
  162. // Suggestions for sum(metric) by (|)
  163. return this.getAggregationCompletionItems(value);
  164. } else if (empty) {
  165. // Suggestions for empty query field
  166. return this.getEmptyCompletionItems(context);
  167. } else if (prefixUnrecognized && noSuffix && !isNextOperand) {
  168. // Show term suggestions in a couple of scenarios
  169. return this.getBeginningCompletionItems(context);
  170. } else if (prefixUnrecognized && safePrefix) {
  171. // Show term suggestions in a couple of scenarios
  172. return this.getTermCompletionItems();
  173. }
  174. return emptyResult;
  175. };
  176. getBeginningCompletionItems = (context: AutocompleteContext): TypeaheadOutput => {
  177. return {
  178. suggestions: [...this.getEmptyCompletionItems(context).suggestions, ...this.getTermCompletionItems().suggestions],
  179. };
  180. };
  181. getEmptyCompletionItems = (context: AutocompleteContext): TypeaheadOutput => {
  182. const { history } = context;
  183. const suggestions: CompletionItemGroup[] = [];
  184. if (history && history.length) {
  185. const historyItems = chain(history)
  186. .map((h) => h.query.expr)
  187. .filter()
  188. .uniq()
  189. .take(HISTORY_ITEM_COUNT)
  190. .map(wrapLabel)
  191. .map((item) => addHistoryMetadata(item, history))
  192. .value();
  193. suggestions.push({
  194. searchFunctionType: SearchFunctionType.Prefix,
  195. skipSort: true,
  196. label: 'History',
  197. items: historyItems,
  198. });
  199. }
  200. return { suggestions };
  201. };
  202. getTermCompletionItems = (): TypeaheadOutput => {
  203. const { metrics, metricsMetadata } = this;
  204. const suggestions: CompletionItemGroup[] = [];
  205. suggestions.push({
  206. searchFunctionType: SearchFunctionType.Prefix,
  207. label: 'Functions',
  208. items: FUNCTIONS.map(setFunctionKind),
  209. });
  210. if (metrics && metrics.length) {
  211. suggestions.push({
  212. label: 'Metrics',
  213. items: metrics.map((m) => addMetricsMetadata(m, metricsMetadata)),
  214. searchFunctionType: SearchFunctionType.Fuzzy,
  215. });
  216. }
  217. return { suggestions };
  218. };
  219. getRangeCompletionItems(): TypeaheadOutput {
  220. return {
  221. context: 'context-range',
  222. suggestions: [
  223. {
  224. label: 'Range vector',
  225. items: [...RATE_RANGES],
  226. },
  227. ],
  228. };
  229. }
  230. getAggregationCompletionItems = async (value: Value): Promise<TypeaheadOutput> => {
  231. const suggestions: CompletionItemGroup[] = [];
  232. // Stitch all query lines together to support multi-line queries
  233. let queryOffset;
  234. const queryText = value.document.getBlocks().reduce((text, block) => {
  235. if (text === undefined) {
  236. return '';
  237. }
  238. if (!block) {
  239. return text;
  240. }
  241. const blockText = block?.getText();
  242. if (value.anchorBlock.key === block.key) {
  243. // Newline characters are not accounted for but this is irrelevant
  244. // for the purpose of extracting the selector string
  245. queryOffset = value.selection.anchor.offset + text.length;
  246. }
  247. return text + blockText;
  248. }, '');
  249. // Try search for selector part on the left-hand side, such as `sum (m) by (l)`
  250. const openParensAggregationIndex = queryText.lastIndexOf('(', queryOffset);
  251. let openParensSelectorIndex = queryText.lastIndexOf('(', openParensAggregationIndex - 1);
  252. let closeParensSelectorIndex = queryText.indexOf(')', openParensSelectorIndex);
  253. // Try search for selector part of an alternate aggregation clause, such as `sum by (l) (m)`
  254. if (openParensSelectorIndex === -1) {
  255. const closeParensAggregationIndex = queryText.indexOf(')', queryOffset);
  256. closeParensSelectorIndex = queryText.indexOf(')', closeParensAggregationIndex + 1);
  257. openParensSelectorIndex = queryText.lastIndexOf('(', closeParensSelectorIndex);
  258. }
  259. const result = {
  260. suggestions,
  261. context: 'context-aggregation',
  262. };
  263. // Suggestions are useless for alternative aggregation clauses without a selector in context
  264. if (openParensSelectorIndex === -1) {
  265. return result;
  266. }
  267. // Range vector syntax not accounted for by subsequent parse so discard it if present
  268. const selectorString = queryText
  269. .slice(openParensSelectorIndex + 1, closeParensSelectorIndex)
  270. .replace(/\[[^\]]+\]$/, '');
  271. const selector = parseSelector(selectorString, selectorString.length - 2).selector;
  272. const series = await this.getSeries(selector);
  273. const labelKeys = Object.keys(series);
  274. if (labelKeys.length > 0) {
  275. const limitInfo = addLimitInfo(labelKeys);
  276. suggestions.push({
  277. label: `Labels${limitInfo}`,
  278. items: labelKeys.map(wrapLabel),
  279. searchFunctionType: SearchFunctionType.Fuzzy,
  280. });
  281. }
  282. return result;
  283. };
  284. getLabelCompletionItems = async ({
  285. text,
  286. wrapperClasses,
  287. labelKey,
  288. value,
  289. }: TypeaheadInput): Promise<TypeaheadOutput> => {
  290. if (!value) {
  291. return { suggestions: [] };
  292. }
  293. const suggestions: CompletionItemGroup[] = [];
  294. const line = value.anchorBlock.getText();
  295. const cursorOffset = value.selection.anchor.offset;
  296. const suffix = line.substr(cursorOffset);
  297. const prefix = line.substr(0, cursorOffset);
  298. const isValueStart = text.match(/^(=|=~|!=|!~)/);
  299. const isValueEnd = suffix.match(/^"?[,}]|$/);
  300. // Detect cursor in front of value, e.g., {key=|"}
  301. const isPreValue = prefix.match(/(=|=~|!=|!~)$/) && suffix.match(/^"/);
  302. // Don't suggest anything at the beginning or inside a value
  303. const isValueEmpty = isValueStart && isValueEnd;
  304. const hasValuePrefix = isValueEnd && !isValueStart;
  305. if ((!isValueEmpty && !hasValuePrefix) || isPreValue) {
  306. return { suggestions };
  307. }
  308. // Get normalized selector
  309. let selector;
  310. let parsedSelector;
  311. try {
  312. parsedSelector = parseSelector(line, cursorOffset);
  313. selector = parsedSelector.selector;
  314. } catch {
  315. selector = EMPTY_SELECTOR;
  316. }
  317. const containsMetric = selector.includes('__name__=');
  318. const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
  319. let series: Record<string, string[]> = {};
  320. // Query labels for selector
  321. if (selector) {
  322. series = await this.getSeries(selector, !containsMetric);
  323. }
  324. if (Object.keys(series).length === 0) {
  325. console.warn(`Server did not return any values for selector = ${selector}`);
  326. return { suggestions };
  327. }
  328. let context: string | undefined;
  329. if ((text && isValueStart) || wrapperClasses.includes('attr-value')) {
  330. // Label values
  331. if (labelKey && series[labelKey]) {
  332. context = 'context-label-values';
  333. const limitInfo = addLimitInfo(series[labelKey]);
  334. suggestions.push({
  335. label: `Label values for "${labelKey}"${limitInfo}`,
  336. items: series[labelKey].map(wrapLabel),
  337. searchFunctionType: SearchFunctionType.Fuzzy,
  338. });
  339. }
  340. } else {
  341. // Label keys
  342. const labelKeys = series ? Object.keys(series) : containsMetric ? null : DEFAULT_KEYS;
  343. if (labelKeys) {
  344. const possibleKeys = difference(labelKeys, existingKeys);
  345. if (possibleKeys.length) {
  346. context = 'context-labels';
  347. const newItems = possibleKeys.map((key) => ({ label: key }));
  348. const limitInfo = addLimitInfo(newItems);
  349. const newSuggestion: CompletionItemGroup = {
  350. label: `Labels${limitInfo}`,
  351. items: newItems,
  352. searchFunctionType: SearchFunctionType.Fuzzy,
  353. };
  354. suggestions.push(newSuggestion);
  355. }
  356. }
  357. }
  358. return { context, suggestions };
  359. };
  360. importFromAbstractQuery(labelBasedQuery: AbstractQuery): PromQuery {
  361. return toPromLikeQuery(labelBasedQuery);
  362. }
  363. exportToAbstractQuery(query: PromQuery): AbstractQuery {
  364. const promQuery = query.expr;
  365. if (!promQuery || promQuery.length === 0) {
  366. return { refId: query.refId, labelMatchers: [] };
  367. }
  368. const tokens = Prism.tokenize(promQuery, PromqlSyntax);
  369. const labelMatchers: AbstractLabelMatcher[] = extractLabelMatchers(tokens);
  370. const nameLabelValue = getNameLabelValue(promQuery, tokens);
  371. if (nameLabelValue && nameLabelValue.length > 0) {
  372. labelMatchers.push({
  373. name: '__name__',
  374. operator: AbstractLabelOperator.Equal,
  375. value: nameLabelValue,
  376. });
  377. }
  378. return {
  379. refId: query.refId,
  380. labelMatchers,
  381. };
  382. }
  383. async getSeries(selector: string, withName?: boolean): Promise<Record<string, string[]>> {
  384. if (this.datasource.lookupsDisabled) {
  385. return {};
  386. }
  387. try {
  388. if (selector === EMPTY_SELECTOR) {
  389. return await this.fetchDefaultSeries();
  390. } else {
  391. return await this.fetchSeriesLabels(selector, withName);
  392. }
  393. } catch (error) {
  394. // TODO: better error handling
  395. console.error(error);
  396. return {};
  397. }
  398. }
  399. fetchLabelValues = async (key: string): Promise<string[]> => {
  400. const params = this.datasource.getTimeRangeParams();
  401. const url = `/api/v1/label/${this.datasource.interpolateString(key)}/values`;
  402. return await this.request(url, [], params);
  403. };
  404. async getLabelValues(key: string): Promise<string[]> {
  405. return await this.fetchLabelValues(key);
  406. }
  407. /**
  408. * Fetches all label keys
  409. */
  410. async fetchLabels(): Promise<string[]> {
  411. const url = '/api/v1/labels';
  412. const params = this.datasource.getTimeRangeParams();
  413. this.labelFetchTs = Date.now().valueOf();
  414. const res = await this.request(url, [], params);
  415. if (Array.isArray(res)) {
  416. this.labelKeys = res.slice().sort();
  417. }
  418. return [];
  419. }
  420. /**
  421. * Fetch labels for a series. This is cached by it's args but also by the global timeRange currently selected as
  422. * they can change over requested time.
  423. * @param name
  424. * @param withName
  425. */
  426. fetchSeriesLabels = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => {
  427. const interpolatedName = this.datasource.interpolateString(name);
  428. const range = this.datasource.getTimeRangeParams();
  429. const urlParams = {
  430. ...range,
  431. 'match[]': interpolatedName,
  432. };
  433. const url = `/api/v1/series`;
  434. // Cache key is a bit different here. We add the `withName` param and also round up to a minute the intervals.
  435. // The rounding may seem strange but makes relative intervals like now-1h less prone to need separate request every
  436. // millisecond while still actually getting all the keys for the correct interval. This still can create problems
  437. // when user does not the newest values for a minute if already cached.
  438. const cacheParams = new URLSearchParams({
  439. 'match[]': interpolatedName,
  440. start: roundSecToMin(parseInt(range.start, 10)).toString(),
  441. end: roundSecToMin(parseInt(range.end, 10)).toString(),
  442. withName: withName ? 'true' : 'false',
  443. });
  444. const cacheKey = `/api/v1/series?${cacheParams.toString()}`;
  445. let value = this.labelsCache.get(cacheKey);
  446. if (!value) {
  447. const data = await this.request(url, [], urlParams);
  448. const { values } = processLabels(data, withName);
  449. value = values;
  450. this.labelsCache.set(cacheKey, value);
  451. }
  452. return value;
  453. };
  454. /**
  455. * Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels.
  456. * @param match
  457. */
  458. fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => {
  459. const url = '/api/v1/series';
  460. const range = this.datasource.getTimeRangeParams();
  461. const params = { ...range, 'match[]': match };
  462. return await this.request(url, {}, params);
  463. };
  464. /**
  465. * Fetch this only one as we assume this won't change over time. This is cached differently from fetchSeriesLabels
  466. * because we can cache more aggressively here and also we do not want to invalidate this cache the same way as in
  467. * fetchSeriesLabels.
  468. */
  469. fetchDefaultSeries = once(async () => {
  470. const values = await Promise.all(DEFAULT_KEYS.map((key) => this.fetchLabelValues(key)));
  471. return DEFAULT_KEYS.reduce((acc, key, i) => ({ ...acc, [key]: values[i] }), {});
  472. });
  473. }
  474. function getNameLabelValue(promQuery: string, tokens: any): string {
  475. let nameLabelValue = '';
  476. for (let prop in tokens) {
  477. if (typeof tokens[prop] === 'string') {
  478. nameLabelValue = tokens[prop] as string;
  479. break;
  480. }
  481. }
  482. return nameLabelValue;
  483. }