language_provider.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import { sortedUniq } from 'lodash';
  2. import Prism, { Grammar } from 'prismjs';
  3. import { lastValueFrom } from 'rxjs';
  4. import { AbsoluteTimeRange, HistoryItem, LanguageProvider } from '@grafana/data';
  5. import { CompletionItemGroup, SearchFunctionType, Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
  6. import { CloudWatchDatasource } from './datasource';
  7. import syntax, {
  8. AGGREGATION_FUNCTIONS_STATS,
  9. BOOLEAN_FUNCTIONS,
  10. DATETIME_FUNCTIONS,
  11. FIELD_AND_FILTER_FUNCTIONS,
  12. IP_FUNCTIONS,
  13. NUMERIC_OPERATORS,
  14. QUERY_COMMANDS,
  15. STRING_FUNCTIONS,
  16. } from './syntax';
  17. import { CloudWatchQuery, TSDBResponse } from './types';
  18. export type CloudWatchHistoryItem = HistoryItem<CloudWatchQuery>;
  19. type TypeaheadContext = {
  20. history?: CloudWatchHistoryItem[];
  21. absoluteRange?: AbsoluteTimeRange;
  22. logGroupNames?: string[];
  23. region: string;
  24. };
  25. export class CloudWatchLanguageProvider extends LanguageProvider {
  26. started = false;
  27. declare initialRange: AbsoluteTimeRange;
  28. datasource: CloudWatchDatasource;
  29. constructor(datasource: CloudWatchDatasource, initialValues?: any) {
  30. super();
  31. this.datasource = datasource;
  32. Object.assign(this, initialValues);
  33. }
  34. // Strip syntax chars
  35. cleanText = (s: string) => s.replace(/[()]/g, '').trim();
  36. getSyntax(): Grammar {
  37. return syntax;
  38. }
  39. request = (url: string, params?: any): Promise<TSDBResponse> => {
  40. return lastValueFrom(this.datasource.awsRequest(url, params));
  41. };
  42. start = () => {
  43. if (!this.startTask) {
  44. this.startTask = Promise.resolve().then(() => {
  45. this.started = true;
  46. return [];
  47. });
  48. }
  49. return this.startTask;
  50. };
  51. isStatsQuery(query: string): boolean {
  52. const grammar = this.getSyntax();
  53. const tokens = Prism.tokenize(query, grammar) ?? [];
  54. return !!tokens.find(
  55. (token) =>
  56. typeof token !== 'string' &&
  57. token.content.toString().toLowerCase() === 'stats' &&
  58. token.type === 'query-command'
  59. );
  60. }
  61. /**
  62. * Return suggestions based on input that can be then plugged into a typeahead dropdown.
  63. * Keep this DOM-free for testing
  64. * @param input
  65. * @param context Is optional in types but is required in case we are doing getLabelCompletionItems
  66. * @param context.absoluteRange Required in case we are doing getLabelCompletionItems
  67. * @param context.history Optional used only in getEmptyCompletionItems
  68. */
  69. async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise<TypeaheadOutput> {
  70. const { value } = input;
  71. // Get tokens
  72. const tokens = value?.data.get('tokens');
  73. if (!tokens || !tokens.length) {
  74. return { suggestions: [] };
  75. }
  76. const curToken: Token = tokens.filter(
  77. (token: any) =>
  78. token.offsets.start <= value!.selection?.start?.offset && token.offsets.end >= value!.selection?.start?.offset
  79. )[0];
  80. const isFirstToken = !curToken.prev;
  81. const prevToken = prevNonWhitespaceToken(curToken);
  82. const isCommandStart = isFirstToken || (!isFirstToken && prevToken?.types.includes('command-separator'));
  83. if (isCommandStart) {
  84. return this.getCommandCompletionItems();
  85. }
  86. if (isInsideFunctionParenthesis(curToken)) {
  87. return await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
  88. }
  89. if (isAfterKeyword('by', curToken)) {
  90. return this.handleKeyword(context);
  91. }
  92. if (prevToken?.types.includes('comparison-operator')) {
  93. return this.handleComparison(context);
  94. }
  95. const commandToken = previousCommandToken(curToken);
  96. if (commandToken) {
  97. return await this.handleCommand(commandToken, curToken, context);
  98. }
  99. return {
  100. suggestions: [],
  101. };
  102. }
  103. private fetchedFieldsCache:
  104. | {
  105. time: number;
  106. logGroups: string[];
  107. fields: string[];
  108. }
  109. | undefined;
  110. private fetchFields = async (logGroups: string[], region: string): Promise<string[]> => {
  111. if (
  112. this.fetchedFieldsCache &&
  113. Date.now() - this.fetchedFieldsCache.time < 30 * 1000 &&
  114. sortedUniq(this.fetchedFieldsCache.logGroups).join('|') === sortedUniq(logGroups).join('|')
  115. ) {
  116. return this.fetchedFieldsCache.fields;
  117. }
  118. const results = await Promise.all(
  119. logGroups.map((logGroup) => this.datasource.getLogGroupFields({ logGroupName: logGroup, region }))
  120. );
  121. const fields = [
  122. ...new Set<string>(
  123. results.reduce((acc: string[], cur) => acc.concat(cur.logGroupFields?.map((f) => f.name) as string[]), [])
  124. ).values(),
  125. ];
  126. this.fetchedFieldsCache = {
  127. time: Date.now(),
  128. logGroups,
  129. fields,
  130. };
  131. return fields;
  132. };
  133. private handleKeyword = async (context?: TypeaheadContext): Promise<TypeaheadOutput> => {
  134. const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
  135. const functionSuggestions: CompletionItemGroup[] = [
  136. {
  137. searchFunctionType: SearchFunctionType.Prefix,
  138. label: 'Functions',
  139. items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS),
  140. },
  141. ];
  142. suggs.suggestions.push(...functionSuggestions);
  143. return suggs;
  144. };
  145. private handleCommand = async (
  146. commandToken: Token,
  147. curToken: Token,
  148. context?: TypeaheadContext
  149. ): Promise<TypeaheadOutput> => {
  150. const queryCommand = commandToken.content.toLowerCase();
  151. const prevToken = prevNonWhitespaceToken(curToken);
  152. const currentTokenIsFirstArg = prevToken === commandToken;
  153. if (queryCommand === 'sort') {
  154. return this.handleSortCommand(currentTokenIsFirstArg, curToken, context);
  155. }
  156. if (queryCommand === 'parse') {
  157. if (currentTokenIsFirstArg) {
  158. return await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
  159. }
  160. }
  161. const currentTokenIsAfterCommandAndEmpty = isTokenType(commandToken.next, 'whitespace') && !commandToken.next?.next;
  162. const currentTokenIsAfterCommand =
  163. currentTokenIsAfterCommandAndEmpty || nextNonWhitespaceToken(commandToken) === curToken;
  164. const currentTokenIsComma = isTokenType(curToken, 'punctuation', ',');
  165. const currentTokenIsCommaOrAfterComma = currentTokenIsComma || isTokenType(prevToken, 'punctuation', ',');
  166. // We only show suggestions if we are after a command or after a comma which is a field separator
  167. if (!(currentTokenIsAfterCommand || currentTokenIsCommaOrAfterComma)) {
  168. return { suggestions: [] };
  169. }
  170. if (['display', 'fields'].includes(queryCommand)) {
  171. const typeaheadOutput = await this.getFieldCompletionItems(
  172. context?.logGroupNames ?? [],
  173. context?.region || 'default'
  174. );
  175. typeaheadOutput.suggestions.push(...this.getFieldAndFilterFunctionCompletionItems().suggestions);
  176. return typeaheadOutput;
  177. }
  178. if (queryCommand === 'stats') {
  179. const typeaheadOutput = this.getStatsAggCompletionItems();
  180. if (currentTokenIsComma || currentTokenIsAfterCommandAndEmpty) {
  181. typeaheadOutput?.suggestions.forEach((group) => {
  182. group.skipFilter = true;
  183. });
  184. }
  185. return typeaheadOutput;
  186. }
  187. if (queryCommand === 'filter' && currentTokenIsFirstArg) {
  188. const sugg = await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
  189. const boolFuncs = this.getBoolFuncCompletionItems();
  190. sugg.suggestions.push(...boolFuncs.suggestions);
  191. return sugg;
  192. }
  193. return { suggestions: [] };
  194. };
  195. private async handleSortCommand(
  196. isFirstArgument: boolean,
  197. curToken: Token,
  198. context?: TypeaheadContext
  199. ): Promise<TypeaheadOutput> {
  200. if (isFirstArgument) {
  201. return await this.getFieldCompletionItems(context?.logGroupNames ?? [], context?.region || 'default');
  202. } else if (isTokenType(prevNonWhitespaceToken(curToken), 'field-name')) {
  203. // suggest sort options
  204. return {
  205. suggestions: [
  206. {
  207. searchFunctionType: SearchFunctionType.Prefix,
  208. label: 'Sort Order',
  209. items: [
  210. {
  211. label: 'asc',
  212. },
  213. { label: 'desc' },
  214. ],
  215. },
  216. ],
  217. };
  218. }
  219. return { suggestions: [] };
  220. }
  221. private handleComparison = async (context?: TypeaheadContext) => {
  222. const fieldsSuggestions = await this.getFieldCompletionItems(
  223. context?.logGroupNames ?? [],
  224. context?.region || 'default'
  225. );
  226. const comparisonSuggestions = this.getComparisonCompletionItems();
  227. fieldsSuggestions.suggestions.push(...comparisonSuggestions.suggestions);
  228. return fieldsSuggestions;
  229. };
  230. private getCommandCompletionItems = (): TypeaheadOutput => {
  231. return {
  232. suggestions: [{ searchFunctionType: SearchFunctionType.Prefix, label: 'Commands', items: QUERY_COMMANDS }],
  233. };
  234. };
  235. private getFieldAndFilterFunctionCompletionItems = (): TypeaheadOutput => {
  236. return {
  237. suggestions: [
  238. { searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: FIELD_AND_FILTER_FUNCTIONS },
  239. ],
  240. };
  241. };
  242. private getStatsAggCompletionItems = (): TypeaheadOutput => {
  243. return {
  244. suggestions: [
  245. { searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS },
  246. ],
  247. };
  248. };
  249. private getBoolFuncCompletionItems = (): TypeaheadOutput => {
  250. return {
  251. suggestions: [
  252. {
  253. searchFunctionType: SearchFunctionType.Prefix,
  254. label: 'Functions',
  255. items: BOOLEAN_FUNCTIONS,
  256. },
  257. ],
  258. };
  259. };
  260. private getComparisonCompletionItems = (): TypeaheadOutput => {
  261. return {
  262. suggestions: [
  263. {
  264. searchFunctionType: SearchFunctionType.Prefix,
  265. label: 'Functions',
  266. items: NUMERIC_OPERATORS.concat(BOOLEAN_FUNCTIONS),
  267. },
  268. ],
  269. };
  270. };
  271. private getFieldCompletionItems = async (logGroups: string[], region: string): Promise<TypeaheadOutput> => {
  272. const fields = await this.fetchFields(logGroups, region);
  273. return {
  274. suggestions: [
  275. {
  276. label: 'Fields',
  277. items: fields.map((field) => ({
  278. label: field,
  279. insertText: field.match(/@?[_a-zA-Z]+[_.0-9a-zA-Z]*/) ? undefined : `\`${field}\``,
  280. })),
  281. },
  282. ],
  283. };
  284. };
  285. }
  286. function nextNonWhitespaceToken(token: Token): Token | null {
  287. let curToken = token;
  288. while (curToken.next) {
  289. if (curToken.next.types.includes('whitespace')) {
  290. curToken = curToken.next;
  291. } else {
  292. return curToken.next;
  293. }
  294. }
  295. return null;
  296. }
  297. function prevNonWhitespaceToken(token: Token): Token | null {
  298. let curToken = token;
  299. while (curToken.prev) {
  300. if (isTokenType(curToken.prev, 'whitespace')) {
  301. curToken = curToken.prev;
  302. } else {
  303. return curToken.prev;
  304. }
  305. }
  306. return null;
  307. }
  308. function previousCommandToken(startToken: Token): Token | null {
  309. let thisToken = startToken;
  310. while (!!thisToken.prev) {
  311. thisToken = thisToken.prev;
  312. if (
  313. thisToken.types.includes('query-command') &&
  314. (!thisToken.prev || isTokenType(prevNonWhitespaceToken(thisToken), 'command-separator'))
  315. ) {
  316. return thisToken;
  317. }
  318. }
  319. return null;
  320. }
  321. const funcsWithFieldArgs = [
  322. 'avg',
  323. 'count',
  324. 'count_distinct',
  325. 'earliest',
  326. 'latest',
  327. 'sortsFirst',
  328. 'sortsLast',
  329. 'max',
  330. 'min',
  331. 'pct',
  332. 'stddev',
  333. 'ispresent',
  334. 'fromMillis',
  335. 'toMillis',
  336. 'isempty',
  337. 'isblank',
  338. 'isValidIp',
  339. 'isValidIpV4',
  340. 'isValidIpV6',
  341. 'isIpInSubnet',
  342. 'isIpv4InSubnet',
  343. 'isIpv6InSubnet',
  344. ].map((funcName) => funcName.toLowerCase());
  345. /**
  346. * Returns true if cursor is currently inside a function parenthesis for example `count(|)` or `count(@mess|)` should
  347. * return true.
  348. */
  349. function isInsideFunctionParenthesis(curToken: Token): boolean {
  350. const prevToken = prevNonWhitespaceToken(curToken);
  351. if (!prevToken) {
  352. return false;
  353. }
  354. const parenthesisToken = curToken.content === '(' ? curToken : prevToken.content === '(' ? prevToken : undefined;
  355. if (parenthesisToken) {
  356. const maybeFunctionToken = prevNonWhitespaceToken(parenthesisToken);
  357. if (maybeFunctionToken) {
  358. return (
  359. funcsWithFieldArgs.includes(maybeFunctionToken.content.toLowerCase()) &&
  360. maybeFunctionToken.types.includes('function')
  361. );
  362. }
  363. }
  364. return false;
  365. }
  366. function isAfterKeyword(keyword: string, token: Token): boolean {
  367. const maybeKeyword = getPreviousTokenExcluding(token, [
  368. 'whitespace',
  369. 'function',
  370. 'punctuation',
  371. 'field-name',
  372. 'number',
  373. ]);
  374. if (isTokenType(maybeKeyword, 'keyword', 'by')) {
  375. const prev = getPreviousTokenExcluding(token, ['whitespace']);
  376. if (prev === maybeKeyword || isTokenType(prev, 'punctuation', ',')) {
  377. return true;
  378. }
  379. }
  380. return false;
  381. }
  382. function isTokenType(token: Token | undefined | null, type: string, content?: string): boolean {
  383. if (!token?.types.includes(type)) {
  384. return false;
  385. }
  386. if (content) {
  387. if (token?.content.toLowerCase() !== content) {
  388. return false;
  389. }
  390. }
  391. return true;
  392. }
  393. type TokenDef = string | { type: string; value: string };
  394. function getPreviousTokenExcluding(token: Token, exclude: TokenDef[]): Token | undefined | null {
  395. let curToken = token.prev;
  396. main: while (curToken) {
  397. for (const item of exclude) {
  398. if (typeof item === 'string') {
  399. if (curToken.types.includes(item)) {
  400. curToken = curToken.prev;
  401. continue main;
  402. }
  403. } else {
  404. if (curToken.types.includes(item.type) && curToken.content.toLowerCase() === item.value) {
  405. curToken = curToken.prev;
  406. continue main;
  407. }
  408. }
  409. }
  410. break;
  411. }
  412. return curToken;
  413. }