utils.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import {useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {SelectOption, SelectSection} from 'sentry/components/compactSelect';
  4. import {BreadcrumbSort} from 'sentry/components/events/interfaces/breadcrumbs';
  5. import type {BreadcrumbMeta} from 'sentry/components/events/interfaces/breadcrumbs/types';
  6. import {
  7. convertCrumbType,
  8. getVirtualCrumb,
  9. } from 'sentry/components/events/interfaces/breadcrumbs/utils';
  10. import type {ColorConfig} from 'sentry/components/timeline';
  11. import {
  12. IconCode,
  13. IconCursorArrow,
  14. IconFire,
  15. IconFix,
  16. IconInfo,
  17. IconLocation,
  18. IconMobile,
  19. IconRefresh,
  20. IconSort,
  21. IconSpan,
  22. IconStack,
  23. IconUser,
  24. IconWarning,
  25. IconWifi,
  26. } from 'sentry/icons';
  27. import {t} from 'sentry/locale';
  28. import {space} from 'sentry/styles/space';
  29. import {
  30. BreadcrumbLevelType,
  31. BreadcrumbType,
  32. type RawCrumb,
  33. } from 'sentry/types/breadcrumbs';
  34. import {EntryType, type Event} from 'sentry/types/event';
  35. import {toTitleCase} from 'sentry/utils/string/toTitleCase';
  36. const BREADCRUMB_TITLE_PLACEHOLDER = t('Generic');
  37. const BREADCRUMB_SUMMARY_COUNT = 5;
  38. export const enum BreadcrumbTimeDisplay {
  39. RELATIVE = 'relative',
  40. ABSOLUTE = 'absolute',
  41. }
  42. export const BREADCRUMB_TIME_DISPLAY_OPTIONS = {
  43. [BreadcrumbTimeDisplay.RELATIVE]: {
  44. label: t('Relative'),
  45. value: BreadcrumbTimeDisplay.RELATIVE,
  46. },
  47. [BreadcrumbTimeDisplay.ABSOLUTE]: {
  48. label: t('Absolute'),
  49. value: BreadcrumbTimeDisplay.ABSOLUTE,
  50. },
  51. };
  52. export const BREADCRUMB_TIME_DISPLAY_LOCALSTORAGE_KEY = 'event-breadcrumb-time-display';
  53. const Color = styled('span')<{colorConfig: ColorConfig}>`
  54. color: ${p => p.theme[p.colorConfig.icon]};
  55. `;
  56. /**
  57. * Returns a summary of the provided breadcrumbs.
  58. * As of writing this, it just grabs a few, but in the future it may collapse,
  59. * or manipulate them in some way for a better summary.
  60. */
  61. export function getSummaryBreadcrumbs(
  62. crumbs: readonly EnhancedCrumb[],
  63. sort: BreadcrumbSort
  64. ) {
  65. const sortedCrumbs = sort === BreadcrumbSort.OLDEST ? crumbs : crumbs.toReversed();
  66. return sortedCrumbs.slice(0, BREADCRUMB_SUMMARY_COUNT);
  67. }
  68. export function getBreadcrumbTypeOptions(crumbs: EnhancedCrumb[]) {
  69. const uniqueCrumbTypes = crumbs.reduce((crumbTypeSet, {breadcrumb: crumb}) => {
  70. crumbTypeSet.add(crumb.type);
  71. return crumbTypeSet;
  72. }, new Set<BreadcrumbType>());
  73. const typeOptions = [...uniqueCrumbTypes].map<SelectOption<string>>(crumbType => {
  74. const crumbFilter = getBreadcrumbFilter(crumbType);
  75. return {
  76. value: crumbFilter,
  77. label: crumbFilter,
  78. leadingItems: (
  79. <Color colorConfig={getBreadcrumbColorConfig(crumbType)}>
  80. <BreadcrumbIcon type={crumbType} />
  81. </Color>
  82. ),
  83. };
  84. });
  85. return typeOptions.sort((a, b) => a.value.localeCompare(b.value));
  86. }
  87. function getBreadcrumbLevelOptions(crumbs: EnhancedCrumb[]) {
  88. const crumbLevels = crumbs.reduce(
  89. (crumbMap, ec) => {
  90. crumbMap[ec.breadcrumb.level] = ec.levelComponent;
  91. return crumbMap;
  92. },
  93. {} as Record<BreadcrumbLevelType, EnhancedCrumb['levelComponent']>
  94. );
  95. const levelOptions = Object.entries(crumbLevels).map<SelectOption<string>>(
  96. ([crumbLevel, levelComponent]) => {
  97. return {
  98. value: crumbLevel,
  99. label: levelComponent,
  100. textValue: crumbLevel,
  101. };
  102. }
  103. );
  104. return levelOptions.sort((a, b) => a.value.localeCompare(b.value));
  105. }
  106. export function useBreadcrumbFilters(crumbs: EnhancedCrumb[]) {
  107. const filterOptions = useMemo(() => {
  108. const options: SelectSection<string>[] = [];
  109. const typeOptions = getBreadcrumbTypeOptions(crumbs);
  110. if (typeOptions.length) {
  111. options.push({
  112. key: 'types',
  113. label: t('Types'),
  114. options: typeOptions.map(o => ({...o, value: `type-${o.value}`})),
  115. });
  116. }
  117. const levelOptions = getBreadcrumbLevelOptions(crumbs);
  118. if (levelOptions.length) {
  119. options.push({
  120. key: 'levels',
  121. label: t('Levels'),
  122. options: levelOptions.map(o => ({...o, value: `level-${o.value}`})),
  123. });
  124. }
  125. return options;
  126. }, [crumbs]);
  127. const applyFilters = useCallback(
  128. (crumbsToFilter: EnhancedCrumb[], options: SelectOption<string>['value'][]) => {
  129. const typeFilterSet = new Set<string>();
  130. const levelFilterSet = new Set<string>();
  131. options.forEach(optionValue => {
  132. const [indicator, value] = optionValue.split('-');
  133. if (indicator === 'type') {
  134. typeFilterSet.add(value);
  135. } else if (indicator === 'level') {
  136. levelFilterSet.add(value);
  137. }
  138. });
  139. return crumbsToFilter.filter(ec => {
  140. if (typeFilterSet.size > 0 && !typeFilterSet.has(ec.filter)) {
  141. return false;
  142. }
  143. if (levelFilterSet.size > 0 && !levelFilterSet.has(ec.breadcrumb.level)) {
  144. return false;
  145. }
  146. return true;
  147. });
  148. },
  149. []
  150. );
  151. return {filterOptions, applyFilters};
  152. }
  153. export interface EnhancedCrumb {
  154. // Mutated crumb where we change types or virtual crumb
  155. breadcrumb: RawCrumb;
  156. colorConfig: ReturnType<typeof getBreadcrumbColorConfig>;
  157. filter: ReturnType<typeof getBreadcrumbFilter>;
  158. iconComponent: ReturnType<typeof BreadcrumbIcon>;
  159. levelComponent: ReturnType<typeof BreadcrumbLevel>;
  160. // Display props
  161. title: ReturnType<typeof getBreadcrumbTitle>;
  162. meta?: BreadcrumbMeta;
  163. // Exact crumb extracted from the event. If raw is missing, crumb is virtual.
  164. raw?: RawCrumb;
  165. }
  166. /**
  167. * This is necessary to keep breadcrumbs with their associated meta annotations. The meta object for
  168. * crumbs on the event uses an array index, but in practice we append to the list (with virtual crumbs),
  169. * change the sort, and filter it. To avoid having to mutate the meta indeces, keep them together from the start.
  170. *
  171. * Display props are also added to reduce repeated iterations.
  172. */
  173. export function getEnhancedBreadcrumbs(event: Event): EnhancedCrumb[] {
  174. const breadcrumbEntryIndex =
  175. event.entries?.findIndex(entry => entry.type === EntryType.BREADCRUMBS) ?? -1;
  176. const breadcrumbs: any[] = event.entries?.[breadcrumbEntryIndex]?.data?.values ?? [];
  177. if (breadcrumbs.length === 0) {
  178. return [];
  179. }
  180. // Mapping of breadcrumb index -> breadcrumb meta
  181. const meta: Record<number, any> =
  182. event._meta?.entries?.[breadcrumbEntryIndex]?.data?.values ?? {};
  183. const enhancedCrumbs = breadcrumbs.map<
  184. Pick<EnhancedCrumb, 'raw' | 'meta' | 'breadcrumb'>
  185. >((raw, i) => ({
  186. raw,
  187. meta: meta[i],
  188. // Converts breadcrumbs into other types if sufficient data is present.
  189. breadcrumb: convertCrumbType(raw),
  190. }));
  191. // The virtual crumb is a representation of this event, displayed alongside
  192. // the rest of the breadcrumbs for more additional context.
  193. const virtualCrumb = getVirtualCrumb(event);
  194. const allCrumbs = virtualCrumb
  195. ? [...enhancedCrumbs, {breadcrumb: virtualCrumb}]
  196. : enhancedCrumbs;
  197. // Add display props
  198. return allCrumbs.map<EnhancedCrumb>(ec => ({
  199. ...ec,
  200. title: getBreadcrumbTitle(ec.breadcrumb),
  201. colorConfig: getBreadcrumbColorConfig(ec.breadcrumb.type),
  202. filter: getBreadcrumbFilter(ec.breadcrumb.type),
  203. iconComponent: <BreadcrumbIcon type={ec.breadcrumb.type} />,
  204. levelComponent: (
  205. <BreadcrumbLevel level={ec.breadcrumb.level}>{ec.breadcrumb.level}</BreadcrumbLevel>
  206. ),
  207. }));
  208. }
  209. export function getBreadcrumbTitle(crumb: RawCrumb) {
  210. if (crumb?.type === BreadcrumbType.DEFAULT) {
  211. return crumb?.category ?? BREADCRUMB_TITLE_PLACEHOLDER.toLocaleLowerCase();
  212. }
  213. switch (crumb?.category) {
  214. case 'http':
  215. case 'xhr':
  216. return crumb?.category.toUpperCase();
  217. case 'ui.click':
  218. return t('UI Click');
  219. case 'ui.input':
  220. return t('UI Input');
  221. case null:
  222. case undefined:
  223. return BREADCRUMB_TITLE_PLACEHOLDER.toLocaleLowerCase();
  224. default:
  225. const titleCategory = crumb?.category.split('.').join(' ');
  226. return toTitleCase(titleCategory, {allowInnerUpperCase: true});
  227. }
  228. }
  229. export function getBreadcrumbColorConfig(type?: BreadcrumbType): ColorConfig {
  230. switch (type) {
  231. case BreadcrumbType.ERROR:
  232. return {title: 'red400', icon: 'red400', iconBorder: 'red200'};
  233. case BreadcrumbType.WARNING:
  234. return {title: 'yellow400', icon: 'yellow400', iconBorder: 'yellow200'};
  235. case BreadcrumbType.NAVIGATION:
  236. case BreadcrumbType.HTTP:
  237. case BreadcrumbType.QUERY:
  238. case BreadcrumbType.TRANSACTION:
  239. return {title: 'green400', icon: 'green400', iconBorder: 'green200'};
  240. case BreadcrumbType.USER:
  241. case BreadcrumbType.UI:
  242. return {title: 'purple400', icon: 'purple400', iconBorder: 'purple200'};
  243. case BreadcrumbType.SYSTEM:
  244. case BreadcrumbType.SESSION:
  245. case BreadcrumbType.DEVICE:
  246. case BreadcrumbType.NETWORK:
  247. return {title: 'pink400', icon: 'pink400', iconBorder: 'pink200'};
  248. case BreadcrumbType.INFO:
  249. return {title: 'blue400', icon: 'blue300', iconBorder: 'blue200'};
  250. case BreadcrumbType.DEBUG:
  251. default:
  252. return {title: 'gray400', icon: 'gray300', iconBorder: 'gray200'};
  253. }
  254. }
  255. export function getBreadcrumbFilter(type?: BreadcrumbType) {
  256. switch (type) {
  257. case BreadcrumbType.USER:
  258. case BreadcrumbType.UI:
  259. return t('User Action');
  260. case BreadcrumbType.NAVIGATION:
  261. return t('Navigation');
  262. case BreadcrumbType.DEBUG:
  263. return t('Debug');
  264. case BreadcrumbType.INFO:
  265. return t('Info');
  266. case BreadcrumbType.ERROR:
  267. return t('Error');
  268. case BreadcrumbType.HTTP:
  269. return t('HTTP Request');
  270. case BreadcrumbType.WARNING:
  271. return t('Warning');
  272. case BreadcrumbType.QUERY:
  273. return t('Query');
  274. case BreadcrumbType.SYSTEM:
  275. return t('System');
  276. case BreadcrumbType.SESSION:
  277. return t('Session');
  278. case BreadcrumbType.TRANSACTION:
  279. return t('Transaction');
  280. case BreadcrumbType.DEVICE:
  281. return t('Device');
  282. case BreadcrumbType.NETWORK:
  283. return t('Network');
  284. default:
  285. return BREADCRUMB_TITLE_PLACEHOLDER;
  286. }
  287. }
  288. export function BreadcrumbIcon({type}: {type?: BreadcrumbType}) {
  289. switch (type) {
  290. case BreadcrumbType.USER:
  291. return <IconUser size="xs" />;
  292. case BreadcrumbType.UI:
  293. return <IconCursorArrow size="xs" />;
  294. case BreadcrumbType.NAVIGATION:
  295. return <IconLocation size="xs" />;
  296. case BreadcrumbType.DEBUG:
  297. return <IconFix size="xs" />;
  298. case BreadcrumbType.INFO:
  299. return <IconInfo size="xs" />;
  300. case BreadcrumbType.ERROR:
  301. return <IconFire size="xs" />;
  302. case BreadcrumbType.HTTP:
  303. return <IconSort size="xs" rotated />;
  304. case BreadcrumbType.WARNING:
  305. return <IconWarning size="xs" />;
  306. case BreadcrumbType.QUERY:
  307. return <IconStack size="xs" />;
  308. case BreadcrumbType.SYSTEM:
  309. return <IconMobile size="xs" />;
  310. case BreadcrumbType.SESSION:
  311. return <IconRefresh size="xs" />;
  312. case BreadcrumbType.TRANSACTION:
  313. return <IconSpan size="xs" />;
  314. case BreadcrumbType.DEVICE:
  315. return <IconMobile size="xs" />;
  316. case BreadcrumbType.NETWORK:
  317. return <IconWifi size="xs" />;
  318. default:
  319. return <IconCode size="xs" />;
  320. }
  321. }
  322. export const BreadcrumbLevel = styled('div')<{level: BreadcrumbLevelType}>`
  323. margin: 0 ${space(1)};
  324. font-weight: normal;
  325. font-size: ${p => p.theme.fontSizeSmall};
  326. border: 0;
  327. background: none;
  328. color: ${p => {
  329. switch (p.level) {
  330. case BreadcrumbLevelType.ERROR:
  331. case BreadcrumbLevelType.FATAL:
  332. return p.theme.red400;
  333. case BreadcrumbLevelType.WARNING:
  334. return p.theme.yellow400;
  335. default:
  336. case BreadcrumbLevelType.DEBUG:
  337. case BreadcrumbLevelType.INFO:
  338. case BreadcrumbLevelType.LOG:
  339. return p.theme.gray300;
  340. }
  341. }};
  342. display: ${p => (p.level === BreadcrumbLevelType.UNDEFINED ? 'none' : 'block')};
  343. `;