replaySearchBar.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import {useCallback, useMemo} from 'react';
  2. import orderBy from 'lodash/orderBy';
  3. import {fetchTagValues, useFetchOrganizationTags} from 'sentry/actionCreators/tags';
  4. import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
  5. import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types';
  6. import type SmartSearchBar from 'sentry/components/smartSearchBar';
  7. import {t} from 'sentry/locale';
  8. import type {PageFilters} from 'sentry/types/core';
  9. import type {Tag, TagCollection, TagValue} from 'sentry/types/group';
  10. import {SavedSearchType} from 'sentry/types/group';
  11. import type {Organization} from 'sentry/types/organization';
  12. import {trackAnalytics} from 'sentry/utils/analytics';
  13. import {getUtcDateString} from 'sentry/utils/dates';
  14. import {isAggregateField} from 'sentry/utils/discover/fields';
  15. import {
  16. FieldKind,
  17. getFieldDefinition,
  18. REPLAY_CLICK_FIELDS,
  19. REPLAY_FIELDS,
  20. } from 'sentry/utils/fields';
  21. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  22. import useApi from 'sentry/utils/useApi';
  23. import {Dataset} from 'sentry/views/alerts/rules/metric/types';
  24. const getReplayFieldDefinition = (key: string) => getFieldDefinition(key, 'replay');
  25. function fieldDefinitionsToTagCollection(fieldKeys: string[]): TagCollection {
  26. return Object.fromEntries(
  27. fieldKeys.map(key => [
  28. key,
  29. {
  30. key,
  31. name: key,
  32. ...getReplayFieldDefinition(key),
  33. },
  34. ])
  35. );
  36. }
  37. const REPLAY_FIELDS_AS_TAGS = fieldDefinitionsToTagCollection(REPLAY_FIELDS);
  38. const REPLAY_CLICK_FIELDS_AS_TAGS = fieldDefinitionsToTagCollection(REPLAY_CLICK_FIELDS);
  39. /**
  40. * Excluded from the display but still valid search queries. browser.name,
  41. * device.name, etc are effectively the same and included from REPLAY_FIELDS.
  42. * Displaying these would be redundant and confusing.
  43. */
  44. const EXCLUDED_TAGS = ['browser', 'device', 'os', 'user'];
  45. /**
  46. * Merges a list of supported tags and replay search properties
  47. * (https://docs.sentry.io/concepts/search/searchable-properties/session-replay/)
  48. * into one collection.
  49. */
  50. function getReplayFilterKeys(supportedTags: TagCollection): TagCollection {
  51. return {
  52. ...REPLAY_FIELDS_AS_TAGS,
  53. ...REPLAY_CLICK_FIELDS_AS_TAGS,
  54. ...Object.fromEntries(
  55. Object.keys(supportedTags)
  56. .filter(key => !EXCLUDED_TAGS.includes(key))
  57. .map(key => [
  58. key,
  59. {
  60. ...supportedTags[key],
  61. kind: getReplayFieldDefinition(key)?.kind ?? FieldKind.TAG,
  62. },
  63. ])
  64. ),
  65. };
  66. }
  67. const getFilterKeySections = (tags: TagCollection): FilterKeySection[] => {
  68. const customTags: Tag[] = Object.values(tags).filter(
  69. tag =>
  70. !EXCLUDED_TAGS.includes(tag.key) &&
  71. !REPLAY_FIELDS.map(String).includes(tag.key) &&
  72. !REPLAY_CLICK_FIELDS.map(String).includes(tag.key)
  73. );
  74. const orderedTagKeys = orderBy(customTags, ['totalValues', 'key'], ['desc', 'asc']).map(
  75. tag => tag.key
  76. );
  77. return [
  78. {
  79. value: 'replay_field',
  80. label: t('Suggested'),
  81. children: Object.keys(REPLAY_FIELDS_AS_TAGS),
  82. },
  83. {
  84. value: 'replay_click_field',
  85. label: t('Click Fields'),
  86. children: Object.keys(REPLAY_CLICK_FIELDS_AS_TAGS),
  87. },
  88. {
  89. value: FieldKind.TAG,
  90. label: t('Tags'),
  91. children: orderedTagKeys,
  92. },
  93. ];
  94. };
  95. type Props = React.ComponentProps<typeof SmartSearchBar> & {
  96. organization: Organization;
  97. pageFilters: PageFilters;
  98. };
  99. function ReplaySearchBar(props: Props) {
  100. const {organization, pageFilters} = props;
  101. const api = useApi();
  102. const projectIds = pageFilters.projects;
  103. const start = pageFilters.datetime.start
  104. ? getUtcDateString(pageFilters.datetime.start)
  105. : undefined;
  106. const end = pageFilters.datetime.end
  107. ? getUtcDateString(pageFilters.datetime.end)
  108. : undefined;
  109. const statsPeriod = pageFilters.datetime.period;
  110. const tagQuery = useFetchOrganizationTags(
  111. {
  112. orgSlug: organization.slug,
  113. projectIds: projectIds.map(String),
  114. dataset: Dataset.REPLAYS,
  115. useCache: true,
  116. enabled: true,
  117. keepPreviousData: false,
  118. start: start,
  119. end: end,
  120. statsPeriod: statsPeriod,
  121. },
  122. {}
  123. );
  124. const customTags: TagCollection = useMemo(() => {
  125. return (tagQuery.data ?? []).reduce<TagCollection>((acc, tag) => {
  126. acc[tag.key] = {...tag, kind: FieldKind.TAG};
  127. return acc;
  128. }, {});
  129. }, [tagQuery]);
  130. // tagQuery.isLoading and tagQuery.isError are not used
  131. const filterKeys = useMemo(() => getReplayFilterKeys(customTags), [customTags]);
  132. const filterKeySections = useMemo(() => {
  133. return getFilterKeySections(customTags);
  134. }, [customTags]);
  135. const getTagValues = useCallback(
  136. (tag: Tag, searchQuery: string): Promise<string[]> => {
  137. if (isAggregateField(tag.key)) {
  138. // We can't really auto suggest values for aggregate fields
  139. // or measurements, so we simply don't
  140. return Promise.resolve([]);
  141. }
  142. const endpointParams = {
  143. start: start,
  144. end: end,
  145. statsPeriod: statsPeriod,
  146. };
  147. return fetchTagValues({
  148. api,
  149. orgSlug: organization.slug,
  150. tagKey: tag.key,
  151. search: searchQuery,
  152. projectIds: projectIds?.map(String),
  153. endpointParams,
  154. includeReplays: true,
  155. }).then(
  156. tagValues =>
  157. (tagValues as TagValue[])
  158. .filter(tagValue => tagValue.name !== '')
  159. .map(({value}) => value),
  160. () => {
  161. throw new Error('Unable to fetch event field values');
  162. }
  163. );
  164. },
  165. [api, organization.slug, projectIds, start, end, statsPeriod]
  166. );
  167. const onSearch = props.onSearch;
  168. const onSearchWithAnalytics = useCallback(
  169. (query: string) => {
  170. onSearch?.(query);
  171. const conditions = new MutableSearch(query);
  172. const searchKeys = conditions.tokens.map(({key}) => key).filter(Boolean);
  173. if (searchKeys.length > 0) {
  174. trackAnalytics('replay.search', {
  175. search_keys: searchKeys.join(','),
  176. organization,
  177. });
  178. }
  179. },
  180. [onSearch, organization]
  181. );
  182. return (
  183. <SearchQueryBuilder
  184. {...props}
  185. onChange={undefined} // not implemented and different type from SmartSearchBar
  186. disallowLogicalOperators={undefined} // ^
  187. className={props.className}
  188. fieldDefinitionGetter={getReplayFieldDefinition}
  189. filterKeys={filterKeys}
  190. filterKeySections={filterKeySections}
  191. getTagValues={getTagValues}
  192. initialQuery={props.query ?? props.defaultQuery ?? ''}
  193. onSearch={onSearchWithAnalytics}
  194. searchSource={props.searchSource ?? 'replay_index'}
  195. placeholder={
  196. props.placeholder ??
  197. t('Search for users, duration, clicked elements, count_errors, and more')
  198. }
  199. recentSearches={SavedSearchType.REPLAY}
  200. showUnsubmittedIndicator
  201. />
  202. );
  203. }
  204. export default ReplaySearchBar;