getFrameDetails.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. import {Fragment, type ReactNode} from 'react';
  2. import styled from '@emotion/styled';
  3. import ExternalLink from 'sentry/components/links/externalLink';
  4. import QuestionTooltip from 'sentry/components/questionTooltip';
  5. import CrumbErrorTitle from 'sentry/components/replays/breadcrumbs/errorTitle';
  6. import SelectorList from 'sentry/components/replays/breadcrumbs/selectorList';
  7. import {
  8. IconCursorArrow,
  9. IconFire,
  10. IconFix,
  11. IconFocus,
  12. IconHappy,
  13. IconInfo,
  14. IconInput,
  15. IconKeyDown,
  16. IconLightning,
  17. IconLocation,
  18. IconMegaphone,
  19. IconMeh,
  20. IconRefresh,
  21. IconSad,
  22. IconSort,
  23. IconTap,
  24. IconTerminal,
  25. IconWarning,
  26. IconWifi,
  27. } from 'sentry/icons';
  28. import {t, tct} from 'sentry/locale';
  29. import {space} from 'sentry/styles/space';
  30. import {explodeSlug} from 'sentry/utils';
  31. import {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
  32. import type {
  33. BreadcrumbFrame,
  34. DeviceBatteryFrame,
  35. DeviceConnectivityFrame,
  36. DeviceOrientationFrame,
  37. ErrorFrame,
  38. FeedbackFrame,
  39. MultiClickFrame,
  40. MutationFrame,
  41. NavFrame,
  42. RawBreadcrumbFrame,
  43. ReplayFrame,
  44. SlowClickFrame,
  45. TapFrame,
  46. WebVitalFrame,
  47. } from 'sentry/utils/replays/types';
  48. import {
  49. getFrameOpOrCategory,
  50. isCLSFrame,
  51. isDeadClick,
  52. isDeadRageClick,
  53. isRageClick,
  54. } from 'sentry/utils/replays/types';
  55. import {toTitleCase} from 'sentry/utils/string/toTitleCase';
  56. import type {Color} from 'sentry/utils/theme';
  57. import stripURLOrigin from 'sentry/utils/url/stripURLOrigin';
  58. import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings';
  59. interface Details {
  60. color: Color;
  61. description: ReactNode;
  62. icon: ReactNode;
  63. tabKey: TabKey;
  64. title: ReactNode;
  65. }
  66. const DEVICE_CONNECTIVITY_MESSAGE: Record<string, string> = {
  67. wifi: t('Device connected to wifi'),
  68. offline: t('Internet connection was lost'),
  69. cellular: t('Device connected to cellular network'),
  70. ethernet: t('Device connected to ethernet'),
  71. };
  72. const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
  73. 'replay.init': (frame: BreadcrumbFrame) => ({
  74. color: 'gray300',
  75. description: stripURLOrigin(frame.message ?? ''),
  76. tabKey: TabKey.CONSOLE,
  77. title: 'Replay Start',
  78. icon: <IconInfo size="xs" />,
  79. }),
  80. navigation: (frame: NavFrame) => ({
  81. color: 'green300',
  82. description: stripURLOrigin((frame as NavFrame).data.to),
  83. tabKey: TabKey.NETWORK,
  84. title: 'Navigation',
  85. icon: <IconLocation size="xs" />,
  86. }),
  87. feedback: (frame: FeedbackFrame) => ({
  88. color: 'purple300',
  89. description: frame.data.projectSlug,
  90. tabKey: TabKey.BREADCRUMBS,
  91. title: defaultTitle(frame),
  92. icon: <IconMegaphone size="xs" />,
  93. }),
  94. issue: (frame: ErrorFrame) => ({
  95. color: 'red300',
  96. description: frame.message,
  97. tabKey: TabKey.ERRORS,
  98. title: <CrumbErrorTitle frame={frame} />,
  99. icon: <IconFire size="xs" />,
  100. }),
  101. 'ui.slowClickDetected': (frame: SlowClickFrame) => {
  102. const node = frame.data.node;
  103. if (isDeadClick(frame)) {
  104. return {
  105. color: isDeadRageClick(frame) ? 'red300' : 'yellow300',
  106. description: tct(
  107. 'Click on [selector] did not cause a visible effect within [timeout] ms',
  108. {
  109. selector: stringifyNodeAttributes(node),
  110. timeout: Math.round(frame.data.timeAfterClickMs),
  111. }
  112. ),
  113. icon: <IconCursorArrow size="xs" />,
  114. title: isDeadRageClick(frame) ? 'Rage Click' : 'Dead Click',
  115. tabKey: TabKey.BREADCRUMBS,
  116. };
  117. }
  118. return {
  119. color: 'yellow300',
  120. description: tct(
  121. 'Click on [selector] took [duration] ms to have a visible effect',
  122. {
  123. selector: stringifyNodeAttributes(node),
  124. duration: Math.round(frame.data.timeAfterClickMs),
  125. }
  126. ),
  127. icon: <IconWarning size="xs" />,
  128. title: 'Slow Click',
  129. tabKey: TabKey.BREADCRUMBS,
  130. };
  131. },
  132. 'ui.multiClick': (frame: MultiClickFrame) => {
  133. if (isRageClick(frame)) {
  134. return {
  135. color: 'red300',
  136. description: tct('Rage clicked [clickCount] times on [selector]', {
  137. clickCount: frame.data.clickCount,
  138. selector: stringifyNodeAttributes(frame.data.node),
  139. }),
  140. tabKey: TabKey.BREADCRUMBS,
  141. title: 'Rage Click',
  142. icon: <IconFire size="xs" />,
  143. };
  144. }
  145. return {
  146. color: 'yellow300',
  147. description: tct('[clickCount] clicks on [selector]', {
  148. clickCount: frame.data.clickCount,
  149. selector: stringifyNodeAttributes(frame.data.node),
  150. }),
  151. tabKey: TabKey.BREADCRUMBS,
  152. title: 'Multi Click',
  153. icon: <IconWarning size="xs" />,
  154. };
  155. },
  156. 'replay.mutations': (frame: MutationFrame) => ({
  157. color: 'yellow300',
  158. description: frame.data.limit
  159. ? tct(
  160. 'Significant mutations detected [count]. Replay is now stopped to prevent poor performance for your customer. [link]',
  161. {
  162. count: frame.data.count,
  163. link: (
  164. <ExternalLink href="https://docs.sentry.io/platforms/javascript/session-replay/configuration/#mutation-limits">
  165. {t('Learn more.')}
  166. </ExternalLink>
  167. ),
  168. }
  169. )
  170. : tct(
  171. 'Significant mutations detected [count]. This can slow down the Replay SDK, impacting your customers. [link]',
  172. {
  173. count: frame.data.count,
  174. link: (
  175. <ExternalLink href="https://docs.sentry.io/platforms/javascript/session-replay/configuration/#mutation-limits">
  176. {t('Learn more.')}
  177. </ExternalLink>
  178. ),
  179. }
  180. ),
  181. tabKey: TabKey.BREADCRUMBS,
  182. title: 'DOM Mutations',
  183. icon: <IconWarning size="xs" />,
  184. }),
  185. 'replay.hydrate-error': () => ({
  186. color: 'red300',
  187. description: t(
  188. 'There was a conflict between the server rendered html and the first client render.'
  189. ),
  190. tabKey: TabKey.BREADCRUMBS,
  191. title: 'Hydration Error',
  192. icon: <IconFire size="xs" />,
  193. }),
  194. 'ui.click': frame => ({
  195. color: 'purple300',
  196. description: <SelectorList frame={frame} />,
  197. tabKey: TabKey.BREADCRUMBS,
  198. title: 'User Click',
  199. icon: <IconCursorArrow size="xs" />,
  200. }),
  201. 'ui.tap': (frame: TapFrame) => ({
  202. color: 'purple300',
  203. description: frame.message,
  204. tabKey: TabKey.BREADCRUMBS,
  205. title: 'User Tap',
  206. icon: <IconTap size="xs" />,
  207. }),
  208. 'ui.input': () => ({
  209. color: 'purple300',
  210. description: t('User Action'),
  211. tabKey: TabKey.BREADCRUMBS,
  212. title: 'User Input',
  213. icon: <IconInput size="xs" />,
  214. }),
  215. 'ui.keyDown': () => ({
  216. color: 'purple300',
  217. description: t('User Action'),
  218. tabKey: TabKey.BREADCRUMBS,
  219. title: 'User KeyDown',
  220. icon: <IconKeyDown size="xs" />,
  221. }),
  222. 'ui.blur': () => ({
  223. color: 'purple300',
  224. description: t('The user is preoccupied with another browser, tab, or window'),
  225. tabKey: TabKey.BREADCRUMBS,
  226. title: 'Window Blur',
  227. icon: <IconFocus isFocused={false} size="xs" />,
  228. }),
  229. 'ui.focus': () => ({
  230. color: 'purple300',
  231. description: t('The user is currently focused on your application,'),
  232. tabKey: TabKey.BREADCRUMBS,
  233. title: 'Window Focus',
  234. icon: <IconFocus size="xs" />,
  235. }),
  236. 'app.foreground': () => ({
  237. color: 'purple300',
  238. description: t('The user is currently focused on your application'),
  239. tabKey: TabKey.BREADCRUMBS,
  240. title: 'App in Foreground',
  241. icon: <IconFocus size="xs" />,
  242. }),
  243. 'app.background': () => ({
  244. color: 'purple300',
  245. description: t('The user is preoccupied with another app or activity'),
  246. tabKey: TabKey.BREADCRUMBS,
  247. title: 'App in Background',
  248. icon: <IconFocus isFocused={false} size="xs" />,
  249. }),
  250. console: frame => ({
  251. color: 'gray300',
  252. description: frame.message ?? '',
  253. tabKey: TabKey.CONSOLE,
  254. title: 'Console',
  255. icon: <IconFix size="xs" />,
  256. }),
  257. 'navigation.navigate': frame => ({
  258. color: 'green300',
  259. description: stripURLOrigin(frame.description),
  260. tabKey: TabKey.NETWORK,
  261. title: 'Page Load',
  262. icon: <IconLocation size="xs" />,
  263. }),
  264. 'navigation.reload': frame => ({
  265. color: 'green300',
  266. description: stripURLOrigin(frame.description),
  267. tabKey: TabKey.NETWORK,
  268. title: 'Reload',
  269. icon: <IconLocation size="xs" />,
  270. }),
  271. 'navigation.back_forward': frame => ({
  272. color: 'green300',
  273. description: stripURLOrigin(frame.description),
  274. tabKey: TabKey.NETWORK,
  275. title: 'Navigate Back/Forward',
  276. icon: <IconLocation size="xs" />,
  277. }),
  278. 'navigation.push': frame => ({
  279. color: 'green300',
  280. description: stripURLOrigin(frame.description),
  281. tabKey: TabKey.NETWORK,
  282. title: 'Navigation',
  283. icon: <IconLocation size="xs" />,
  284. }),
  285. 'web-vital': (frame: WebVitalFrame) => {
  286. switch (frame.data.rating) {
  287. case 'good':
  288. return {
  289. color: 'green300',
  290. description: tct('[value][unit] (Good)', {
  291. value: frame.data.value.toFixed(2),
  292. unit: isCLSFrame(frame) ? '' : 'ms',
  293. }),
  294. tabKey: TabKey.NETWORK,
  295. title: WebVitalTitle(frame),
  296. icon: <IconHappy size="xs" />,
  297. };
  298. case 'needs-improvement':
  299. return {
  300. color: 'yellow300',
  301. description: tct('[value][unit] (Meh)', {
  302. value: frame.data.value.toFixed(2),
  303. unit: isCLSFrame(frame) ? '' : 'ms',
  304. }),
  305. tabKey: TabKey.NETWORK,
  306. title: WebVitalTitle(frame),
  307. icon: <IconMeh size="xs" />,
  308. };
  309. default:
  310. return {
  311. color: 'red300',
  312. description: tct('[value][unit] (Poor)', {
  313. value: frame.data.value.toFixed(2),
  314. unit: isCLSFrame(frame) ? '' : 'ms',
  315. }),
  316. tabKey: TabKey.NETWORK,
  317. title: WebVitalTitle(frame),
  318. icon: <IconSad size="xs" />,
  319. };
  320. }
  321. },
  322. memory: () => ({
  323. color: 'gray300',
  324. description: undefined,
  325. tabKey: TabKey.MEMORY,
  326. title: 'Memory',
  327. icon: <IconInfo size="xs" />,
  328. }),
  329. paint: () => ({
  330. color: 'gray300',
  331. description: undefined,
  332. tabKey: TabKey.NETWORK,
  333. title: 'Paint',
  334. icon: <IconInfo size="xs" />,
  335. }),
  336. 'resource.css': frame => ({
  337. color: 'gray300',
  338. description: undefined,
  339. tabKey: TabKey.NETWORK,
  340. title: frame.description,
  341. icon: <IconSort size="xs" rotated />,
  342. }),
  343. 'resource.fetch': frame => ({
  344. color: 'gray300',
  345. description: undefined,
  346. tabKey: TabKey.NETWORK,
  347. title: frame.description,
  348. icon: <IconSort size="xs" rotated />,
  349. }),
  350. 'resource.iframe': frame => ({
  351. color: 'gray300',
  352. description: undefined,
  353. tabKey: TabKey.NETWORK,
  354. title: frame.description,
  355. icon: <IconSort size="xs" rotated />,
  356. }),
  357. 'resource.img': frame => ({
  358. color: 'gray300',
  359. description: undefined,
  360. tabKey: TabKey.NETWORK,
  361. title: frame.description,
  362. icon: <IconSort size="xs" rotated />,
  363. }),
  364. 'resource.link': frame => ({
  365. color: 'gray300',
  366. description: undefined,
  367. tabKey: TabKey.NETWORK,
  368. title: frame.description,
  369. icon: <IconSort size="xs" rotated />,
  370. }),
  371. 'resource.other': frame => ({
  372. color: 'gray300',
  373. description: undefined,
  374. tabKey: TabKey.NETWORK,
  375. title: frame.description,
  376. icon: <IconSort size="xs" rotated />,
  377. }),
  378. 'resource.script': frame => ({
  379. color: 'gray300',
  380. description: undefined,
  381. tabKey: TabKey.NETWORK,
  382. title: frame.description,
  383. icon: <IconSort size="xs" rotated />,
  384. }),
  385. 'resource.xhr': frame => ({
  386. color: 'gray300',
  387. description: undefined,
  388. tabKey: TabKey.NETWORK,
  389. title: frame.description,
  390. icon: <IconSort size="xs" rotated />,
  391. }),
  392. 'resource.http': frame => ({
  393. color: 'gray300',
  394. description: undefined,
  395. tabKey: TabKey.NETWORK,
  396. title: frame.description,
  397. icon: <IconSort size="xs" rotated />,
  398. }),
  399. 'device.connectivity': (frame: DeviceConnectivityFrame) => ({
  400. color: 'pink300',
  401. description: DEVICE_CONNECTIVITY_MESSAGE[frame.data.state],
  402. tabKey: TabKey.BREADCRUMBS,
  403. title: 'Device Connectivity',
  404. icon: <IconWifi size="xs" />,
  405. }),
  406. 'device.battery': (frame: DeviceBatteryFrame) => ({
  407. color: 'pink300',
  408. description: tct('Device was at [percent]% battery and [charging]', {
  409. percent: frame.data.level,
  410. charging: frame.data.charging ? 'charging' : 'not charging',
  411. }),
  412. tabKey: TabKey.BREADCRUMBS,
  413. title: 'Device Battery',
  414. icon: <IconLightning size="xs" />,
  415. }),
  416. 'device.orientation': (frame: DeviceOrientationFrame) => ({
  417. color: 'pink300',
  418. description: tct('Device orientation was changed to [orientation]', {
  419. orientation: frame.data.position,
  420. }),
  421. tabKey: TabKey.BREADCRUMBS,
  422. title: 'Device Orientation',
  423. icon: <IconRefresh size="xs" />,
  424. }),
  425. };
  426. const MAPPER_DEFAULT = (frame): Details => ({
  427. color: 'gray300',
  428. description: frame.message ?? frame.data ?? '',
  429. tabKey: TabKey.BREADCRUMBS,
  430. title: toTitleCase(defaultTitle(frame)),
  431. icon: <IconTerminal size="xs" />,
  432. });
  433. export default function getFrameDetails(frame: ReplayFrame): Details {
  434. const key = getFrameOpOrCategory(frame);
  435. const fn = MAPPER_FOR_FRAME[key] ?? MAPPER_DEFAULT;
  436. try {
  437. return fn(frame);
  438. } catch (error) {
  439. return MAPPER_DEFAULT(frame);
  440. }
  441. }
  442. export function defaultTitle(frame: ReplayFrame | RawBreadcrumbFrame) {
  443. // Override title for User Feedback frames
  444. if ('message' in frame && frame.message === 'User Feedback') {
  445. return t('User Feedback');
  446. }
  447. if ('category' in frame && frame.category) {
  448. const [type, action] = frame.category.split('.');
  449. return `${type} ${action || ''}`.trim();
  450. }
  451. if ('message' in frame && frame.message) {
  452. return frame.message as string; // TODO(replay): Included for backwards compat
  453. }
  454. return 'description' in frame ? frame.description ?? '' : '';
  455. }
  456. function stringifyNodeAttributes(node: SlowClickFrame['data']['node']) {
  457. const {tagName, attributes} = node ?? {};
  458. const attributesEntries = Object.entries(attributes ?? {});
  459. const componentName = node?.attributes['data-sentry-component'];
  460. return `${componentName ?? tagName}${
  461. attributesEntries.length
  462. ? attributesEntries
  463. .map(([attr, val]) =>
  464. componentName && attr === 'data-sentry-component' ? '' : `[${attr}="${val}"]`
  465. )
  466. .join('')
  467. : ''
  468. }`;
  469. }
  470. function WebVitalTitle(frame: WebVitalFrame) {
  471. const vitalDefinition = function () {
  472. switch (frame.description) {
  473. case 'cumulative-layout-shift':
  474. return 'Cumulative Layout Shift (CLS) is the sum of individual layout shift scores for every unexpected element shift during the rendering process. ';
  475. case 'interaction-to-next-paint':
  476. return "Interaction to Next Paint (INP) is a metric that assesses a page's overall responsiveness to user interactions by observing the latency of all user interactions that occur throughout the lifespan of a user's visit to a page. ";
  477. case 'largest-contentful-paint':
  478. return 'Largest Contentful Paint (LCP) measures the render time for the largest content to appear in the viewport. ';
  479. default:
  480. return '';
  481. }
  482. };
  483. return (
  484. <Title>
  485. {t('Web Vital: ') + toTitleCase(explodeSlug(frame.description))}
  486. <QuestionTooltip
  487. isHoverable
  488. size={'xs'}
  489. title={
  490. <Fragment>
  491. {vitalDefinition()}
  492. <ExternalLink href={`${MODULE_DOC_LINK}/web-vitals-concepts/`}>
  493. {t('Learn more about web vitals here.')}
  494. </ExternalLink>
  495. </Fragment>
  496. }
  497. />
  498. </Title>
  499. );
  500. }
  501. const Title = styled('div')`
  502. display: flex;
  503. align-items: center;
  504. gap: ${space(0.5)};
  505. `;