import {Fragment, type ReactNode} from 'react'; import styled from '@emotion/styled'; import ExternalLink from 'sentry/components/links/externalLink'; import QuestionTooltip from 'sentry/components/questionTooltip'; import CrumbErrorTitle from 'sentry/components/replays/breadcrumbs/errorTitle'; import SelectorList from 'sentry/components/replays/breadcrumbs/selectorList'; import { IconCursorArrow, IconFire, IconFix, IconFocus, IconHappy, IconInfo, IconInput, IconKeyDown, IconLightning, IconLocation, IconMegaphone, IconMeh, IconRefresh, IconSad, IconSort, IconTap, IconTerminal, IconWarning, IconWifi, } from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {explodeSlug} from 'sentry/utils'; import {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab'; import type { BreadcrumbFrame, DeviceBatteryFrame, DeviceConnectivityFrame, DeviceOrientationFrame, ErrorFrame, FeedbackFrame, MultiClickFrame, MutationFrame, NavFrame, RawBreadcrumbFrame, ReplayFrame, SlowClickFrame, TapFrame, WebVitalFrame, } from 'sentry/utils/replays/types'; import { getFrameOpOrCategory, isCLSFrame, isDeadClick, isDeadRageClick, isRageClick, } from 'sentry/utils/replays/types'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import type {Color} from 'sentry/utils/theme'; import stripURLOrigin from 'sentry/utils/url/stripURLOrigin'; import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings'; interface Details { color: Color; description: ReactNode; icon: ReactNode; tabKey: TabKey; title: ReactNode; } const DEVICE_CONNECTIVITY_MESSAGE: Record = { wifi: t('Device connected to wifi'), offline: t('Internet connection was lost'), cellular: t('Device connected to cellular network'), ethernet: t('Device connected to ethernet'), }; const MAPPER_FOR_FRAME: Record Details> = { 'replay.init': (frame: BreadcrumbFrame) => ({ color: 'gray300', description: stripURLOrigin(frame.message ?? ''), tabKey: TabKey.CONSOLE, title: 'Replay Start', icon: , }), navigation: (frame: NavFrame) => ({ color: 'green300', description: stripURLOrigin((frame as NavFrame).data.to), tabKey: TabKey.NETWORK, title: 'Navigation', icon: , }), feedback: (frame: FeedbackFrame) => ({ color: 'purple300', description: frame.data.projectSlug, tabKey: TabKey.BREADCRUMBS, title: defaultTitle(frame), icon: , }), issue: (frame: ErrorFrame) => ({ color: 'red300', description: frame.message, tabKey: TabKey.ERRORS, title: , icon: , }), 'ui.slowClickDetected': (frame: SlowClickFrame) => { const node = frame.data.node; if (isDeadClick(frame)) { return { color: isDeadRageClick(frame) ? 'red300' : 'yellow300', description: tct( 'Click on [selector] did not cause a visible effect within [timeout] ms', { selector: stringifyNodeAttributes(node), timeout: Math.round(frame.data.timeAfterClickMs), } ), icon: , title: isDeadRageClick(frame) ? 'Rage Click' : 'Dead Click', tabKey: TabKey.BREADCRUMBS, }; } return { color: 'yellow300', description: tct( 'Click on [selector] took [duration] ms to have a visible effect', { selector: stringifyNodeAttributes(node), duration: Math.round(frame.data.timeAfterClickMs), } ), icon: , title: 'Slow Click', tabKey: TabKey.BREADCRUMBS, }; }, 'ui.multiClick': (frame: MultiClickFrame) => { if (isRageClick(frame)) { return { color: 'red300', description: tct('Rage clicked [clickCount] times on [selector]', { clickCount: frame.data.clickCount, selector: stringifyNodeAttributes(frame.data.node), }), tabKey: TabKey.BREADCRUMBS, title: 'Rage Click', icon: , }; } return { color: 'yellow300', description: tct('[clickCount] clicks on [selector]', { clickCount: frame.data.clickCount, selector: stringifyNodeAttributes(frame.data.node), }), tabKey: TabKey.BREADCRUMBS, title: 'Multi Click', icon: , }; }, 'replay.mutations': (frame: MutationFrame) => ({ color: 'yellow300', description: frame.data.limit ? tct( 'Significant mutations detected [count]. Replay is now stopped to prevent poor performance for your customer. [link]', { count: frame.data.count, link: ( {t('Learn more.')} ), } ) : tct( 'Significant mutations detected [count]. This can slow down the Replay SDK, impacting your customers. [link]', { count: frame.data.count, link: ( {t('Learn more.')} ), } ), tabKey: TabKey.BREADCRUMBS, title: 'DOM Mutations', icon: , }), 'replay.hydrate-error': () => ({ color: 'red300', description: t( 'There was a conflict between the server rendered html and the first client render.' ), tabKey: TabKey.BREADCRUMBS, title: 'Hydration Error', icon: , }), 'ui.click': frame => ({ color: 'purple300', description: , tabKey: TabKey.BREADCRUMBS, title: 'User Click', icon: , }), 'ui.tap': (frame: TapFrame) => ({ color: 'purple300', description: frame.message, tabKey: TabKey.BREADCRUMBS, title: 'User Tap', icon: , }), 'ui.input': () => ({ color: 'purple300', description: t('User Action'), tabKey: TabKey.BREADCRUMBS, title: 'User Input', icon: , }), 'ui.keyDown': () => ({ color: 'purple300', description: t('User Action'), tabKey: TabKey.BREADCRUMBS, title: 'User KeyDown', icon: , }), 'ui.blur': () => ({ color: 'purple300', description: t('The user is preoccupied with another browser, tab, or window'), tabKey: TabKey.BREADCRUMBS, title: 'Window Blur', icon: , }), 'ui.focus': () => ({ color: 'purple300', description: t('The user is currently focused on your application,'), tabKey: TabKey.BREADCRUMBS, title: 'Window Focus', icon: , }), 'app.foreground': () => ({ color: 'purple300', description: t('The user is currently focused on your application'), tabKey: TabKey.BREADCRUMBS, title: 'App in Foreground', icon: , }), 'app.background': () => ({ color: 'purple300', description: t('The user is preoccupied with another app or activity'), tabKey: TabKey.BREADCRUMBS, title: 'App in Background', icon: , }), console: frame => ({ color: 'gray300', description: frame.message ?? '', tabKey: TabKey.CONSOLE, title: 'Console', icon: , }), 'navigation.navigate': frame => ({ color: 'green300', description: stripURLOrigin(frame.description), tabKey: TabKey.NETWORK, title: 'Page Load', icon: , }), 'navigation.reload': frame => ({ color: 'green300', description: stripURLOrigin(frame.description), tabKey: TabKey.NETWORK, title: 'Reload', icon: , }), 'navigation.back_forward': frame => ({ color: 'green300', description: stripURLOrigin(frame.description), tabKey: TabKey.NETWORK, title: 'Navigate Back/Forward', icon: , }), 'navigation.push': frame => ({ color: 'green300', description: stripURLOrigin(frame.description), tabKey: TabKey.NETWORK, title: 'Navigation', icon: , }), 'web-vital': (frame: WebVitalFrame) => { switch (frame.data.rating) { case 'good': return { color: 'green300', description: tct('[value][unit] (Good)', { value: frame.data.value.toFixed(2), unit: isCLSFrame(frame) ? '' : 'ms', }), tabKey: TabKey.NETWORK, title: WebVitalTitle(frame), icon: , }; case 'needs-improvement': return { color: 'yellow300', description: tct('[value][unit] (Meh)', { value: frame.data.value.toFixed(2), unit: isCLSFrame(frame) ? '' : 'ms', }), tabKey: TabKey.NETWORK, title: WebVitalTitle(frame), icon: , }; default: return { color: 'red300', description: tct('[value][unit] (Poor)', { value: frame.data.value.toFixed(2), unit: isCLSFrame(frame) ? '' : 'ms', }), tabKey: TabKey.NETWORK, title: WebVitalTitle(frame), icon: , }; } }, memory: () => ({ color: 'gray300', description: undefined, tabKey: TabKey.MEMORY, title: 'Memory', icon: , }), paint: () => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: 'Paint', icon: , }), 'resource.css': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.fetch': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.iframe': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.img': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.link': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.other': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.script': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.xhr': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'resource.http': frame => ({ color: 'gray300', description: undefined, tabKey: TabKey.NETWORK, title: frame.description, icon: , }), 'device.connectivity': (frame: DeviceConnectivityFrame) => ({ color: 'pink300', description: DEVICE_CONNECTIVITY_MESSAGE[frame.data.state], tabKey: TabKey.BREADCRUMBS, title: 'Device Connectivity', icon: , }), 'device.battery': (frame: DeviceBatteryFrame) => ({ color: 'pink300', description: tct('Device was at [percent]% battery and [charging]', { percent: frame.data.level, charging: frame.data.charging ? 'charging' : 'not charging', }), tabKey: TabKey.BREADCRUMBS, title: 'Device Battery', icon: , }), 'device.orientation': (frame: DeviceOrientationFrame) => ({ color: 'pink300', description: tct('Device orientation was changed to [orientation]', { orientation: frame.data.position, }), tabKey: TabKey.BREADCRUMBS, title: 'Device Orientation', icon: , }), }; const MAPPER_DEFAULT = (frame): Details => ({ color: 'gray300', description: frame.message ?? frame.data ?? '', tabKey: TabKey.BREADCRUMBS, title: toTitleCase(defaultTitle(frame)), icon: , }); export default function getFrameDetails(frame: ReplayFrame): Details { const key = getFrameOpOrCategory(frame); const fn = MAPPER_FOR_FRAME[key] ?? MAPPER_DEFAULT; try { return fn(frame); } catch (error) { return MAPPER_DEFAULT(frame); } } export function defaultTitle(frame: ReplayFrame | RawBreadcrumbFrame) { // Override title for User Feedback frames if ('message' in frame && frame.message === 'User Feedback') { return t('User Feedback'); } if ('category' in frame && frame.category) { const [type, action] = frame.category.split('.'); return `${type} ${action || ''}`.trim(); } if ('message' in frame && frame.message) { return frame.message as string; // TODO(replay): Included for backwards compat } return 'description' in frame ? frame.description ?? '' : ''; } function stringifyNodeAttributes(node: SlowClickFrame['data']['node']) { const {tagName, attributes} = node ?? {}; const attributesEntries = Object.entries(attributes ?? {}); const componentName = node?.attributes['data-sentry-component']; return `${componentName ?? tagName}${ attributesEntries.length ? attributesEntries .map(([attr, val]) => componentName && attr === 'data-sentry-component' ? '' : `[${attr}="${val}"]` ) .join('') : '' }`; } function WebVitalTitle(frame: WebVitalFrame) { const vitalDefinition = function () { switch (frame.description) { case 'cumulative-layout-shift': return 'Cumulative Layout Shift (CLS) is the sum of individual layout shift scores for every unexpected element shift during the rendering process. '; case 'interaction-to-next-paint': 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. "; case 'largest-contentful-paint': return 'Largest Contentful Paint (LCP) measures the render time for the largest content to appear in the viewport. '; default: return ''; } }; return ( {t('Web Vital: ') + toTitleCase(explodeSlug(frame.description))} <QuestionTooltip isHoverable size={'xs'} title={ <Fragment> {vitalDefinition()} <ExternalLink href={`${MODULE_DOC_LINK}/web-vitals-concepts/`}> {t('Learn more about web vitals here.')} </ExternalLink> </Fragment> } /> ); } const Title = styled('div')` display: flex; align-items: center; gap: ${space(0.5)}; `;