getFrameDetails.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import {Fragment, ReactNode} from 'react';
  2. import FeatureBadge from 'sentry/components/featureBadge';
  3. import ExternalLink from 'sentry/components/links/externalLink';
  4. import {Tooltip} from 'sentry/components/tooltip';
  5. import {
  6. IconCursorArrow,
  7. IconFire,
  8. IconFix,
  9. IconInfo,
  10. IconInput,
  11. IconKeyDown,
  12. IconLocation,
  13. IconSort,
  14. IconTerminal,
  15. IconUser,
  16. IconWarning,
  17. } from 'sentry/icons';
  18. import {t, tct} from 'sentry/locale';
  19. import {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
  20. import type {
  21. BreadcrumbFrame,
  22. ErrorFrame,
  23. LargestContentfulPaintFrame,
  24. MultiClickFrame,
  25. MutationFrame,
  26. NavFrame,
  27. ReplayFrame,
  28. SlowClickFrame,
  29. } from 'sentry/utils/replays/types';
  30. import {
  31. getFrameOpOrCategory,
  32. isDeadClick,
  33. isDeadRageClick,
  34. isRageClick,
  35. } from 'sentry/utils/replays/types';
  36. import type {Color} from 'sentry/utils/theme';
  37. import stripOrigin from 'sentry/utils/url/stripOrigin';
  38. interface Details {
  39. color: Color;
  40. description: ReactNode;
  41. icon: ReactNode;
  42. tabKey: TabKey;
  43. title: ReactNode;
  44. }
  45. const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
  46. 'replay.init': (frame: BreadcrumbFrame) => ({
  47. color: 'gray300',
  48. description: stripOrigin(frame.message ?? ''),
  49. tabKey: TabKey.CONSOLE,
  50. title: 'Replay Start',
  51. icon: <IconTerminal size="xs" />,
  52. }),
  53. navigation: (frame: NavFrame) => ({
  54. color: 'green300',
  55. description: stripOrigin((frame as NavFrame).data.to),
  56. tabKey: TabKey.NETWORK,
  57. title: 'Navigation',
  58. icon: <IconLocation size="xs" />,
  59. }),
  60. issue: (frame: ErrorFrame) => ({
  61. color: 'red300',
  62. description: frame.message,
  63. tabKey: TabKey.ERRORS,
  64. title: defaultTitle(frame),
  65. icon: <IconFire size="xs" />,
  66. }),
  67. 'ui.slowClickDetected': (frame: SlowClickFrame) => {
  68. const node = frame.data.node;
  69. if (isDeadClick(frame)) {
  70. return {
  71. color: isDeadRageClick(frame) ? 'red300' : 'yellow300',
  72. description: tct(
  73. 'Click on [selector] did not cause a visible effect within [timeout] ms',
  74. {
  75. selector: stringifyNodeAttributes(node),
  76. timeout: Math.round(frame.data.timeAfterClickMs),
  77. }
  78. ),
  79. icon: <IconCursorArrow size="xs" />,
  80. title: isDeadRageClick(frame) ? 'Rage Click' : 'Dead Click',
  81. tabKey: TabKey.BREADCRUMBS,
  82. };
  83. }
  84. return {
  85. color: 'yellow300',
  86. description: tct(
  87. 'Click on [selector] took [duration] ms to have a visible effect',
  88. {
  89. selector: stringifyNodeAttributes(node),
  90. duration: Math.round(frame.data.timeAfterClickMs),
  91. }
  92. ),
  93. icon: <IconWarning size="xs" />,
  94. title: 'Slow Click',
  95. tabKey: TabKey.BREADCRUMBS,
  96. };
  97. },
  98. 'ui.multiClick': (frame: MultiClickFrame) => {
  99. if (isRageClick(frame)) {
  100. return {
  101. color: 'red300',
  102. description: tct('Rage clicked [clickCount] times on [selector]', {
  103. clickCount: frame.data.clickCount,
  104. selector: stringifyNodeAttributes(frame.data.node),
  105. }),
  106. tabKey: TabKey.BREADCRUMBS,
  107. title: 'Rage Click',
  108. icon: <IconFire size="xs" />,
  109. };
  110. }
  111. return {
  112. color: 'yellow300',
  113. description: tct('[clickCount] clicks on [selector]', {
  114. clickCount: frame.data.clickCount,
  115. selector: stringifyNodeAttributes(frame.data.node),
  116. }),
  117. tabKey: TabKey.BREADCRUMBS,
  118. title: 'Multi Click',
  119. icon: <IconWarning size="xs" />,
  120. };
  121. },
  122. 'replay.mutations': (frame: MutationFrame) => ({
  123. color: 'yellow300',
  124. description: frame.data.limit
  125. ? tct(
  126. 'A large number of mutations was detected [count]. Replay is now stopped to prevent poor performance for your customer. [link]',
  127. {
  128. count: frame.data.count,
  129. link: (
  130. <ExternalLink href="https://docs.sentry.io/platforms/javascript/session-replay/configuration/#mutation-limits">
  131. {t('Learn more.')}
  132. </ExternalLink>
  133. ),
  134. }
  135. )
  136. : tct(
  137. 'A large number of mutations was detected [count]. This can slow down the Replay SDK and impact your customers. [link]',
  138. {
  139. count: frame.data.count,
  140. link: (
  141. <ExternalLink href="https://docs.sentry.io/platforms/javascript/session-replay/configuration/#mutation-limits">
  142. {t('Learn more.')}
  143. </ExternalLink>
  144. ),
  145. }
  146. ),
  147. tabKey: TabKey.BREADCRUMBS,
  148. title: 'Replay',
  149. icon: <IconWarning size="xs" />,
  150. }),
  151. 'replay.hydrate-error': () => ({
  152. color: 'red300',
  153. description: t(
  154. 'There was a conflict between the server rendered html and the first client render.'
  155. ),
  156. tabKey: TabKey.BREADCRUMBS,
  157. title: (
  158. <Fragment>
  159. Hydration Error <FeatureBadge type="beta" />
  160. </Fragment>
  161. ),
  162. icon: <IconFire size="xs" />,
  163. }),
  164. 'ui.click': frame => ({
  165. color: 'purple300',
  166. description: frame.message ?? '',
  167. tabKey: TabKey.BREADCRUMBS,
  168. title: 'User Click',
  169. icon: <IconCursorArrow size="xs" />,
  170. }),
  171. 'ui.input': () => ({
  172. color: 'purple300',
  173. description: 'User Action',
  174. tabKey: TabKey.BREADCRUMBS,
  175. title: 'User Input',
  176. icon: <IconInput size="xs" />,
  177. }),
  178. 'ui.keyDown': () => ({
  179. color: 'purple300',
  180. description: 'User Action',
  181. tabKey: TabKey.BREADCRUMBS,
  182. title: 'User KeyDown',
  183. icon: <IconKeyDown size="xs" />,
  184. }),
  185. 'ui.blur': () => ({
  186. color: 'purple300',
  187. description: 'User Action',
  188. tabKey: TabKey.BREADCRUMBS,
  189. title: 'User Blur',
  190. icon: <IconUser size="xs" />,
  191. }),
  192. 'ui.focus': () => ({
  193. color: 'purple300',
  194. description: 'User Action',
  195. tabKey: TabKey.BREADCRUMBS,
  196. title: 'User Focus',
  197. icon: <IconUser size="xs" />,
  198. }),
  199. console: frame => ({
  200. color: 'gray300',
  201. description: frame.message ?? '',
  202. tabKey: TabKey.CONSOLE,
  203. title: 'Console',
  204. icon: <IconFix size="xs" />,
  205. }),
  206. 'navigation.navigate': frame => ({
  207. color: 'green300',
  208. description: stripOrigin(frame.description),
  209. tabKey: TabKey.NETWORK,
  210. title: 'Page Load',
  211. icon: <IconLocation size="xs" />,
  212. }),
  213. 'navigation.reload': frame => ({
  214. color: 'green300',
  215. description: stripOrigin(frame.description),
  216. tabKey: TabKey.NETWORK,
  217. title: 'Reload',
  218. icon: <IconLocation size="xs" />,
  219. }),
  220. 'navigation.back_forward': frame => ({
  221. color: 'green300',
  222. description: stripOrigin(frame.description),
  223. tabKey: TabKey.NETWORK,
  224. title: 'Navigate Back/Forward',
  225. icon: <IconLocation size="xs" />,
  226. }),
  227. 'navigation.push': frame => ({
  228. color: 'green300',
  229. description: stripOrigin(frame.description),
  230. tabKey: TabKey.NETWORK,
  231. title: 'Navigation',
  232. icon: <IconLocation size="xs" />,
  233. }),
  234. 'largest-contentful-paint': (frame: LargestContentfulPaintFrame) => ({
  235. color: 'gray300',
  236. description:
  237. typeof frame.data.value === 'number' ? (
  238. `${Math.round(frame.data.value)}ms`
  239. ) : (
  240. <Tooltip
  241. title={t(
  242. 'This replay uses a SDK version that is subject to inaccurate LCP values. Please upgrade to the latest version for best results if you have not already done so.'
  243. )}
  244. >
  245. <IconWarning />
  246. </Tooltip>
  247. ),
  248. tabKey: TabKey.NETWORK,
  249. title: 'LCP',
  250. icon: <IconInfo size="xs" />,
  251. }),
  252. memory: () => ({
  253. color: 'gray300',
  254. description: undefined,
  255. tabKey: TabKey.MEMORY,
  256. title: 'Memory',
  257. icon: <IconInfo size="xs" />,
  258. }),
  259. paint: () => ({
  260. color: 'gray300',
  261. description: undefined,
  262. tabKey: TabKey.NETWORK,
  263. title: 'Paint',
  264. icon: <IconInfo size="xs" />,
  265. }),
  266. 'resource.css': frame => ({
  267. color: 'gray300',
  268. description: undefined,
  269. tabKey: TabKey.NETWORK,
  270. title: frame.description,
  271. icon: <IconSort size="xs" rotated />,
  272. }),
  273. 'resource.fetch': frame => ({
  274. color: 'gray300',
  275. description: undefined,
  276. tabKey: TabKey.NETWORK,
  277. title: frame.description,
  278. icon: <IconSort size="xs" rotated />,
  279. }),
  280. 'resource.iframe': frame => ({
  281. color: 'gray300',
  282. description: undefined,
  283. tabKey: TabKey.NETWORK,
  284. title: frame.description,
  285. icon: <IconSort size="xs" rotated />,
  286. }),
  287. 'resource.img': frame => ({
  288. color: 'gray300',
  289. description: undefined,
  290. tabKey: TabKey.NETWORK,
  291. title: frame.description,
  292. icon: <IconSort size="xs" rotated />,
  293. }),
  294. 'resource.link': frame => ({
  295. color: 'gray300',
  296. description: undefined,
  297. tabKey: TabKey.NETWORK,
  298. title: frame.description,
  299. icon: <IconSort size="xs" rotated />,
  300. }),
  301. 'resource.other': frame => ({
  302. color: 'gray300',
  303. description: undefined,
  304. tabKey: TabKey.NETWORK,
  305. title: frame.description,
  306. icon: <IconSort size="xs" rotated />,
  307. }),
  308. 'resource.script': frame => ({
  309. color: 'gray300',
  310. description: undefined,
  311. tabKey: TabKey.NETWORK,
  312. title: frame.description,
  313. icon: <IconSort size="xs" rotated />,
  314. }),
  315. 'resource.xhr': frame => ({
  316. color: 'gray300',
  317. description: undefined,
  318. tabKey: TabKey.NETWORK,
  319. title: frame.description,
  320. icon: <IconSort size="xs" rotated />,
  321. }),
  322. };
  323. const MAPPER_DEFAULT = (frame): Details => ({
  324. color: 'gray300',
  325. description: frame.message ?? '',
  326. tabKey: TabKey.CONSOLE,
  327. title: defaultTitle(frame),
  328. icon: <IconTerminal size="xs" />,
  329. });
  330. export default function getFrameDetails(frame: ReplayFrame): Details {
  331. const key = getFrameOpOrCategory(frame);
  332. const fn = MAPPER_FOR_FRAME[key] ?? MAPPER_DEFAULT;
  333. try {
  334. return fn(frame);
  335. } catch (error) {
  336. return MAPPER_DEFAULT(frame);
  337. }
  338. }
  339. function defaultTitle(frame: ReplayFrame) {
  340. if ('category' in frame) {
  341. const [type, action] = frame.category.split('.');
  342. return `${type} ${action || ''}`.trim();
  343. }
  344. if ('message' in frame) {
  345. return frame.message as string; // TODO(replay): Included for backwards compat
  346. }
  347. return frame.description ?? '';
  348. }
  349. function stringifyNodeAttributes(node: SlowClickFrame['data']['node']) {
  350. const {tagName, attributes} = node ?? {};
  351. const attributesEntries = Object.entries(attributes ?? {});
  352. return `${tagName}${
  353. attributesEntries.length
  354. ? attributesEntries.map(([attr, val]) => `[${attr}="${val}"]`).join('')
  355. : ''
  356. }`;
  357. }