Browse Source

feat(replays): Highlight the searched-for dom node when loading up replay details (#48870)

Workflow:
1. Do a search on the replay index for one of our `click.*` fields. 
- In this example I searched for `click.tag:button` which should return
lots of replays as results
2. Click a replay
3. Notice when the details page loads, the first instance of a
`<button>` that was clicked is given focus with this 'spotlight'
treatment


![SCR-20230510-hulf](https://github.com/getsentry/sentry/assets/187460/b6b931aa-f00a-4658-8068-469232196cb7)

The highlighting necessarily goes away whenever you start to interact
with the page.

Fixes https://github.com/getsentry/sentry/issues/48252
Ryan Albrecht 1 year ago
parent
commit
ea907e8318

+ 1 - 1
static/app/components/events/eventReplay/replayPreview.tsx

@@ -99,7 +99,7 @@ function ReplayPreview({orgSlug, replaySlug, event}: Props) {
     <ReplayContextProvider
       isFetching={fetching}
       replay={replay}
-      initialTimeOffsetMs={initialTimeOffsetMs}
+      initialTimeOffsetMs={{offsetMs: initialTimeOffsetMs}}
     >
       <PlayerContainer data-test-id="player-container">
         <StaticPanel>

+ 35 - 7
static/app/components/replays/replayContext.tsx

@@ -11,6 +11,7 @@ import {
   highlightNode,
   removeHighlightedNode,
 } from 'sentry/utils/replays/highlightNode';
+import type useInitialOffsetMs from 'sentry/utils/replays/hooks/useInitialTimeOffsetMs';
 import useRAF from 'sentry/utils/replays/hooks/useRAF';
 import type ReplayReader from 'sentry/utils/replays/replayReader';
 import useOrganization from 'sentry/utils/useOrganization';
@@ -27,9 +28,11 @@ type ReplayConfig = {
 type Dimensions = {height: number; width: number};
 type RootElem = null | HTMLDivElement;
 
+// See also: Highlight in static/app/views/replays/types.tsx
 type HighlightParams = {
   nodeId: number;
   annotation?: string;
+  spotlight?: boolean;
 };
 
 // Important: Don't allow context Consumers to access `Replayer` directly.
@@ -192,7 +195,7 @@ type Props = {
   /**
    * Time, in seconds, when the video should start
    */
-  initialTimeOffsetMs?: number;
+  initialTimeOffsetMs?: ReturnType<typeof useInitialOffsetMs>;
 
   /**
    * Override return fields for testing
@@ -212,7 +215,7 @@ function updateSavedReplayConfig(config: ReplayConfig) {
 
 export function Provider({
   children,
-  initialTimeOffsetMs = 0,
+  initialTimeOffsetMs,
   isFetching,
   replay,
   value = {},
@@ -254,13 +257,13 @@ export function Provider({
     setFFSpeed(0);
   };
 
-  const highlight = useCallback(({nodeId, annotation}: HighlightParams) => {
+  const highlight = useCallback(({nodeId, annotation, spotlight}: HighlightParams) => {
     const replayer = replayerRef.current;
     if (!replayer) {
       return;
     }
 
-    highlightNode({replayer, nodeId, annotation});
+    highlightNode({replayer, nodeId, annotation, spotlight});
   }, []);
 
   const clearAllHighlightsCallback = useCallback(() => {
@@ -495,14 +498,14 @@ export function Provider({
 
   // Only on pageload: set the initial playback timestamp
   useEffect(() => {
-    if (initialTimeOffsetMs && events && replayerRef.current) {
-      setCurrentTime(initialTimeOffsetMs);
+    if (initialTimeOffsetMs?.offsetMs && events && replayerRef.current) {
+      setCurrentTime(initialTimeOffsetMs.offsetMs);
     }
 
     return () => {
       unMountedRef.current = true;
     };
-  }, [events, replayerRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
+  }, [events, initialTimeOffsetMs, setCurrentTime]);
 
   const currentPlayerTime = useCurrentTime(getCurrentTime);
 
@@ -513,6 +516,31 @@ export function Provider({
       ? [true, buffer.target]
       : [false, currentPlayerTime];
 
+  // Only on pageload: highlight the node that relates to the initialTimeOffset
+  useEffect(() => {
+    if (
+      !isBuffering &&
+      initialTimeOffsetMs?.highlight &&
+      events &&
+      events?.length >= 2 &&
+      replayerRef.current
+    ) {
+      const highlightArgs = initialTimeOffsetMs.highlight;
+      highlight(highlightArgs);
+      setTimeout(() => {
+        clearAllHighlightsCallback();
+        highlight(highlightArgs);
+      });
+    }
+  }, [
+    clearAllHighlightsCallback,
+    events,
+    dimensions,
+    highlight,
+    initialTimeOffsetMs,
+    isBuffering,
+  ]);
+
   useEffect(() => {
     if (!isBuffering && buffer.target !== -1) {
       setBufferTime({target: -1, previous: -1});

+ 33 - 11
static/app/utils/replays/highlightNode.tsx

@@ -9,6 +9,7 @@ interface AddHighlightParams {
   replayer: Replayer;
   annotation?: string;
   color?: string;
+  spotlight?: boolean;
 }
 
 interface RemoveHighlightParams {
@@ -57,6 +58,7 @@ export function highlightNode({
   nodeId,
   annotation = '',
   color,
+  spotlight,
 }: AddHighlightParams) {
   // @ts-expect-error mouseTail is private
   const {mouseTail, wrapper} = replayer;
@@ -83,7 +85,7 @@ export function highlightNode({
   // removeHighlight() method.
   const canvas = mouseTail.cloneNode();
 
-  const ctx = canvas.getContext('2d');
+  const ctx = canvas.getContext('2d') as undefined | CanvasRenderingContext2D;
 
   if (!ctx) {
     return null;
@@ -91,10 +93,15 @@ export function highlightNode({
 
   // TODO(replays): Does not account for scrolling (should we attempt to keep highlight visible, or does it disappear)
 
-  // Draw a rectangle to highlight element
   ctx.fillStyle = highlightColor;
-  ctx.fillRect(left, top, width, height);
-
+  if (spotlight) {
+    // Create a screen over the whole area, so only the highlighted part is normal
+    ctx.fillRect(0, 0, canvas.width, canvas.height);
+    ctx.clearRect(left, top, width, height);
+  } else {
+    // Draw a rectangle to highlight element
+    ctx.fillRect(left, top, width, height);
+  }
   // Draw a dashed border around highlight
   ctx.beginPath();
   ctx.setLineDash([5, 5]);
@@ -109,15 +116,30 @@ export function highlightNode({
   ctx.textAlign = 'right';
   ctx.textBaseline = 'bottom';
 
-  const textWidth = ctx.measureText(annotation).width;
+  const {width: textWidth} = ctx.measureText(annotation);
+  const textHeight = 30;
+
+  if (height <= textHeight + 10) {
+    // Draw the text outside the box
 
-  // Draw rect around text
-  ctx.fillStyle = 'rgba(30, 30, 30, 0.75)';
-  ctx.fillRect(left + width - textWidth, top + height - 30, textWidth, 30);
+    // Draw rect around text
+    ctx.fillStyle = 'rgba(30, 30, 30, 0.75)';
+    ctx.fillRect(left, top + height, textWidth, textHeight);
 
-  // Draw text
-  ctx.fillStyle = 'white';
-  ctx.fillText(annotation, left + width, top + height);
+    // Draw text
+    ctx.fillStyle = 'white';
+    ctx.fillText(annotation, left + textWidth, top + height + textHeight);
+  } else {
+    // Draw the text inside the clicked element
+
+    // Draw rect around text
+    ctx.fillStyle = 'rgba(30, 30, 30, 0.75)';
+    ctx.fillRect(left + width - textWidth, top + height - 30, textWidth, 30);
+
+    // Draw text
+    ctx.fillStyle = 'white';
+    ctx.fillText(annotation, left + width, top + height);
+  }
 
   highlightsByNodeId.set(nodeId, {
     canvas,

+ 26 - 10
static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx

@@ -51,7 +51,7 @@ describe('useInitialTimeOffsetMs', () => {
       });
       await waitForNextUpdate();
 
-      expect(result.current).toBe(23 * 1000);
+      expect(result.current).toStrictEqual({offsetMs: 23 * 1000});
     });
 
     it('should prefer reading `t` over the other qs params', async () => {
@@ -72,7 +72,7 @@ describe('useInitialTimeOffsetMs', () => {
       });
       await waitForNextUpdate();
 
-      expect(result.current).toBe(23 * 1000);
+      expect(result.current).toStrictEqual({offsetMs: 23 * 1000});
       expect(MockFetchReplayClicks).toHaveBeenCalledTimes(0);
     });
   });
@@ -95,7 +95,7 @@ describe('useInitialTimeOffsetMs', () => {
       await waitForNextUpdate();
 
       // Expecting 5 minutes difference, in ms
-      expect(result.current).toBe(5 * 60 * 1000);
+      expect(result.current).toStrictEqual({offsetMs: 5 * 60 * 1000});
     });
 
     it('should return 0 offset if there is no replayStartTimetsamp, then recalculate when the startTimestamp appears', async () => {
@@ -114,7 +114,7 @@ describe('useInitialTimeOffsetMs', () => {
       );
 
       await waitForNextUpdate();
-      expect(result.current).toBe(0);
+      expect(result.current).toStrictEqual({offsetMs: 0});
 
       rerender({
         orgSlug: organization.slug,
@@ -125,7 +125,7 @@ describe('useInitialTimeOffsetMs', () => {
       await waitForNextUpdate();
 
       // Expecting 5 minutes difference, in ms
-      expect(result.current).toBe(5 * 60 * 1000);
+      expect(result.current).toStrictEqual({offsetMs: 5 * 60 * 1000});
     });
 
     it('should prefer reading `event_t` over the other search query params', async () => {
@@ -149,7 +149,7 @@ describe('useInitialTimeOffsetMs', () => {
       });
       await waitForNextUpdate();
 
-      expect(result.current).toBe(5 * 60 * 1000);
+      expect(result.current).toStrictEqual({offsetMs: 5 * 60 * 1000});
       expect(MockFetchReplayClicks).toHaveBeenCalledTimes(0);
     });
   });
@@ -169,7 +169,7 @@ describe('useInitialTimeOffsetMs', () => {
       await waitForNextUpdate();
 
       expect(MockFetchReplayClicks).toHaveBeenCalledTimes(0);
-      expect(result.current).toBe(0);
+      expect(result.current).toStrictEqual({offsetMs: 0});
     });
 
     it('should request a list of click results, and calculate the offset from the first result', async () => {
@@ -192,7 +192,14 @@ describe('useInitialTimeOffsetMs', () => {
 
       expect(MockFetchReplayClicks).toHaveBeenCalledTimes(1);
       // Expecting 5 minutes difference, in ms
-      expect(result.current).toBe(5 * 60 * 1000);
+      expect(result.current).toStrictEqual({
+        highlight: {
+          annotation: 'click.tag:button',
+          nodeId: 7,
+          spotlight: true,
+        },
+        offsetMs: 5 * 60 * 1000,
+      });
     });
 
     it('should not call call fetch twice when props change', async () => {
@@ -217,7 +224,9 @@ describe('useInitialTimeOffsetMs', () => {
       await waitForNextUpdate();
 
       expect(MockFetchReplayClicks).toHaveBeenCalledTimes(0);
-      expect(result.current).toBe(0);
+      expect(result.current).toStrictEqual({
+        offsetMs: 0,
+      });
 
       rerender({
         orgSlug: organization.slug,
@@ -228,7 +237,14 @@ describe('useInitialTimeOffsetMs', () => {
       await waitForNextUpdate();
 
       expect(MockFetchReplayClicks).toHaveBeenCalledTimes(1);
-      expect(result.current).toBe(5 * 60 * 1000);
+      expect(result.current).toStrictEqual({
+        highlight: {
+          annotation: 'click.tag:button',
+          nodeId: 7,
+          spotlight: true,
+        },
+        offsetMs: 5 * 60 * 1000,
+      });
     });
   });
 });

+ 36 - 14
static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx

@@ -48,16 +48,29 @@ type Opts = {
   replayStartTimestampMs?: number;
 };
 
-function fromOffset({offsetSec}) {
+type Result =
+  | undefined
+  | {
+      offsetMs: number;
+      highlight?: {
+        nodeId: number;
+        annotation?: string;
+        spotlight?: boolean;
+      };
+    };
+
+const ZERO_OFFSET = {offsetMs: 0};
+
+function fromOffset({offsetSec}): Result {
   if (offsetSec === undefined) {
     // Not using this strategy
     return undefined;
   }
 
-  return Number(offsetSec) * 1000;
+  return {offsetMs: Number(offsetSec) * 1000};
 }
 
-function fromEventTimestamp({eventTimestamp, replayStartTimestampMs}) {
+function fromEventTimestamp({eventTimestamp, replayStartTimestampMs}): Result {
   if (eventTimestamp === undefined) {
     // Not using this strategy
     return undefined;
@@ -66,11 +79,11 @@ function fromEventTimestamp({eventTimestamp, replayStartTimestampMs}) {
   if (replayStartTimestampMs !== undefined) {
     const eventTimestampMs = new Date(eventTimestamp).getTime();
     if (eventTimestampMs >= replayStartTimestampMs) {
-      return eventTimestampMs - replayStartTimestampMs;
+      return {offsetMs: eventTimestampMs - replayStartTimestampMs};
     }
   }
   // The strategy failed, default to something safe
-  return 0;
+  return ZERO_OFFSET;
 }
 
 async function fromListPageQuery({
@@ -80,7 +93,7 @@ async function fromListPageQuery({
   replayId,
   projectSlug,
   replayStartTimestampMs,
-}) {
+}): Promise<Result> {
   if (listPageQuery === undefined) {
     // Not using this strategy
     return undefined;
@@ -96,7 +109,7 @@ async function fromListPageQuery({
 
   if (replayStartTimestampMs === undefined) {
     // Using the strategy, but we must wait for replayStartTimestampMs to appear
-    return 0;
+    return ZERO_OFFSET;
   }
 
   if (!projectSlug) {
@@ -111,14 +124,23 @@ async function fromListPageQuery({
     query: listPageQuery,
   });
   if (!results.clicks.length) {
-    return 0;
+    return ZERO_OFFSET;
   }
   try {
-    const firstTimestamp = first(results.clicks)!.timestamp;
+    const firstResult = first(results.clicks)!;
+    const firstTimestamp = firstResult!.timestamp;
+    const nodeId = firstResult!.node_id;
     const firstTimestmpMs = new Date(firstTimestamp).getTime();
-    return firstTimestmpMs - replayStartTimestampMs;
+    return {
+      highlight: {
+        annotation: listPageQuery,
+        nodeId,
+        spotlight: true,
+      },
+      offsetMs: firstTimestmpMs - replayStartTimestampMs,
+    };
   } catch {
-    return 0;
+    return ZERO_OFFSET;
   }
 }
 
@@ -127,12 +149,12 @@ function useInitialTimeOffsetMs({
   replayId,
   projectSlug,
   replayStartTimestampMs,
-}: Opts) {
+}: Opts): Result {
   const api = useApi();
   const {
     query: {event_t: eventTimestamp, query: listPageQuery, t: offsetSec},
   } = useLocation<TimeOffsetLocationQueryParams>();
-  const [timestamp, setTimestamp] = useState<undefined | number>(undefined);
+  const [timestamp, setTimestamp] = useState<Result>(undefined);
 
   // The different strategies for getting a time offset into the replay (what
   // time to start the replay at)
@@ -170,7 +192,7 @@ function useInitialTimeOffsetMs({
       .then(definedOrDefault(offsetTimeMs))
       .then(definedOrDefault(eventTimeMs))
       .then(definedOrDefault(queryTimeMs))
-      .then(definedOrDefault(0))
+      .then(definedOrDefault(ZERO_OFFSET))
       .then(setTimestamp);
   }, [offsetTimeMs, eventTimeMs, queryTimeMs, projectSlug]);
 

+ 2 - 0
static/app/views/replays/types.tsx

@@ -147,6 +147,8 @@ export type ReplaySegment = {
 
 /**
  * Highlight Replay Plugin types
+ *
+ * See also HighlightParams in static/app/components/replays/replayContext.tsx
  */
 export interface Highlight {
   nodeId: number;