getFrameDetails.tsx 11 KB

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