utils.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. // eslint-disable-next-line simple-import-sort/imports
  2. import {
  3. filterTypeConfig,
  4. interchangeableFilterOperators,
  5. SearchConfig,
  6. TermOperator,
  7. Token,
  8. TokenResult,
  9. } from 'sentry/components/searchSyntax/parser';
  10. import {
  11. IconArrow,
  12. IconClock,
  13. IconDelete,
  14. IconExclamation,
  15. IconStar,
  16. IconTag,
  17. IconToggle,
  18. IconUser,
  19. } from 'sentry/icons';
  20. import {t} from 'sentry/locale';
  21. import {
  22. AutocompleteGroup,
  23. ItemType,
  24. SearchGroup,
  25. SearchItem,
  26. Shortcut,
  27. ShortcutType,
  28. invalidTypes,
  29. } from './types';
  30. import {TagCollection} from 'sentry/types';
  31. import {FieldKind, FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
  32. import {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
  33. export function addSpace(query = '') {
  34. if (query.length !== 0 && query[query.length - 1] !== ' ') {
  35. return query + ' ';
  36. }
  37. return query;
  38. }
  39. export function removeSpace(query = '') {
  40. if (query[query.length - 1] === ' ') {
  41. return query.slice(0, query.length - 1);
  42. }
  43. return query;
  44. }
  45. function getTitleForType(type: ItemType) {
  46. if (type === ItemType.TAG_VALUE) {
  47. return t('Values');
  48. }
  49. if (type === ItemType.RECENT_SEARCH) {
  50. return t('Recent Searches');
  51. }
  52. if (type === ItemType.DEFAULT) {
  53. return t('Common Search Terms');
  54. }
  55. if (type === ItemType.TAG_OPERATOR) {
  56. return t('Operator Helpers');
  57. }
  58. if (type === ItemType.PROPERTY) {
  59. return t('Properties');
  60. }
  61. return t('Keys');
  62. }
  63. function getIconForTypeAndTag(type: ItemType, tagName: string) {
  64. if (type === ItemType.RECENT_SEARCH) {
  65. return <IconClock size="xs" />;
  66. }
  67. if (type === ItemType.DEFAULT) {
  68. return <IconStar size="xs" />;
  69. }
  70. // Change based on tagName and default to "icon-tag"
  71. switch (tagName) {
  72. case 'is':
  73. return <IconToggle size="xs" />;
  74. case 'assigned':
  75. case 'assigned_or_suggested':
  76. case 'bookmarks':
  77. return <IconUser size="xs" />;
  78. case 'firstSeen':
  79. case 'lastSeen':
  80. case 'event.timestamp':
  81. return <IconClock size="xs" />;
  82. default:
  83. return <IconTag size="xs" />;
  84. }
  85. }
  86. const filterSearchItems = (
  87. searchItems: SearchItem[],
  88. recentSearchItems?: SearchItem[],
  89. maxSearchItems?: number,
  90. queryCharsLeft?: number
  91. ): {recentSearchItems: SearchItem[] | undefined; searchItems: SearchItem[]} => {
  92. if (maxSearchItems && maxSearchItems > 0) {
  93. searchItems = searchItems.filter(
  94. (value: SearchItem, index: number) =>
  95. index < maxSearchItems || value.ignoreMaxSearchItems
  96. );
  97. }
  98. if (queryCharsLeft || queryCharsLeft === 0) {
  99. searchItems = searchItems.flatMap(item => {
  100. if (!item.children) {
  101. if (!item.value || item.value.length <= queryCharsLeft) {
  102. return [item];
  103. }
  104. return [];
  105. }
  106. const newItem = {
  107. ...item,
  108. children: item.children.filter(
  109. child => !child.value || child.value.length <= queryCharsLeft
  110. ),
  111. };
  112. if (newItem.children.length === 0) {
  113. return [];
  114. }
  115. return [newItem];
  116. });
  117. searchItems = searchItems.filter(
  118. value => !value.value || value.value.length <= queryCharsLeft
  119. );
  120. if (recentSearchItems) {
  121. recentSearchItems = recentSearchItems.filter(
  122. value => !value.value || value.value.length <= queryCharsLeft
  123. );
  124. }
  125. }
  126. return {searchItems, recentSearchItems};
  127. };
  128. interface SearchGroups {
  129. activeSearchItem: number;
  130. flatSearchItems: SearchItem[];
  131. searchGroups: SearchGroup[];
  132. }
  133. function isSearchGroup(searchItem: SearchItem | SearchGroup): searchItem is SearchGroup {
  134. return (
  135. (searchItem as SearchGroup).children !== undefined && searchItem.type === 'header'
  136. );
  137. }
  138. function isSearchGroupArray(items: SearchItem[] | SearchGroup[]): items is SearchGroup[] {
  139. // Typescript doesn't like that there's no shared properties between SearchItem and SearchGroup
  140. return (items as any[]).every(isSearchGroup);
  141. }
  142. export function createSearchGroups(
  143. searchGroupItems: SearchItem[] | SearchGroup[],
  144. recentSearchItems: SearchItem[] | undefined,
  145. tagName: string,
  146. type: ItemType,
  147. maxSearchItems?: number,
  148. queryCharsLeft?: number,
  149. isDefaultState?: boolean,
  150. defaultSearchGroup?: SearchGroup,
  151. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  152. ): SearchGroups {
  153. const searchGroup: SearchGroup = {
  154. title: getTitleForType(type),
  155. type: invalidTypes.includes(type) ? type : 'header',
  156. icon: getIconForTypeAndTag(type, tagName),
  157. children: [],
  158. };
  159. if (isSearchGroupArray(searchGroupItems)) {
  160. // Autocomplete item has provided its own search groups
  161. const searchGroups = searchGroupItems
  162. .map(group => {
  163. const {searchItems: filteredSearchItems} = filterSearchItems(
  164. group.children,
  165. recentSearchItems,
  166. maxSearchItems,
  167. queryCharsLeft
  168. );
  169. return {...group, children: filteredSearchItems};
  170. })
  171. .filter(group => group.children.length > 0);
  172. return {
  173. // Fallback to the blank search group when "no items found"
  174. searchGroups: searchGroups.length ? searchGroups : [searchGroup],
  175. flatSearchItems: searchGroups.flatMap(item => item.children ?? []),
  176. activeSearchItem: -1,
  177. };
  178. }
  179. const fieldDefinition = fieldDefinitionGetter(tagName);
  180. const activeSearchItem = 0;
  181. const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
  182. filterSearchItems(
  183. searchGroupItems,
  184. recentSearchItems,
  185. maxSearchItems,
  186. queryCharsLeft
  187. );
  188. const recentSearchGroup: SearchGroup | undefined =
  189. filteredRecentSearchItems && filteredRecentSearchItems.length > 0
  190. ? {
  191. title: t('Recent Searches'),
  192. type: 'header',
  193. icon: <IconClock size="xs" />,
  194. children: [...filteredRecentSearchItems],
  195. }
  196. : undefined;
  197. searchGroup.children = filteredSearchItems;
  198. if (searchGroup.children && !!searchGroup.children.length) {
  199. searchGroup.children[activeSearchItem] = {
  200. ...searchGroup.children[activeSearchItem],
  201. };
  202. }
  203. const flatSearchItems = filteredSearchItems.flatMap(item => {
  204. if (item.children) {
  205. if (!item.value) {
  206. return [...item.children];
  207. }
  208. return [item, ...item.children];
  209. }
  210. return [item];
  211. });
  212. if (fieldDefinition?.valueType === FieldValueType.DATE) {
  213. if (type === ItemType.TAG_OPERATOR) {
  214. return {
  215. searchGroups: [],
  216. flatSearchItems: [],
  217. activeSearchItem: -1,
  218. };
  219. }
  220. }
  221. if (isDefaultState) {
  222. // Recent searches first in default state.
  223. return {
  224. searchGroups: [
  225. ...(recentSearchGroup ? [recentSearchGroup] : []),
  226. ...(defaultSearchGroup ? [defaultSearchGroup] : []),
  227. searchGroup,
  228. ],
  229. flatSearchItems: [
  230. ...(recentSearchItems ? recentSearchItems : []),
  231. ...(defaultSearchGroup ? defaultSearchGroup.children : []),
  232. ...flatSearchItems,
  233. ],
  234. activeSearchItem: -1,
  235. };
  236. }
  237. return {
  238. searchGroups: [searchGroup, ...(recentSearchGroup ? [recentSearchGroup] : [])],
  239. flatSearchItems: [
  240. ...flatSearchItems,
  241. ...(recentSearchItems ? recentSearchItems : []),
  242. ],
  243. activeSearchItem: -1,
  244. };
  245. }
  246. export function generateOperatorEntryMap(tag: string) {
  247. return {
  248. [TermOperator.DEFAULT]: {
  249. type: ItemType.TAG_OPERATOR,
  250. value: ':',
  251. desc: `${tag}:${t('[value]')}`,
  252. documentation: 'is equal to',
  253. },
  254. [TermOperator.GREATER_THAN_EQUAL]: {
  255. type: ItemType.TAG_OPERATOR,
  256. value: ':>=',
  257. desc: `${tag}:${t('>=[value]')}`,
  258. documentation: 'is greater than or equal to',
  259. },
  260. [TermOperator.LESS_THAN_EQUAL]: {
  261. type: ItemType.TAG_OPERATOR,
  262. value: ':<=',
  263. desc: `${tag}:${t('<=[value]')}`,
  264. documentation: 'is less than or equal to',
  265. },
  266. [TermOperator.GREATER_THAN]: {
  267. type: ItemType.TAG_OPERATOR,
  268. value: ':>',
  269. desc: `${tag}:${t('>[value]')}`,
  270. documentation: 'is greater than',
  271. },
  272. [TermOperator.LESS_THAN]: {
  273. type: ItemType.TAG_OPERATOR,
  274. value: ':<',
  275. desc: `${tag}:${t('<[value]')}`,
  276. documentation: 'is less than',
  277. },
  278. [TermOperator.EQUAL]: {
  279. type: ItemType.TAG_OPERATOR,
  280. value: ':=',
  281. desc: `${tag}:${t('=[value]')}`,
  282. documentation: 'is equal to',
  283. },
  284. [TermOperator.NOT_EQUAL]: {
  285. type: ItemType.TAG_OPERATOR,
  286. value: '!:',
  287. desc: `!${tag}:${t('[value]')}`,
  288. documentation: 'is not equal to',
  289. },
  290. };
  291. }
  292. export function getValidOps(
  293. filterToken: TokenResult<Token.FILTER>
  294. ): readonly TermOperator[] {
  295. // If the token is invalid we want to use the possible expected types as our filter type
  296. const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
  297. // Determine any interchangeable filter types for our valid types
  298. const interchangeableTypes = validTypes.map(
  299. type => interchangeableFilterOperators[type] ?? []
  300. );
  301. // Combine all types
  302. const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
  303. // Find all valid operations
  304. const validOps = new Set<TermOperator>(
  305. allValidTypes.map(type => filterTypeConfig[type].validOps).flat()
  306. );
  307. return [...validOps];
  308. }
  309. export const shortcuts: Shortcut[] = [
  310. {
  311. text: 'Delete',
  312. shortcutType: ShortcutType.DELETE,
  313. hotkeys: {
  314. actual: 'ctrl+option+backspace',
  315. },
  316. icon: <IconDelete size="xs" color="gray300" />,
  317. canRunShortcut: token => {
  318. return token?.type === Token.FILTER;
  319. },
  320. },
  321. {
  322. text: 'Exclude',
  323. shortcutType: ShortcutType.NEGATE,
  324. hotkeys: {
  325. actual: 'ctrl+option+1',
  326. },
  327. icon: <IconExclamation size="xs" color="gray300" />,
  328. canRunShortcut: token => {
  329. return token?.type === Token.FILTER && !token.negated;
  330. },
  331. },
  332. {
  333. text: 'Include',
  334. shortcutType: ShortcutType.NEGATE,
  335. hotkeys: {
  336. actual: 'ctrl+option+1',
  337. },
  338. icon: <IconExclamation size="xs" color="gray300" />,
  339. canRunShortcut: token => {
  340. return token?.type === Token.FILTER && token.negated;
  341. },
  342. },
  343. {
  344. text: 'Previous',
  345. shortcutType: ShortcutType.PREVIOUS,
  346. hotkeys: {
  347. actual: 'ctrl+option+left',
  348. },
  349. icon: <IconArrow direction="left" size="xs" color="gray300" />,
  350. canRunShortcut: (token, count) => {
  351. return count > 1 || (count > 0 && token?.type !== Token.FILTER);
  352. },
  353. },
  354. {
  355. text: 'Next',
  356. shortcutType: ShortcutType.NEXT,
  357. hotkeys: {
  358. actual: 'ctrl+option+right',
  359. },
  360. icon: <IconArrow direction="right" size="xs" color="gray300" />,
  361. canRunShortcut: (token, count) => {
  362. return count > 1 || (count > 0 && token?.type !== Token.FILTER);
  363. },
  364. },
  365. ];
  366. const getItemTitle = (key: string, kind: FieldKind) => {
  367. if (kind === FieldKind.FUNCTION) {
  368. // Replace the function innards with ... for cleanliness
  369. return key.replace(/\(.*\)/g, '(...)');
  370. }
  371. return key;
  372. };
  373. /**
  374. * Groups tag keys based on the "." character in their key.
  375. * For example, "device.arch" and "device.name" will be grouped together as children of "device", a non-interactive parent.
  376. * The parent will become interactive if there exists a key "device".
  377. */
  378. export const getTagItemsFromKeys = (
  379. tagKeys: Readonly<Readonly<string>[]>,
  380. supportedTags: TagCollection,
  381. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  382. ) => {
  383. return tagKeys.reduce<SearchItem[]>((groups: SearchItem[], key: string) => {
  384. const keyWithColon = `${key}:`;
  385. const sections = key.split('.');
  386. const definition =
  387. supportedTags[key]?.kind === FieldKind.FUNCTION
  388. ? fieldDefinitionGetter(key.split('(')[0])
  389. : fieldDefinitionGetter(key);
  390. const kind = supportedTags[key]?.kind ?? definition?.kind ?? FieldKind.FIELD;
  391. const item: SearchItem = {
  392. value: keyWithColon,
  393. title: getItemTitle(key, kind),
  394. documentation: definition?.desc ?? '-',
  395. kind,
  396. deprecated: definition?.deprecated,
  397. featureFlag: definition?.featureFlag,
  398. };
  399. const lastGroup = groups.at(-1);
  400. const [title] = sections;
  401. if (kind !== FieldKind.FUNCTION && lastGroup) {
  402. if (lastGroup.children && lastGroup.title === title) {
  403. lastGroup.children.push(item);
  404. return groups;
  405. }
  406. if (lastGroup.title && lastGroup.title.split('.')[0] === title) {
  407. if (lastGroup.title === title) {
  408. return [
  409. ...groups.slice(0, -1),
  410. {
  411. title,
  412. value: lastGroup.value,
  413. documentation: lastGroup.documentation,
  414. kind: lastGroup.kind,
  415. children: [item],
  416. },
  417. ];
  418. }
  419. // Add a blank parent if the last group's full key is not the same as the title
  420. return [
  421. ...groups.slice(0, -1),
  422. {
  423. title,
  424. value: null,
  425. documentation: '-',
  426. kind: lastGroup.kind,
  427. children: [lastGroup, item],
  428. },
  429. ];
  430. }
  431. }
  432. groups.push(item);
  433. return groups;
  434. }, []);
  435. };
  436. /**
  437. * Sets an item as active within a search group array and returns new search groups without mutating.
  438. * the item is compared via value, so this function assumes that each value is unique.
  439. */
  440. export const getSearchGroupWithItemMarkedActive = (
  441. searchGroups: SearchGroup[],
  442. currentItem: SearchItem,
  443. active: boolean
  444. ): SearchGroup[] => {
  445. return searchGroups.map(group => ({
  446. ...group,
  447. children: group.children?.map(item => {
  448. if (item.value === currentItem.value && item.type === currentItem.type) {
  449. return {
  450. ...item,
  451. active,
  452. };
  453. }
  454. if (item.children && item.children.length > 0) {
  455. return {
  456. ...item,
  457. children: item.children.map(child => {
  458. if (child.value === currentItem.value && item.type === currentItem.type) {
  459. return {
  460. ...child,
  461. active,
  462. };
  463. }
  464. return child;
  465. }),
  466. };
  467. }
  468. return item;
  469. }),
  470. }));
  471. };
  472. /**
  473. * Filter tag keys based on the query and the key, description, and associated keywords of each tag.
  474. */
  475. export const filterKeysFromQuery = (
  476. tagKeys: string[],
  477. searchTerm: string,
  478. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  479. ): string[] =>
  480. tagKeys
  481. .flatMap(key => {
  482. const keyWithoutFunctionPart = key.replaceAll(/\(.*\)/g, '').toLocaleLowerCase();
  483. const definition = fieldDefinitionGetter(keyWithoutFunctionPart);
  484. const lowerCasedSearchTerm = searchTerm.toLocaleLowerCase();
  485. const combinedKeywords = [
  486. ...(definition?.desc ? [definition.desc] : []),
  487. ...(definition?.keywords ?? []),
  488. ]
  489. .join(' ')
  490. .toLocaleLowerCase();
  491. const matchedInKey = keyWithoutFunctionPart.includes(lowerCasedSearchTerm);
  492. const matchedInKeywords = combinedKeywords.includes(lowerCasedSearchTerm);
  493. if (!matchedInKey && !matchedInKeywords) {
  494. return [];
  495. }
  496. return [{matchedInKey, matchedInKeywords, key}];
  497. })
  498. .sort((a, b) => {
  499. // Sort by matched in key first, then by matched in keywords
  500. if (a.matchedInKey && !b.matchedInKey) {
  501. return -1;
  502. }
  503. if (b.matchedInKey && !a.matchedInKey) {
  504. return 1;
  505. }
  506. return a.key < b.key ? -1 : 1;
  507. })
  508. .map(({key}) => key);
  509. const DATE_SUGGESTED_VALUES = [
  510. {
  511. title: t('Last hour'),
  512. value: '-1h',
  513. desc: '-1h',
  514. type: ItemType.TAG_VALUE,
  515. },
  516. {
  517. title: t('Last 24 hours'),
  518. value: '-24h',
  519. desc: '-24h',
  520. type: ItemType.TAG_VALUE,
  521. },
  522. {
  523. title: t('Last 7 days'),
  524. value: '-7d',
  525. desc: '-7d',
  526. type: ItemType.TAG_VALUE,
  527. },
  528. {
  529. title: t('Last 14 days'),
  530. value: '-14d',
  531. desc: '-14d',
  532. type: ItemType.TAG_VALUE,
  533. },
  534. {
  535. title: t('Last 30 days'),
  536. value: '-30d',
  537. desc: '-30d',
  538. type: ItemType.TAG_VALUE,
  539. },
  540. {
  541. title: t('After a custom datetime'),
  542. value: '>',
  543. desc: '>YYYY-MM-DDThh:mm:ss',
  544. type: ItemType.TAG_VALUE_ISO_DATE,
  545. },
  546. {
  547. title: t('Before a custom datetime'),
  548. value: '<',
  549. desc: '<YYYY-MM-DDThh:mm:ss',
  550. type: ItemType.TAG_VALUE_ISO_DATE,
  551. },
  552. {
  553. title: t('At a custom datetime'),
  554. value: '=',
  555. desc: '=YYYY-MM-DDThh:mm:ss',
  556. type: ItemType.TAG_VALUE_ISO_DATE,
  557. },
  558. ] satisfies SearchItem[];
  559. export const getDateTagAutocompleteGroups = (tagName: string): AutocompleteGroup[] => {
  560. return [
  561. {
  562. searchItems: DATE_SUGGESTED_VALUES,
  563. recentSearchItems: [],
  564. tagName,
  565. type: ItemType.TAG_VALUE,
  566. },
  567. ];
  568. };
  569. export const getSearchConfigFromCustomPerformanceMetrics = (
  570. customPerformanceMetrics?: CustomMeasurementCollection
  571. ): Partial<SearchConfig> => {
  572. const searchConfigMap: Record<string, string[]> = {
  573. sizeKeys: [],
  574. durationKeys: [],
  575. percentageKeys: [],
  576. numericKeys: [],
  577. };
  578. if (customPerformanceMetrics) {
  579. Object.keys(customPerformanceMetrics).forEach(metricName => {
  580. const {fieldType} = customPerformanceMetrics[metricName];
  581. switch (fieldType) {
  582. case 'size':
  583. searchConfigMap.sizeKeys.push(metricName);
  584. break;
  585. case 'duration':
  586. searchConfigMap.durationKeys.push(metricName);
  587. break;
  588. case 'percentage':
  589. searchConfigMap.percentageKeys.push(metricName);
  590. break;
  591. default:
  592. searchConfigMap.numericKeys.push(metricName);
  593. }
  594. });
  595. }
  596. const searchConfig = {
  597. sizeKeys: new Set(searchConfigMap.sizeKeys),
  598. durationKeys: new Set(searchConfigMap.durationKeys),
  599. percentageKeys: new Set(searchConfigMap.percentageKeys),
  600. numericKeys: new Set(searchConfigMap.numericKeys),
  601. };
  602. return searchConfig;
  603. };
  604. /**
  605. * Gets an invalid group for when the usage of wildcards are not allowed and it is used in the search query.
  606. * When this group is set, a message with a link to the documentation is displayed to the user in the dropdown.
  607. */
  608. export function getAutoCompleteGroupForInvalidWildcard(searchText: string) {
  609. return [
  610. {
  611. searchItems: [
  612. {
  613. type: ItemType.INVALID_QUERY_WITH_WILDCARD,
  614. desc: searchText,
  615. callback: () =>
  616. window.open(
  617. 'https://docs.sentry.io/product/sentry-basics/search/searchable-properties/'
  618. ),
  619. },
  620. ],
  621. recentSearchItems: [],
  622. tagName: searchText,
  623. type: ItemType.INVALID_QUERY_WITH_WILDCARD,
  624. },
  625. ];
  626. }
  627. export function escapeTagValue(value: string): string {
  628. // Wrap in quotes if there is a space
  629. const isArrayTag = value.startsWith('[') && value.endsWith(']') && value.includes(',');
  630. return (value.includes(' ') || value.includes('"')) && !isArrayTag
  631. ? `"${value.replace(/"/g, '\\"')}"`
  632. : value;
  633. }