Browse Source

ref(replay): Convert the DOM tab to use *Frame types (#51209)

<!-- Describe your PR here. -->
Ryan Albrecht 1 year ago
parent
commit
b8a3d2d67c

+ 1 - 1
fixtures/js-stubs/replay/replayBreadcrumbFrameData.ts

@@ -32,7 +32,7 @@ export function ClickFrame(fields: TestableFrame<'ui.click'>): MockFrame<'ui.cli
     data: fields.data ?? {},
     message: fields.message ?? '',
     timestamp: fields.timestamp.getTime() / 1000,
-    type: BreadcrumbType.DEFAULT,
+    type: BreadcrumbType.UI,
   };
 }
 

+ 61 - 63
static/app/utils/replays/extractDomNodes.tsx

@@ -1,31 +1,35 @@
 import * as Sentry from '@sentry/react';
-import type {eventWithTime} from '@sentry-internal/rrweb';
-import {EventType, Replayer} from '@sentry-internal/rrweb';
+import {Replayer} from '@sentry-internal/rrweb';
 import first from 'lodash/first';
 
-import type {Crumb} from 'sentry/types/breadcrumbs';
+import type {
+  BreadcrumbFrame,
+  RecordingFrame,
+  SpanFrame,
+} from 'sentry/utils/replays/types';
+import {EventType} from 'sentry/utils/replays/types';
 import requestIdleCallback from 'sentry/utils/window/requestIdleCallback';
 
 export type Extraction = {
-  crumb: Crumb;
+  frame: BreadcrumbFrame | SpanFrame;
   html: string;
   timestamp: number;
 };
 
 type Args = {
-  crumbs: Crumb[] | undefined;
   finishedAt: Date | undefined;
-  rrwebEvents: eventWithTime[] | undefined;
+  frames: (BreadcrumbFrame | SpanFrame)[] | undefined;
+  rrwebEvents: RecordingFrame[] | undefined;
 };
 
 function _extractDomNodes({
-  crumbs,
+  frames,
   rrwebEvents,
   finishedAt,
 }: Args): Promise<Extraction[]> {
-  // Get a list of the breadcrumbs that relate directly to the DOM, for each
-  // crumb we will extract the referenced HTML.
-  if (!crumbs || !rrwebEvents || rrwebEvents.length < 2 || !finishedAt) {
+  // Get a list of the BreadcrumbFrames that relate directly to the DOM, for each
+  // frame we will extract the referenced HTML.
+  if (!frames || !rrwebEvents || rrwebEvents.length < 2 || !finishedAt) {
     return Promise.reject();
   }
 
@@ -45,7 +49,7 @@ function _extractDomNodes({
     // ReplayerReader added. RRWeb will skip that event when it comes time to render
     const lastEvent = rrwebEvents[rrwebEvents.length - 2];
 
-    const isLastRRWebEvent = (event: eventWithTime) => lastEvent === event;
+    const isLastRRWebEvent = (event: RecordingFrame) => lastEvent === event;
 
     const replayerRef = new Replayer(rrwebEvents, {
       root: domRoot,
@@ -57,7 +61,7 @@ function _extractDomNodes({
       triggerFocus: false,
       plugins: [
         new BreadcrumbReferencesPlugin({
-          crumbs,
+          frames,
           isFinished: isLastRRWebEvent,
           onFinish: rows => {
             resolve(rows);
@@ -96,31 +100,31 @@ export default function extractDomNodes(args: Args): Promise<Extraction[]> {
 }
 
 type PluginOpts = {
-  crumbs: Crumb[];
-  isFinished: (event: eventWithTime) => boolean;
+  frames: (BreadcrumbFrame | SpanFrame)[];
+  isFinished: (event: RecordingFrame) => boolean;
   onFinish: (mutations: Extraction[]) => void;
 };
 
 class BreadcrumbReferencesPlugin {
-  crumbs: Crumb[];
-  isFinished: (event: eventWithTime) => boolean;
+  frames: (BreadcrumbFrame | SpanFrame)[];
+  isFinished: (event: RecordingFrame) => boolean;
   onFinish: (mutations: Extraction[]) => void;
 
   nextExtract: null | Extraction['html'] = null;
   activities: Extraction[] = [];
 
-  constructor({crumbs, isFinished, onFinish}: PluginOpts) {
-    this.crumbs = crumbs;
+  constructor({frames, isFinished, onFinish}: PluginOpts) {
+    this.frames = frames;
     this.isFinished = isFinished;
     this.onFinish = onFinish;
   }
 
-  handler(event: eventWithTime, _isSync: boolean, {replayer}: {replayer: Replayer}) {
+  handler(event: RecordingFrame, _isSync: boolean, {replayer}: {replayer: Replayer}) {
     if (event.type === EventType.FullSnapshot) {
-      this.extractNextCrumb({replayer});
+      this.extractNextFrame({replayer});
     } else if (event.type === EventType.IncrementalSnapshot) {
-      this.extractCurrentCrumb(event, {replayer});
-      this.extractNextCrumb({replayer});
+      this.extractCurrentFrame(event, {replayer});
+      this.extractNextFrame({replayer});
     }
 
     if (this.isFinished(event)) {
@@ -128,43 +132,41 @@ class BreadcrumbReferencesPlugin {
     }
   }
 
-  extractCurrentCrumb(event: eventWithTime, {replayer}: {replayer: Replayer}) {
-    const crumb = first(this.crumbs);
-    const crumbTimestamp = +new Date(crumb?.timestamp || '');
+  extractCurrentFrame(event: RecordingFrame, {replayer}: {replayer: Replayer}) {
+    const frame = first(this.frames);
 
-    if (!crumb || !crumbTimestamp || crumbTimestamp > event.timestamp) {
+    if (!frame || !frame?.timestampMs || frame.timestampMs > event.timestamp) {
       return;
     }
 
-    const truncated = extractNode(crumb, replayer) || this.nextExtract;
+    const truncated = extractNode(frame, replayer) || this.nextExtract;
     if (truncated) {
       this.activities.push({
-        crumb,
+        frame,
         html: truncated,
-        timestamp: crumbTimestamp,
+        timestamp: frame.timestampMs,
       });
     }
 
     this.nextExtract = null;
-    this.crumbs.shift();
+    this.frames.shift();
   }
 
-  extractNextCrumb({replayer}: {replayer: Replayer}) {
-    const crumb = first(this.crumbs);
-    const crumbTimestamp = +new Date(crumb?.timestamp || '');
+  extractNextFrame({replayer}: {replayer: Replayer}) {
+    const frame = first(this.frames);
 
-    if (!crumb || !crumbTimestamp) {
+    if (!frame || !frame?.timestampMs) {
       return;
     }
 
-    this.nextExtract = extractNode(crumb, replayer);
+    this.nextExtract = extractNode(frame, replayer);
   }
 }
 
-function extractNode(crumb: Crumb, replayer: Replayer) {
+function extractNode(frame: BreadcrumbFrame | SpanFrame, replayer: Replayer) {
   const mirror = replayer.getMirror();
   // @ts-expect-error
-  const nodeId = crumb.data?.nodeId || '';
+  const nodeId = (frame.data?.nodeId ?? -1) as number;
   const node = mirror.getNode(nodeId);
   // @ts-expect-error
   const html = node?.outerHTML || node?.textContent || '';
@@ -178,37 +180,33 @@ function extractNode(crumb: Crumb, replayer: Replayer) {
   return truncated;
 }
 
+function removeChildLevel(max: number, collection: HTMLCollection, current: number = 0) {
+  for (let i = 0; i < collection.length; i++) {
+    const child = collection[i];
+
+    if (child.nodeName === 'STYLE') {
+      child.textContent = '/* Inline CSS */';
+    }
+
+    if (child.nodeName === 'svg') {
+      child.innerHTML = '<!-- SVG -->';
+    }
+
+    if (max <= current) {
+      if (child.childElementCount > 0) {
+        child.innerHTML = `<!-- ${child.childElementCount} descendents -->`;
+      }
+    } else {
+      removeChildLevel(max, child.children, current + 1);
+    }
+  }
+}
+
 function removeNodesAtLevel(html: string, level: number) {
   const parser = new DOMParser();
   try {
     const doc = parser.parseFromString(html, 'text/html');
 
-    const removeChildLevel = (
-      max: number,
-      collection: HTMLCollection,
-      current: number = 0
-    ) => {
-      for (let i = 0; i < collection.length; i++) {
-        const child = collection[i];
-
-        if (child.nodeName === 'STYLE') {
-          child.textContent = '/* Inline CSS */';
-        }
-
-        if (child.nodeName === 'svg') {
-          child.innerHTML = '<!-- SVG -->';
-        }
-
-        if (max <= current) {
-          if (child.childElementCount > 0) {
-            child.innerHTML = `<!-- ${child.childElementCount} descendents -->`;
-          }
-        } else {
-          removeChildLevel(max, child.children, current + 1);
-        }
-      }
-    };
-
     removeChildLevel(level, doc.body.children);
     return doc.body.innerHTML;
   } catch (err) {

+ 259 - 0
static/app/utils/replays/frame.tsx

@@ -0,0 +1,259 @@
+import {ReactNode} from 'react';
+
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconWarning} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {BreadcrumbType} from 'sentry/types/breadcrumbs';
+import {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
+import {
+  BreadcrumbFrame,
+  ErrorFrame,
+  LargestContentfulPaintFrame,
+  MutationFrame,
+  NavigationFrame,
+  SlowClickFrame,
+  SpanFrame,
+} from 'sentry/utils/replays/types';
+import type {Color} from 'sentry/utils/theme';
+
+export function getColor(frame: BreadcrumbFrame | SpanFrame | ErrorFrame): Color {
+  if ('category' in frame) {
+    switch (frame.category) {
+      case 'issue':
+        return 'red300';
+      case 'ui.slowClickDetected':
+        return (frame as SlowClickFrame).data.endReason === 'timeout'
+          ? 'red300'
+          : 'yellow300';
+      case 'replay.mutations':
+        return 'yellow300';
+      case 'ui.click':
+      case 'ui.input':
+      case 'ui.keyDown':
+      case 'ui.blur':
+      case 'ui.focus':
+        return 'purple300';
+      case 'console':
+      default: // Custom breadcrumbs will fall through here
+        return 'gray300';
+    }
+  }
+
+  switch (frame.op) {
+    case 'navigation.navigate':
+    case 'navigation.reload':
+    case 'navigation.back_forward':
+    case 'navigation.push':
+      return 'green300';
+    case 'largest-contentful-paint':
+    case 'memory':
+    case 'paint':
+    case 'resource.fetch':
+    case 'resource.xhr':
+    default:
+      return 'gray300';
+  }
+}
+
+/**
+ * The breadcrumbType is used as a value for <BreadcrumbIcon/>
+ * We could remove the indirection by associating frames with icons directly.
+ *
+ * @deprecated
+ */
+export function getBreadcrumbType(
+  frame: BreadcrumbFrame | SpanFrame | ErrorFrame
+): BreadcrumbType {
+  if ('category' in frame) {
+    switch (frame.category) {
+      case 'issue':
+        return BreadcrumbType.ERROR;
+      case 'ui.slowClickDetected':
+        return (frame as SlowClickFrame).data.endReason === 'timeout'
+          ? BreadcrumbType.ERROR
+          : BreadcrumbType.WARNING;
+      case 'replay.mutations':
+        return BreadcrumbType.WARNING;
+      case 'ui.click':
+      case 'ui.input':
+      case 'ui.keyDown':
+      case 'ui.blur':
+      case 'ui.focus':
+        return BreadcrumbType.UI;
+      case 'console':
+        return BreadcrumbType.DEBUG;
+      default: // Custom breadcrumbs will fall through here
+        return BreadcrumbType.DEFAULT;
+    }
+  }
+
+  switch (frame.op) {
+    case 'navigation.navigate':
+    case 'navigation.reload':
+    case 'navigation.back_forward':
+    case 'navigation.push':
+      return BreadcrumbType.NAVIGATION;
+    case 'largest-contentful-paint':
+    case 'memory':
+    case 'paint':
+      return BreadcrumbType.INFO;
+    case 'resource.fetch':
+    case 'resource.xhr':
+      return BreadcrumbType.HTTP;
+    default:
+      return BreadcrumbType.DEFAULT;
+  }
+}
+
+export function getTitle(frame: BreadcrumbFrame | SpanFrame | ErrorFrame): ReactNode {
+  if (
+    typeof frame.data === 'object' &&
+    frame.data !== null &&
+    'label' in frame.data &&
+    frame.data.label
+  ) {
+    return frame.data.label; // TODO(replay): Included for backwards compat
+  }
+
+  if ('category' in frame) {
+    const [type, action] = frame.category.split('.');
+    switch (frame.category) {
+      case 'ui.slowClickDetected':
+        return (frame as SlowClickFrame).data.endReason === 'timeout'
+          ? 'Dead Click'
+          : 'Slow Click';
+      case 'replay.mutations':
+        return 'Replay';
+      case 'ui.click':
+      case 'ui.input':
+      case 'ui.keyDown':
+      case 'ui.blur':
+      case 'ui.focus':
+        return `User ${action || ''}`;
+      default: // Custom breadcrumbs will fall through here
+        return `${type} ${action || ''}`.trim();
+    }
+  }
+
+  if ('message' in frame) {
+    return frame.message; // TODO(replay): Included for backwards compat
+  }
+  return frame.description;
+}
+
+function stringifyNodeAttributes(node: SlowClickFrame['data']['node']) {
+  const {tagName, attributes} = node ?? {};
+  const attributesEntries = Object.entries(attributes ?? {});
+  return `${tagName}${
+    attributesEntries.length
+      ? attributesEntries.map(([attr, val]) => `[${attr}="${val}"]`).join('')
+      : ''
+  }`;
+}
+
+export function getDescription(
+  frame: BreadcrumbFrame | SpanFrame | ErrorFrame
+): ReactNode {
+  if ('category' in frame) {
+    switch (frame.category) {
+      case 'issue':
+      case 'ui.slowClickDetected': {
+        const slowClickFrame = frame as SlowClickFrame;
+        const node = slowClickFrame.data.node;
+        return slowClickFrame.data.endReason === 'timeout'
+          ? tct(
+              'Click on [selector] did not cause a visible effect within [timeout] ms',
+              {
+                selector: stringifyNodeAttributes(node),
+                timeout: slowClickFrame.data.timeAfterClickMs,
+              }
+            )
+          : tct('Click on [selector] took [duration] ms to have a visible effect', {
+              selector: stringifyNodeAttributes(node),
+              duration: slowClickFrame.data.timeAfterClickMs,
+            });
+      }
+      case 'replay.mutations': {
+        const mutationFrame = frame as MutationFrame;
+        return mutationFrame.data.limit
+          ? t(
+              'A large number of mutations was detected (%s). Replay is now stopped to prevent poor performance for your customer.',
+              mutationFrame.data.count
+            )
+          : t(
+              'A large number of mutations was detected (%s). This can slow down the Replay SDK and impact your customers.',
+              mutationFrame.data.count
+            );
+      }
+      case 'ui.click':
+      case 'ui.input':
+      case 'ui.keyDown':
+      case 'ui.blur':
+      case 'ui.focus':
+        return t('User Action');
+      case 'console':
+      default: // Custom breadcrumbs will fall through here
+        return frame.message ?? '';
+    }
+  }
+
+  switch (frame.op) {
+    case 'navigation.navigate':
+    case 'navigation.reload':
+    case 'navigation.back_forward':
+    case 'navigation.push':
+      // @ts-expect-error `.to` isn't part of the type
+      return (frame as NavigationFrame).data.to ?? '';
+    case 'largest-contentful-paint': {
+      const lcpFrame = frame as LargestContentfulPaintFrame;
+      if (typeof lcpFrame.data.value === 'number') {
+        return `${Math.round((frame as LargestContentfulPaintFrame).data.value)}ms`;
+      }
+      // Included for backwards compat
+      return (
+        <Tooltip
+          title={t(
+            '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.'
+          )}
+        >
+          <IconWarning />
+        </Tooltip>
+      );
+    }
+    default:
+      return undefined;
+  }
+}
+
+export function getTabKeyForFrame(frame: BreadcrumbFrame | SpanFrame): TabKey {
+  if ('category' in frame) {
+    switch (frame.category) {
+      case 'issue':
+        return TabKey.ERRORS;
+      case 'ui.slowClickDetected':
+      case 'replay.mutations':
+      case 'ui.click':
+      case 'ui.input':
+      case 'ui.keyDown':
+        return TabKey.DOM;
+      case 'console':
+      default: // Custom breadcrumbs will fall through here
+        return TabKey.CONSOLE;
+    }
+  }
+
+  switch (frame.op) {
+    case 'memory':
+      return TabKey.MEMORY;
+    case 'navigation.navigate':
+    case 'navigation.reload':
+    case 'navigation.back_forward':
+    case 'navigation.push':
+    case 'largest-contentful-paint':
+    case 'paint':
+    case 'resource.fetch':
+    case 'resource.xhr':
+    default:
+      return TabKey.NETWORK;
+  }
+}

+ 22 - 8
static/app/utils/replays/hooks/useCrumbHandlers.tsx

@@ -3,7 +3,9 @@ import {useCallback, useRef} from 'react';
 import {useReplayContext} from 'sentry/components/replays/replayContext';
 import {relativeTimeInMs} from 'sentry/components/replays/utils';
 import {BreadcrumbType, Crumb} from 'sentry/types/breadcrumbs';
+import {getTabKeyForFrame} from 'sentry/utils/replays/frame';
 import useActiveReplayTab from 'sentry/utils/replays/hooks/useActiveReplayTab';
+import {BreadcrumbFrame, SpanFrame} from 'sentry/utils/replays/types';
 import useOrganization from 'sentry/utils/useOrganization';
 import type {NetworkSpan} from 'sentry/views/replays/types';
 
@@ -21,7 +23,7 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
   const hasErrorTab = organization.features.includes('session-replay-errors-tab');
 
   const mouseEnterCallback = useRef<{
-    id: string | number | null;
+    id: Crumb | NetworkSpan | BreadcrumbFrame | SpanFrame | null;
     timeoutId: NodeJS.Timeout | null;
   }>({
     id: null,
@@ -29,20 +31,25 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
   });
 
   const handleMouseEnter = useCallback(
-    (item: Crumb | NetworkSpan) => {
+    (item: Crumb | NetworkSpan | BreadcrumbFrame | SpanFrame) => {
       // this debounces the mouseEnter callback in unison with mouseLeave
       // we ensure the pointer remains over the target element before dispatching state events in order to minimize unnecessary renders
       // this helps during scrolling or mouse move events which would otherwise fire in rapid succession slowing down our app
-      mouseEnterCallback.current.id = item.id;
+      mouseEnterCallback.current.id = item;
       mouseEnterCallback.current.timeoutId = setTimeout(() => {
         if (startTimestampMs) {
-          setCurrentHoverTime(relativeTimeInMs(item.timestamp ?? '', startTimestampMs));
+          setCurrentHoverTime(
+            'offsetMs' in item
+              ? item.offsetMs
+              : relativeTimeInMs(item.timestamp ?? '', startTimestampMs)
+          );
         }
 
         if (item.data && typeof item.data === 'object' && 'nodeId' in item.data) {
           // XXX: Kind of hacky, but mouseLeave does not fire if you move from a
           // crumb to a tooltip
           clearAllHighlights();
+          // @ts-expect-error: Property 'label' does not exist on type
           highlight({nodeId: item.data.nodeId, annotation: item.data.label});
         }
         mouseEnterCallback.current.id = null;
@@ -53,9 +60,9 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
   );
 
   const handleMouseLeave = useCallback(
-    (item: Crumb | NetworkSpan) => {
+    (item: Crumb | NetworkSpan | BreadcrumbFrame | SpanFrame) => {
       // if there is a mouseEnter callback queued and we're leaving it we can just cancel the timeout
-      if (mouseEnterCallback.current.id === item.id) {
+      if (mouseEnterCallback.current.id === item) {
         if (mouseEnterCallback.current.timeoutId) {
           clearTimeout(mouseEnterCallback.current.timeoutId);
         }
@@ -75,7 +82,15 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
   );
 
   const handleClick = useCallback(
-    (crumb: Crumb | NetworkSpan) => {
+    (crumb: Crumb | NetworkSpan | BreadcrumbFrame | SpanFrame) => {
+      if ('offsetMs' in crumb) {
+        const frame = crumb; // Finding `offsetMs` means we have a frame, not a crumb or span
+
+        setCurrentTime(frame.offsetMs);
+        setActiveTab(getTabKeyForFrame(frame));
+        return;
+      }
+
       if (crumb.timestamp !== undefined) {
         setCurrentTime(relativeTimeInMs(crumb.timestamp, startTimestampMs));
       }
@@ -95,7 +110,6 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
           setActiveTab('errors');
           return;
         }
-
         switch (crumb.type) {
           case BreadcrumbType.NAVIGATION:
             setActiveTab('network');

+ 11 - 10
static/app/utils/replays/replayReader.tsx

@@ -234,8 +234,17 @@ export default class ReplayReader {
     )
   );
 
-  getDOMFrames = memoize(() =>
-    this._sortedBreadcrumbFrames.filter(frame => 'nodeId' in (frame.data ?? {}))
+  getDOMFrames = memoize(() => [
+    ...this._sortedBreadcrumbFrames.filter(frame => 'nodeId' in (frame.data ?? {})),
+    ...this._sortedSpanFrames.filter(frame => 'nodeId' in (frame.data ?? {})),
+  ]);
+
+  getDomNodes = memoize(() =>
+    extractDomNodes({
+      frames: this.getDOMFrames(),
+      rrwebEvents: this.getRRWebFrames(),
+      finishedAt: this.replayRecord.finished_at,
+    })
   );
 
   getMemoryFrames = memoize(() =>
@@ -322,14 +331,6 @@ export default class ReplayReader {
 
   getMemorySpans = memoize(() => this.sortedSpans.filter(isMemorySpan));
 
-  getDomNodes = memoize(() =>
-    extractDomNodes({
-      crumbs: this.getCrumbsWithRRWebNodes(),
-      rrwebEvents: this.getRRWebFrames(),
-      finishedAt: this.replayRecord.finished_at,
-    })
-  );
-
   sdkConfig = memoize(() => {
     const found = this.rrwebEvents.find(
       event => event.type === EventType.Custom && event.data.tag === 'options'

+ 7 - 0
static/app/utils/replays/types.tsx

@@ -13,6 +13,7 @@ import type {
   SpanFrame as TRawSpanFrame,
   SpanFrameEvent as TSpanFrameEvent,
 } from '@sentry/replay';
+import invariant from 'invariant';
 
 export type RawBreadcrumbFrame = TRawBreadcrumbFrame;
 export type BreadcrumbFrameEvent = TBreadcrumbFrameEvent;
@@ -46,6 +47,12 @@ export function isOptionFrameEvent(
   return attachment.data?.tag === 'options';
 }
 
+export function frameOpOrCategory(frame: BreadcrumbFrame | SpanFrame | ErrorFrame) {
+  const val = ('op' in frame && frame.op) || ('category' in frame && frame.category);
+  invariant(val, 'Frame has no category or op');
+  return val;
+}
+
 type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
 
 type HydratedTimestamp = {

+ 14 - 15
static/app/views/replays/detail/domMutations/domMutationRow.tsx

@@ -1,14 +1,13 @@
-import {CSSProperties, useCallback, useMemo} from 'react';
+import {CSSProperties, useCallback} from 'react';
 import styled from '@emotion/styled';
 import classNames from 'classnames';
 import beautify from 'js-beautify';
 
 import {CodeSnippet} from 'sentry/components/codeSnippet';
 import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/breadcrumb/type/icon';
-import {getDetails} from 'sentry/components/replays/breadcrumbs/utils';
-import {relativeTimeInMs} from 'sentry/components/replays/utils';
 import {space} from 'sentry/styles/space';
 import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
+import {getBreadcrumbType, getColor, getTitle} from 'sentry/utils/replays/frame';
 import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
 import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
 import TimestampButton from 'sentry/views/replays/detail/timestampButton';
@@ -28,7 +27,7 @@ function DomMutationRow({
   startTimestampMs,
   style,
 }: Props) {
-  const {html, crumb: breadcrumb} = mutation;
+  const {html, frame: breadcrumb} = mutation;
 
   const {handleMouseEnter, handleMouseLeave, handleClick} =
     useCrumbHandlers(startTimestampMs);
@@ -46,14 +45,13 @@ function DomMutationRow({
     [handleMouseLeave, breadcrumb]
   );
 
-  const crumbTime = useMemo(
-    () => relativeTimeInMs(breadcrumb.timestamp || 0, startTimestampMs),
-    [breadcrumb.timestamp, startTimestampMs]
-  );
-  const hasOccurred = currentTime >= crumbTime;
-  const isBeforeHover = currentHoverTime === undefined || currentHoverTime >= crumbTime;
+  const hasOccurred = currentTime >= breadcrumb.offsetMs;
+  const isBeforeHover =
+    currentHoverTime === undefined || currentHoverTime >= breadcrumb.offsetMs;
 
-  const {title} = getDetails(breadcrumb);
+  const color = getColor(breadcrumb);
+  const title = getTitle(breadcrumb);
+  const type = getBreadcrumbType(breadcrumb);
 
   return (
     <MutationListItem
@@ -67,8 +65,8 @@ function DomMutationRow({
       onMouseLeave={onMouseLeave}
       style={style}
     >
-      <IconWrapper color={breadcrumb.color} hasOccurred={hasOccurred}>
-        <BreadcrumbIcon type={breadcrumb.type} />
+      <IconWrapper color={color} hasOccurred={hasOccurred}>
+        <BreadcrumbIcon type={type} />
       </IconWrapper>
       <List>
         <Row>
@@ -76,10 +74,11 @@ function DomMutationRow({
           <TimestampButton
             onClick={onClickTimestamp}
             startTimestampMs={startTimestampMs}
-            timestampMs={breadcrumb.timestamp || ''}
+            timestampMs={breadcrumb.timestampMs}
           />
         </Row>
-        <Selector>{breadcrumb.message}</Selector>
+        {/* @ts-expect-error */}
+        <Selector>{breadcrumb.message ?? ''}</Selector>
         <CodeContainer>
           <CodeSnippet language="html" hideCopyButton>
             {beautify.html(html, {indent_size: 2})}

+ 46 - 60
static/app/views/replays/detail/domMutations/useDomFilters.spec.tsx

@@ -3,8 +3,9 @@ import type {Location} from 'history';
 
 import {reactHooks} from 'sentry-test/reactTestingLibrary';
 
-import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
 import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
+import hydrateBreadcrumbs from 'sentry/utils/replays/hydrateBreadcrumbs';
+import hydrateSpans from 'sentry/utils/replays/hydrateSpans';
 import {useLocation} from 'sentry/utils/useLocation';
 
 import useDomFilters, {FilterFields} from './useDomFilters';
@@ -18,64 +19,49 @@ const mockBrowserHistoryPush = browserHistory.push as jest.MockedFunction<
 >;
 
 const ACTION_1_DEBUG = {
-  crumb: {
-    type: BreadcrumbType.DEBUG,
-    timestamp: '2022-09-20T16:32:39.961Z',
-    level: BreadcrumbLevelType.INFO,
-    category: 'default',
-    data: {
-      action: 'largest-contentful-paint',
-      duration: 0,
-      size: 17782,
-      nodeId: 1126,
-      label: 'LCP',
-    },
-    id: 21,
-    color: 'purple300',
-    description: 'Debug',
-  },
+  frame: hydrateSpans(TestStubs.ReplayRecord(), [
+    TestStubs.Replay.LargestContentfulPaintFrame({
+      startTimestamp: new Date(1663691559961),
+      endTimestamp: new Date(1663691559962),
+      data: {
+        nodeId: 1126,
+        size: 17782,
+        value: 0,
+      },
+    }),
+  ])[0],
   html: '<div class="css-vruter e1weinmj3">HTTP 400 (invalid_grant): The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.</div>',
   timestamp: 1663691559961,
-} as Extraction;
-
-const ACTION_2_UI = {
-  crumb: {
-    type: BreadcrumbType.UI,
-    timestamp: '2022-09-20T16:32:50.812Z',
-    category: 'ui.click',
-    message: 'li.active > a.css-c5vwnq.e1ycxor00 > span.css-507rzt.e1lk5gpt0',
-    data: {
-      nodeId: 424,
-    },
-    id: 4,
-    color: 'purple300',
-    description: 'User Action',
-    level: BreadcrumbLevelType.UNDEFINED,
-  },
+};
+
+const ACTION_2_CLICK = {
+  frame: hydrateBreadcrumbs(TestStubs.ReplayRecord(), [
+    TestStubs.Replay.ClickFrame({
+      timestamp: new Date(1663691570812),
+      data: {
+        nodeId: 424,
+      },
+    }),
+  ])[0],
   html: '<span aria-describedby="tooltip-nxf8deymg3" class="css-507rzt e1lk5gpt0">Ignored <span type="default" class="css-2uol17 e1gotaso0"><span><!-- 1 descendents --></span></span></span>',
   timestamp: 1663691570812,
-} as Extraction;
-
-const ACTION_3_UI = {
-  crumb: {
-    type: BreadcrumbType.UI,
-    timestamp: '2022-09-20T16:33:54.529Z',
-    category: 'ui.click',
-    message: 'div > div.exception > pre.exc-message.css-r7tqg9.e1rtpi7z1',
-    data: {
-      nodeId: 9304,
-    },
-    id: 17,
-    color: 'purple300',
-    description: 'User Action',
-    level: BreadcrumbLevelType.UNDEFINED,
-  },
+};
+
+const ACTION_3_CLICK = {
+  frame: hydrateBreadcrumbs(TestStubs.ReplayRecord(), [
+    TestStubs.Replay.ClickFrame({
+      timestamp: new Date(1663691634529),
+      data: {
+        nodeId: 9304,
+      },
+    }),
+  ])[0],
   html: '<div class="loadmore" style="display: block;">Load more..</div>',
   timestamp: 1663691634529,
-} as Extraction;
+};
 
 describe('useDomFilters', () => {
-  const actions: Extraction[] = [ACTION_1_DEBUG, ACTION_2_UI, ACTION_3_UI];
+  const actions: Extraction[] = [ACTION_1_DEBUG, ACTION_2_CLICK, ACTION_3_CLICK];
 
   beforeEach(() => {
     mockBrowserHistoryPush.mockReset();
@@ -133,7 +119,7 @@ describe('useDomFilters', () => {
     mockUseLocation.mockReturnValue({
       pathname: '/',
       query: {
-        f_d_type: ['ui'],
+        f_d_type: ['ui.click'],
       },
     } as Location<FilterFields>);
 
@@ -158,7 +144,7 @@ describe('useDomFilters', () => {
       pathname: '/',
       query: {
         f_d_search: 'aria',
-        f_d_type: ['ui'],
+        f_d_type: ['ui.click'],
       },
     } as Location<FilterFields>);
 
@@ -167,24 +153,24 @@ describe('useDomFilters', () => {
   });
 });
 
-describe('getDomMutationsTypes', () => {
+describe('getMutationsTypes', () => {
   it('should return a sorted list of BreadcrumbType', () => {
-    const actions = [ACTION_1_DEBUG, ACTION_2_UI];
+    const actions = [ACTION_1_DEBUG, ACTION_2_CLICK];
 
     const {result} = reactHooks.renderHook(useDomFilters, {initialProps: {actions}});
     expect(result.current.getMutationsTypes()).toStrictEqual([
-      {label: BreadcrumbType.DEBUG, value: BreadcrumbType.DEBUG},
-      {label: BreadcrumbType.UI, value: BreadcrumbType.UI},
+      {label: 'LCP', value: 'largest-contentful-paint'},
+      {label: 'Click', value: 'ui.click'},
     ]);
   });
 
   it('should deduplicate BreadcrumbType', () => {
-    const actions = [ACTION_1_DEBUG, ACTION_2_UI, ACTION_3_UI];
+    const actions = [ACTION_1_DEBUG, ACTION_2_CLICK, ACTION_3_CLICK];
 
     const {result} = reactHooks.renderHook(useDomFilters, {initialProps: {actions}});
     expect(result.current.getMutationsTypes()).toStrictEqual([
-      {label: BreadcrumbType.DEBUG, value: BreadcrumbType.DEBUG},
-      {label: BreadcrumbType.UI, value: BreadcrumbType.UI},
+      {label: 'LCP', value: 'largest-contentful-paint'},
+      {label: 'Click', value: 'ui.click'},
     ]);
   });
 });

+ 17 - 9
static/app/views/replays/detail/domMutations/useDomFilters.tsx

@@ -1,8 +1,10 @@
 import {useCallback, useMemo} from 'react';
+import uniq from 'lodash/uniq';
 
 import {decodeList, decodeScalar} from 'sentry/utils/queryString';
 import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
 import useFiltersInLocationQuery from 'sentry/utils/replays/hooks/useFiltersInLocationQuery';
+import {frameOpOrCategory} from 'sentry/utils/replays/types';
 import {filterItems} from 'sentry/views/replays/detail/utils';
 
 export type FilterFields = {
@@ -23,10 +25,21 @@ type Return = {
   type: string[];
 };
 
+const TYPE_TO_LABEL: Record<string, string> = {
+  'ui.slowClickDetected': 'Slow & Dead Click',
+  'largest-contentful-paint': 'LCP',
+  'ui.click': 'Click',
+  'ui.keyDown': 'KeyDown',
+  'ui.input': 'Input',
+};
+
+function typeToLabel(val: string): string {
+  return TYPE_TO_LABEL[val] ?? 'Unknown';
+}
+
 const FILTERS = {
   type: (item: Extraction, type: string[]) =>
-    type.length === 0 || type.includes(item.crumb.type),
-
+    type.length === 0 || type.includes(frameOpOrCategory(item.frame)),
   searchTerm: (item: Extraction, searchTerm: string) =>
     JSON.stringify(item.html).toLowerCase().includes(searchTerm),
 };
@@ -52,14 +65,9 @@ function useDomFilters({actions}: Options): Return {
 
   const getMutationsTypes = useCallback(
     () =>
-      Array.from(
-        new Set(actions.map(mutation => mutation.crumb.type as string).concat(type))
-      )
+      uniq(actions.map(mutation => frameOpOrCategory(mutation.frame)).concat(type))
         .sort()
-        .map(value => ({
-          value,
-          label: value,
-        })),
+        .map(value => ({value, label: typeToLabel(value)})),
     [actions, type]
   );
 

+ 0 - 7
static/app/views/replays/detail/domMutations/utils.tsx

@@ -1,7 +0,0 @@
-import type {BreadcrumbType} from 'sentry/types/breadcrumbs';
-import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
-
-export const getDomMutationsTypes = (actions: Extraction[]) =>
-  Array.from(
-    new Set<BreadcrumbType>(actions.map(mutation => mutation.crumb.type))
-  ).sort();

Some files were not shown because too many files changed in this diff