language_provider.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import { chain, difference } from 'lodash';
  2. import LRU from 'lru-cache';
  3. import Prism, { Grammar } from 'prismjs';
  4. import { dateTime, AbsoluteTimeRange, LanguageProvider, HistoryItem, AbstractQuery } from '@grafana/data';
  5. import { CompletionItem, TypeaheadInput, TypeaheadOutput, CompletionItemGroup } from '@grafana/ui';
  6. import {
  7. extractLabelMatchers,
  8. parseSelector,
  9. processLabels,
  10. toPromLikeExpr,
  11. } from 'app/plugins/datasource/prometheus/language_utils';
  12. import { LokiDatasource } from './datasource';
  13. import syntax, { FUNCTIONS, PIPE_PARSERS, PIPE_OPERATORS } from './syntax';
  14. import { LokiQuery, LokiQueryType } from './types';
  15. const DEFAULT_KEYS = ['job', 'namespace'];
  16. const EMPTY_SELECTOR = '{}';
  17. const HISTORY_ITEM_COUNT = 10;
  18. const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
  19. const NS_IN_MS = 1000000;
  20. // When changing RATE_RANGES, check if Prometheus/PromQL ranges should be changed too
  21. // @see public/app/plugins/datasource/prometheus/promql.ts
  22. const RATE_RANGES: CompletionItem[] = [
  23. { label: '$__interval', sortValue: '$__interval' },
  24. { label: '$__range', sortValue: '$__range' },
  25. { label: '1m', sortValue: '00:01:00' },
  26. { label: '5m', sortValue: '00:05:00' },
  27. { label: '10m', sortValue: '00:10:00' },
  28. { label: '30m', sortValue: '00:30:00' },
  29. { label: '1h', sortValue: '01:00:00' },
  30. { label: '1d', sortValue: '24:00:00' },
  31. ];
  32. export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
  33. const wrapLabel = (label: string) => ({ label, filterText: `\"${label}\"` });
  34. export type LokiHistoryItem = HistoryItem<LokiQuery>;
  35. type TypeaheadContext = {
  36. history?: LokiHistoryItem[];
  37. absoluteRange?: AbsoluteTimeRange;
  38. };
  39. export function addHistoryMetadata(item: CompletionItem, history: LokiHistoryItem[]): CompletionItem {
  40. const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
  41. const historyForItem = history.filter((h) => h.ts > cutoffTs && h.query.expr === item.label);
  42. let hint = `Queried ${historyForItem.length} times in the last 24h.`;
  43. const recent = historyForItem[0];
  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. export default class LokiLanguageProvider extends LanguageProvider {
  54. labelKeys: string[];
  55. labelFetchTs: number;
  56. started = false;
  57. datasource: LokiDatasource;
  58. lookupsDisabled = false; // Dynamically set to true for big/slow instances
  59. /**
  60. * Cache for labels of series. This is bit simplistic in the sense that it just counts responses each as a 1 and does
  61. * not account for different size of a response. If that is needed a `length` function can be added in the options.
  62. * 10 as a max size is totally arbitrary right now.
  63. */
  64. private seriesCache = new LRU<string, Record<string, string[]>>({ max: 10 });
  65. private labelsCache = new LRU<string, string[]>({ max: 10 });
  66. constructor(datasource: LokiDatasource, initialValues?: any) {
  67. super();
  68. this.datasource = datasource;
  69. this.labelKeys = [];
  70. this.labelFetchTs = 0;
  71. Object.assign(this, initialValues);
  72. }
  73. // Strip syntax chars
  74. cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%\|]/g, '').trim();
  75. getSyntax(): Grammar {
  76. return syntax;
  77. }
  78. request = async (url: string, params?: any): Promise<any> => {
  79. try {
  80. return await this.datasource.metadataRequest(url, params);
  81. } catch (error) {
  82. console.error(error);
  83. }
  84. return undefined;
  85. };
  86. /**
  87. * Initialise the language provider by fetching set of labels. Without this initialisation the provider would return
  88. * just a set of hardcoded default labels on provideCompletionItems or a recent queries from history.
  89. */
  90. start = () => {
  91. if (!this.startTask) {
  92. this.startTask = this.fetchLabels().then(() => {
  93. this.started = true;
  94. return [];
  95. });
  96. }
  97. return this.startTask;
  98. };
  99. getLabelKeys(): string[] {
  100. return this.labelKeys;
  101. }
  102. /**
  103. * Return suggestions based on input that can be then plugged into a typeahead dropdown.
  104. * Keep this DOM-free for testing
  105. * @param input
  106. * @param context Is optional in types but is required in case we are doing getLabelCompletionItems
  107. * @param context.absoluteRange Required in case we are doing getLabelCompletionItems
  108. * @param context.history Optional used only in getEmptyCompletionItems
  109. */
  110. async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise<TypeaheadOutput> {
  111. const { wrapperClasses, value, prefix, text } = input;
  112. const emptyResult: TypeaheadOutput = { suggestions: [] };
  113. if (!value) {
  114. return emptyResult;
  115. }
  116. // Local text properties
  117. const empty = value?.document.text.length === 0;
  118. const selectedLines = value.document.getTextsAtRange(value.selection);
  119. const currentLine = selectedLines.size === 1 ? selectedLines.first().getText() : null;
  120. const nextCharacter = currentLine ? currentLine[value.selection.anchor.offset] : null;
  121. // Syntax spans have 3 classes by default. More indicate a recognized token
  122. const tokenRecognized = wrapperClasses.length > 3;
  123. // Non-empty prefix, but not inside known token
  124. const prefixUnrecognized = prefix && !tokenRecognized;
  125. // Prevent suggestions in `function(|suffix)`
  126. const noSuffix = !nextCharacter || nextCharacter === ')';
  127. // Prefix is safe if it does not immediately follow a complete expression and has no text after it
  128. const safePrefix = prefix && !text.match(/^['"~=\]})\s]+$/) && noSuffix;
  129. // About to type next operand if preceded by binary operator
  130. const operatorsPattern = /[+\-*/^%]/;
  131. const isNextOperand = text.match(operatorsPattern);
  132. // Determine candidates by CSS context
  133. if (wrapperClasses.includes('context-range')) {
  134. // Suggestions for metric[|]
  135. return this.getRangeCompletionItems();
  136. } else if (wrapperClasses.includes('context-labels')) {
  137. // Suggestions for {|} and {foo=|}
  138. return await this.getLabelCompletionItems(input);
  139. } else if (wrapperClasses.includes('context-pipe')) {
  140. return this.getPipeCompletionItem();
  141. } else if (empty) {
  142. // Suggestions for empty query field
  143. return this.getEmptyCompletionItems(context);
  144. } else if (prefixUnrecognized && noSuffix && !isNextOperand) {
  145. // Show term suggestions in a couple of scenarios
  146. return this.getBeginningCompletionItems(context);
  147. } else if (prefixUnrecognized && safePrefix) {
  148. // Show term suggestions in a couple of scenarios
  149. return this.getTermCompletionItems();
  150. }
  151. return emptyResult;
  152. }
  153. getBeginningCompletionItems = (context?: TypeaheadContext): TypeaheadOutput => {
  154. return {
  155. suggestions: [...this.getEmptyCompletionItems(context).suggestions, ...this.getTermCompletionItems().suggestions],
  156. };
  157. };
  158. getEmptyCompletionItems(context?: TypeaheadContext): TypeaheadOutput {
  159. const history = context?.history;
  160. const suggestions = [];
  161. if (history?.length) {
  162. const historyItems = chain(history)
  163. .map((h) => h.query.expr)
  164. .filter()
  165. .uniq()
  166. .take(HISTORY_ITEM_COUNT)
  167. .map(wrapLabel)
  168. .map((item) => addHistoryMetadata(item, history))
  169. .value();
  170. suggestions.push({
  171. prefixMatch: true,
  172. skipSort: true,
  173. label: 'History',
  174. items: historyItems,
  175. });
  176. }
  177. return { suggestions };
  178. }
  179. getTermCompletionItems = (): TypeaheadOutput => {
  180. const suggestions = [];
  181. suggestions.push({
  182. prefixMatch: true,
  183. label: 'Functions',
  184. items: FUNCTIONS.map((suggestion) => ({ ...suggestion, kind: 'function' })),
  185. });
  186. return { suggestions };
  187. };
  188. getPipeCompletionItem = (): TypeaheadOutput => {
  189. const suggestions = [];
  190. suggestions.push({
  191. label: 'Operators',
  192. items: PIPE_OPERATORS.map((suggestion) => ({ ...suggestion, kind: 'operators' })),
  193. });
  194. suggestions.push({
  195. label: 'Parsers',
  196. items: PIPE_PARSERS.map((suggestion) => ({ ...suggestion, kind: 'parsers' })),
  197. });
  198. return { suggestions };
  199. };
  200. getRangeCompletionItems(): TypeaheadOutput {
  201. return {
  202. context: 'context-range',
  203. suggestions: [
  204. {
  205. label: 'Range vector',
  206. items: [...RATE_RANGES],
  207. },
  208. ],
  209. };
  210. }
  211. async getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): Promise<TypeaheadOutput> {
  212. let context = 'context-labels';
  213. const suggestions: CompletionItemGroup[] = [];
  214. if (!value) {
  215. return { context, suggestions: [] };
  216. }
  217. const line = value.anchorBlock.getText();
  218. const cursorOffset = value.selection.anchor.offset;
  219. const isValueStart = text.match(/^(=|=~|!=|!~)/);
  220. // Get normalized selector
  221. let selector;
  222. let parsedSelector;
  223. try {
  224. parsedSelector = parseSelector(line, cursorOffset);
  225. selector = parsedSelector.selector;
  226. } catch {
  227. selector = EMPTY_SELECTOR;
  228. }
  229. if (!labelKey && selector === EMPTY_SELECTOR) {
  230. // start task gets all labels
  231. await this.start();
  232. const allLabels = this.getLabelKeys();
  233. return { context, suggestions: [{ label: `Labels`, items: allLabels.map(wrapLabel) }] };
  234. }
  235. const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
  236. let labelValues;
  237. // Query labels for selector
  238. if (selector) {
  239. if (selector === EMPTY_SELECTOR && labelKey) {
  240. const labelValuesForKey = await this.getLabelValues(labelKey);
  241. labelValues = { [labelKey]: labelValuesForKey };
  242. } else {
  243. labelValues = await this.getSeriesLabels(selector);
  244. }
  245. }
  246. if (!labelValues) {
  247. console.warn(`Server did not return any values for selector = ${selector}`);
  248. return { context, suggestions };
  249. }
  250. if ((text && isValueStart) || wrapperClasses.includes('attr-value')) {
  251. // Label values
  252. if (labelKey && labelValues[labelKey]) {
  253. context = 'context-label-values';
  254. suggestions.push({
  255. label: `Label values for "${labelKey}"`,
  256. // Filter to prevent previously selected values from being repeatedly suggested
  257. items: labelValues[labelKey].map(wrapLabel).filter(({ filterText }) => filterText !== text),
  258. });
  259. }
  260. } else {
  261. // Label keys
  262. const labelKeys = labelValues ? Object.keys(labelValues) : DEFAULT_KEYS;
  263. if (labelKeys) {
  264. const possibleKeys = difference(labelKeys, existingKeys);
  265. if (possibleKeys.length) {
  266. const newItems = possibleKeys.map((key) => ({ label: key }));
  267. const newSuggestion: CompletionItemGroup = { label: `Labels`, items: newItems };
  268. suggestions.push(newSuggestion);
  269. }
  270. }
  271. }
  272. return { context, suggestions };
  273. }
  274. importFromAbstractQuery(labelBasedQuery: AbstractQuery): LokiQuery {
  275. return {
  276. refId: labelBasedQuery.refId,
  277. expr: toPromLikeExpr(labelBasedQuery),
  278. queryType: LokiQueryType.Range,
  279. };
  280. }
  281. exportToAbstractQuery(query: LokiQuery): AbstractQuery {
  282. const lokiQuery = query.expr;
  283. if (!lokiQuery || lokiQuery.length === 0) {
  284. return { refId: query.refId, labelMatchers: [] };
  285. }
  286. const tokens = Prism.tokenize(lokiQuery, syntax);
  287. return {
  288. refId: query.refId,
  289. labelMatchers: extractLabelMatchers(tokens),
  290. };
  291. }
  292. async getSeriesLabels(selector: string) {
  293. if (this.lookupsDisabled) {
  294. return undefined;
  295. }
  296. try {
  297. return await this.fetchSeriesLabels(selector);
  298. } catch (error) {
  299. // TODO: better error handling
  300. console.error(error);
  301. return undefined;
  302. }
  303. }
  304. /**
  305. * Fetches all label keys
  306. */
  307. async fetchLabels(): Promise<string[]> {
  308. const url = 'labels';
  309. const timeRange = this.datasource.getTimeRangeParams();
  310. this.labelFetchTs = Date.now().valueOf();
  311. const res = await this.request(url, timeRange);
  312. if (Array.isArray(res)) {
  313. const labels = res
  314. .slice()
  315. .sort()
  316. .filter((label) => label !== '__name__');
  317. this.labelKeys = labels;
  318. }
  319. return [];
  320. }
  321. async refreshLogLabels(forceRefresh?: boolean) {
  322. if ((this.labelKeys && Date.now().valueOf() - this.labelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
  323. await this.fetchLabels();
  324. }
  325. }
  326. /**
  327. * Fetch labels for a selector. This is cached by it's args but also by the global timeRange currently selected as
  328. * they can change over requested time.
  329. * @param name
  330. */
  331. fetchSeriesLabels = async (match: string): Promise<Record<string, string[]>> => {
  332. const interpolatedMatch = this.datasource.interpolateString(match);
  333. const url = 'series';
  334. const { start, end } = this.datasource.getTimeRangeParams();
  335. const cacheKey = this.generateCacheKey(url, start, end, interpolatedMatch);
  336. let value = this.seriesCache.get(cacheKey);
  337. if (!value) {
  338. // Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice.
  339. this.seriesCache.set(cacheKey, {});
  340. const params = { 'match[]': interpolatedMatch, start, end };
  341. const data = await this.request(url, params);
  342. const { values } = processLabels(data);
  343. value = values;
  344. this.seriesCache.set(cacheKey, value);
  345. }
  346. return value;
  347. };
  348. /**
  349. * Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels.
  350. * @param match
  351. */
  352. fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => {
  353. const url = 'series';
  354. const { start, end } = this.datasource.getTimeRangeParams();
  355. const params = { 'match[]': match, start, end };
  356. return await this.request(url, params);
  357. };
  358. // Cache key is a bit different here. We round up to a minute the intervals.
  359. // The rounding may seem strange but makes relative intervals like now-1h less prone to need separate request every
  360. // millisecond while still actually getting all the keys for the correct interval. This still can create problems
  361. // when user does not the newest values for a minute if already cached.
  362. generateCacheKey(url: string, start: number, end: number, param: string): string {
  363. return [url, this.roundTime(start), this.roundTime(end), param].join();
  364. }
  365. // Round nanos epoch to nearest 5 minute interval
  366. roundTime(nanos: number): number {
  367. return nanos ? Math.floor(nanos / NS_IN_MS / 1000 / 60 / 5) : 0;
  368. }
  369. async getLabelValues(key: string): Promise<string[]> {
  370. return await this.fetchLabelValues(key);
  371. }
  372. async fetchLabelValues(key: string): Promise<string[]> {
  373. const interpolatedKey = encodeURIComponent(this.datasource.interpolateString(key));
  374. const url = `label/${interpolatedKey}/values`;
  375. const rangeParams = this.datasource.getTimeRangeParams();
  376. const { start, end } = rangeParams;
  377. const cacheKey = this.generateCacheKey(url, start, end, interpolatedKey);
  378. const params = { start, end };
  379. let labelValues = this.labelsCache.get(cacheKey);
  380. if (!labelValues) {
  381. // Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice.
  382. this.labelsCache.set(cacheKey, []);
  383. const res = await this.request(url, params);
  384. if (Array.isArray(res)) {
  385. labelValues = res.slice().sort();
  386. this.labelsCache.set(cacheKey, labelValues);
  387. }
  388. }
  389. return labelValues ?? [];
  390. }
  391. }