index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import {Fragment, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import omit from 'lodash/omit';
  4. import pick from 'lodash/pick';
  5. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  6. import {Button} from 'sentry/components/button';
  7. import {
  8. CompactSelect,
  9. SelectOption,
  10. SelectSection,
  11. } from 'sentry/components/compactSelect';
  12. import ErrorBoundary from 'sentry/components/errorBoundary';
  13. import {EventDataSection} from 'sentry/components/events/eventDataSection';
  14. import EventReplay from 'sentry/components/events/eventReplay';
  15. import {BreadcrumbWithMeta} from 'sentry/components/events/interfaces/breadcrumbs/types';
  16. import {IconSort} from 'sentry/icons';
  17. import {t} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import {Organization} from 'sentry/types';
  20. import {BreadcrumbLevelType, RawCrumb} from 'sentry/types/breadcrumbs';
  21. import {EntryType, Event} from 'sentry/types/event';
  22. import {defined} from 'sentry/utils';
  23. import {getReplayIdFromEvent} from 'sentry/utils/replays/getReplayIdFromEvent';
  24. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  25. import SearchBarAction from '../searchBarAction';
  26. import Level from './breadcrumb/level';
  27. import Type from './breadcrumb/type';
  28. import Breadcrumbs from './breadcrumbs';
  29. import {getVirtualCrumb, transformCrumbs} from './utils';
  30. type SelectOptionWithLevels = SelectOption<string> & {levels?: BreadcrumbLevelType[]};
  31. type Props = {
  32. data: {
  33. values: Array<RawCrumb>;
  34. };
  35. event: Event;
  36. organization: Organization;
  37. projectSlug: string;
  38. isShare?: boolean;
  39. };
  40. enum BreadcrumbSort {
  41. NEWEST = 'newest',
  42. OLDEST = 'oldest',
  43. }
  44. const EVENT_BREADCRUMB_SORT_LOCALSTORAGE_KEY = 'event-breadcrumb-sort';
  45. const sortOptions = [
  46. {label: t('Newest'), value: BreadcrumbSort.NEWEST},
  47. {label: t('Oldest'), value: BreadcrumbSort.OLDEST},
  48. ];
  49. function BreadcrumbsContainer({data, event, organization, projectSlug, isShare}: Props) {
  50. const [searchTerm, setSearchTerm] = useState('');
  51. const [filterSelections, setFilterSelections] = useState<SelectOption<string>[]>([]);
  52. const [displayRelativeTime, setDisplayRelativeTime] = useState(false);
  53. const [sort, setSort] = useLocalStorageState<BreadcrumbSort>(
  54. EVENT_BREADCRUMB_SORT_LOCALSTORAGE_KEY,
  55. BreadcrumbSort.NEWEST
  56. );
  57. const entryIndex = event.entries.findIndex(
  58. entry => entry.type === EntryType.BREADCRUMBS
  59. );
  60. const initialBreadcrumbs = useMemo(() => {
  61. let crumbs = data.values;
  62. // Add the (virtual) breadcrumb based on the error or message event if possible.
  63. const virtualCrumb = getVirtualCrumb(event);
  64. if (virtualCrumb) {
  65. crumbs = [...crumbs, virtualCrumb];
  66. }
  67. return transformCrumbs(crumbs);
  68. }, [data, event]);
  69. const relativeTime = useMemo(() => {
  70. return initialBreadcrumbs[initialBreadcrumbs.length - 1]?.timestamp ?? '';
  71. }, [initialBreadcrumbs]);
  72. const filterOptions = useMemo(() => {
  73. const typeOptions = getFilterTypes(initialBreadcrumbs);
  74. const levels = getFilterLevels(typeOptions);
  75. const options: SelectSection<string>[] = [];
  76. if (typeOptions.length) {
  77. options.push({
  78. key: 'types',
  79. label: t('Types'),
  80. options: typeOptions.map(typeOption => omit(typeOption, 'levels')),
  81. });
  82. }
  83. if (levels.length) {
  84. options.push({
  85. key: 'levels',
  86. label: t('Levels'),
  87. options: levels,
  88. });
  89. }
  90. return options;
  91. }, [initialBreadcrumbs]);
  92. function getFilterTypes(crumbs: ReturnType<typeof transformCrumbs>) {
  93. const filterTypes: SelectOptionWithLevels[] = [];
  94. for (const index in crumbs) {
  95. const breadcrumb = crumbs[index];
  96. const foundFilterType = filterTypes.findIndex(
  97. f => f.value === `type-${breadcrumb.type}`
  98. );
  99. if (foundFilterType === -1) {
  100. filterTypes.push({
  101. value: `type-${breadcrumb.type}`,
  102. leadingItems: <Type type={breadcrumb.type} color={breadcrumb.color} />,
  103. label: breadcrumb.description,
  104. levels: breadcrumb?.level ? [breadcrumb.level] : [],
  105. });
  106. continue;
  107. }
  108. if (
  109. breadcrumb?.level &&
  110. !filterTypes[foundFilterType].levels?.includes(breadcrumb.level)
  111. ) {
  112. filterTypes[foundFilterType].levels?.push(breadcrumb.level);
  113. }
  114. }
  115. return filterTypes;
  116. }
  117. function getFilterLevels(types: SelectOptionWithLevels[]) {
  118. const filterLevels: SelectOption<string>[] = [];
  119. for (const indexType in types) {
  120. for (const indexLevel in types[indexType].levels) {
  121. const level = types[indexType].levels?.[indexLevel];
  122. if (filterLevels.some(f => f.value === `level-${level}`)) {
  123. continue;
  124. }
  125. filterLevels.push({
  126. value: `level-${level}`,
  127. textValue: level,
  128. label: (
  129. <LevelWrap>
  130. <Level level={level} />
  131. </LevelWrap>
  132. ),
  133. });
  134. }
  135. }
  136. return filterLevels;
  137. }
  138. function applySearchTerm(breadcrumbs: BreadcrumbWithMeta[], newSearchTerm: string) {
  139. if (!newSearchTerm.trim()) {
  140. return breadcrumbs;
  141. }
  142. // Slightly hacky, but it works
  143. // the string is being `stringify`d here in order to match exactly the same `stringify`d string of the loop
  144. const searchFor = JSON.stringify(newSearchTerm)
  145. // it replaces double backslash generate by JSON.stringify with single backslash
  146. .replace(/((^")|("$))/g, '')
  147. .toLocaleLowerCase();
  148. return breadcrumbs.filter(({breadcrumb}) =>
  149. Object.keys(
  150. pick(breadcrumb, ['type', 'category', 'message', 'level', 'timestamp', 'data'])
  151. ).some(key => {
  152. const info = breadcrumb[key];
  153. if (!defined(info) || !String(info).trim()) {
  154. return false;
  155. }
  156. return JSON.stringify(info)
  157. .replace(/((^")|("$))/g, '')
  158. .toLocaleLowerCase()
  159. .trim()
  160. .includes(searchFor);
  161. })
  162. );
  163. }
  164. function applySelectedFilters(
  165. breadcrumbs: BreadcrumbWithMeta[],
  166. selectedFilterOptions: SelectOption<string>[]
  167. ) {
  168. const checkedTypeOptions = new Set(
  169. selectedFilterOptions
  170. .filter(option => option.value.startsWith('type-'))
  171. .map(option => option.value.split('-')[1])
  172. );
  173. const checkedLevelOptions = new Set(
  174. selectedFilterOptions
  175. .filter(option => option.value.startsWith('level-'))
  176. .map(option => option.value.split('-')[1])
  177. );
  178. if (!![...checkedTypeOptions].length && !![...checkedLevelOptions].length) {
  179. return breadcrumbs.filter(
  180. ({breadcrumb}) =>
  181. checkedTypeOptions.has(breadcrumb.type) &&
  182. checkedLevelOptions.has(breadcrumb.level)
  183. );
  184. }
  185. if ([...checkedTypeOptions].length) {
  186. return breadcrumbs.filter(({breadcrumb}) =>
  187. checkedTypeOptions.has(breadcrumb.type)
  188. );
  189. }
  190. if ([...checkedLevelOptions].length) {
  191. return breadcrumbs.filter(({breadcrumb}) =>
  192. checkedLevelOptions.has(breadcrumb.level)
  193. );
  194. }
  195. return breadcrumbs;
  196. }
  197. const displayedBreadcrumbs = useMemo(() => {
  198. const breadcrumbsWithMeta = initialBreadcrumbs.map((breadcrumb, index) => ({
  199. breadcrumb,
  200. meta: event._meta?.entries?.[entryIndex]?.data?.values?.[index],
  201. }));
  202. const filteredBreadcrumbs = applySearchTerm(
  203. applySelectedFilters(breadcrumbsWithMeta, filterSelections),
  204. searchTerm
  205. );
  206. // Breadcrumbs come back from API sorted oldest -> newest.
  207. // Need to `reverse()` instead of sort by timestamp because crumbs with
  208. // exact same timestamp will appear out of order.
  209. return sort === BreadcrumbSort.NEWEST
  210. ? [...filteredBreadcrumbs].reverse()
  211. : filteredBreadcrumbs;
  212. }, [
  213. entryIndex,
  214. event._meta?.entries,
  215. filterSelections,
  216. initialBreadcrumbs,
  217. searchTerm,
  218. sort,
  219. ]);
  220. function getEmptyMessage() {
  221. if (displayedBreadcrumbs.length) {
  222. return {};
  223. }
  224. if (searchTerm && !displayedBreadcrumbs.length) {
  225. const hasActiveFilter = filterSelections.length > 0;
  226. return {
  227. emptyMessage: t('Sorry, no breadcrumbs match your search query'),
  228. emptyAction: hasActiveFilter ? (
  229. <Button onClick={() => setFilterSelections([])} priority="primary">
  230. {t('Reset filter')}
  231. </Button>
  232. ) : (
  233. <Button onClick={() => setSearchTerm('')} priority="primary">
  234. {t('Clear search bar')}
  235. </Button>
  236. ),
  237. };
  238. }
  239. return {
  240. emptyMessage: t('There are no breadcrumbs to be displayed'),
  241. };
  242. }
  243. const replayId = getReplayIdFromEvent(event);
  244. const showReplay = !isShare && organization.features.includes('session-replay');
  245. const actions = (
  246. <SearchAndSortWrapper isFullWidth={showReplay}>
  247. <SearchBarAction
  248. placeholder={t('Search breadcrumbs')}
  249. onChange={setSearchTerm}
  250. query={searchTerm}
  251. filterOptions={filterOptions}
  252. filterSelections={filterSelections}
  253. onFilterChange={setFilterSelections}
  254. />
  255. <CompactSelect
  256. triggerProps={{
  257. icon: <IconSort />,
  258. size: 'sm',
  259. }}
  260. onChange={selectedOption => {
  261. setSort(selectedOption.value);
  262. }}
  263. value={sort}
  264. options={sortOptions}
  265. />
  266. </SearchAndSortWrapper>
  267. );
  268. return (
  269. <EventDataSection
  270. type={EntryType.BREADCRUMBS}
  271. title={t('Breadcrumbs')}
  272. actions={!showReplay ? actions : null}
  273. >
  274. {showReplay ? (
  275. <Fragment>
  276. <EventReplay
  277. organization={organization}
  278. replayId={replayId}
  279. projectSlug={projectSlug}
  280. event={event}
  281. />
  282. {actions}
  283. </Fragment>
  284. ) : null}
  285. <ErrorBoundary>
  286. <GuideAnchor target="breadcrumbs" position="bottom">
  287. <Breadcrumbs
  288. emptyMessage={getEmptyMessage()}
  289. breadcrumbs={displayedBreadcrumbs}
  290. event={event}
  291. organization={organization}
  292. onSwitchTimeFormat={() => setDisplayRelativeTime(old => !old)}
  293. displayRelativeTime={displayRelativeTime}
  294. searchTerm={searchTerm}
  295. relativeTime={relativeTime}
  296. />
  297. </GuideAnchor>
  298. </ErrorBoundary>
  299. </EventDataSection>
  300. );
  301. }
  302. export {BreadcrumbsContainer as Breadcrumbs};
  303. const SearchAndSortWrapper = styled('div')<{isFullWidth?: boolean}>`
  304. display: grid;
  305. grid-template-columns: 1fr auto;
  306. gap: ${space(1)};
  307. @media (max-width: ${p => p.theme.breakpoints.small}) {
  308. grid-template-columns: 1fr;
  309. }
  310. margin-bottom: ${p => (p.isFullWidth ? space(1) : 0)};
  311. `;
  312. const LevelWrap = styled('span')`
  313. height: ${p => p.theme.text.lineHeightBody}em;
  314. display: flex;
  315. align-items: center;
  316. `;