Browse Source

feat(replay): Show the current url as a replay video is playing (#34315)

Show the url for the current timestamp above the replay player. The display updates as the video progresses.

Reasoning:
It was possible already know the current url based on the most recent Navigation breadcrumb that appeared before the current timestamp. But I think it's more ergonomic to give that some space on the screen. It makes the replay video more relatable to what you see in the real browser with the url bar.

Fixes #34266
Ryan Albrecht 2 years ago
parent
commit
1f36f5bfea

+ 5 - 4
static/app/components/forms/textCopyInput.tsx

@@ -12,7 +12,7 @@ const Wrapper = styled('div')`
   display: flex;
 `;
 
-const StyledInput = styled('input')<{rtl?: boolean}>`
+export const StyledInput = styled('input')<{rtl?: boolean}>`
   ${inputStyles};
   background-color: ${p => p.theme.backgroundSecondary};
   border-right-width: 0;
@@ -32,7 +32,7 @@ const OverflowContainer = styled('div')`
   border: none;
 `;
 
-const StyledCopyButton = styled(Button)`
+export const StyledCopyButton = styled(Button)`
   flex-shrink: 1;
   border-radius: 0 0.25em 0.25em 0;
   box-shadow: none;
@@ -43,6 +43,7 @@ type Props = {
    * Text to copy
    */
   children: string;
+  className?: string;
   onCopy?: (value: string, event: React.MouseEvent) => void;
   /**
    * Always show the ending of a long overflowing text in input
@@ -92,7 +93,7 @@ class TextCopyInput extends Component<Props> {
   };
 
   render() {
-    const {style, children, rtl} = this.props;
+    const {className, style, children, rtl} = this.props;
 
     /**
      * We are using direction: rtl; to always show the ending of a long overflowing text in input.
@@ -105,7 +106,7 @@ class TextCopyInput extends Component<Props> {
     const inputValue = rtl ? '\u202A' + children + '\u202C' : children;
 
     return (
-      <Wrapper>
+      <Wrapper className={className}>
         <OverflowContainer>
           <StyledInput
             readOnly

+ 12 - 2
static/app/components/replays/replayContext.tsx

@@ -3,6 +3,7 @@ import {useTheme} from '@emotion/react';
 import {Replayer, ReplayerEvents} from 'rrweb';
 import type {eventWithTime} from 'rrweb/typings/types';
 
+import type ReplayReader from 'sentry/utils/replays/replayReader';
 import usePrevious from 'sentry/utils/usePrevious';
 
 import HighlightReplayPlugin from './highlightReplayPlugin';
@@ -66,6 +67,11 @@ type ReplayPlayerContextProps = {
    */
   isSkippingInactive: boolean;
 
+  /**
+   * The core replay data
+   */
+  replay: ReplayReader | null;
+
   /**
    * Jump the video to a specific time
    */
@@ -106,6 +112,7 @@ const ReplayPlayerContext = React.createContext<ReplayPlayerContextProps>({
   isBuffering: false,
   isPlaying: false,
   isSkippingInactive: false,
+  replay: null,
   setCurrentTime: () => {},
   setSpeed: () => {},
   speed: 1,
@@ -115,7 +122,7 @@ const ReplayPlayerContext = React.createContext<ReplayPlayerContextProps>({
 
 type Props = {
   children: React.ReactNode;
-  events: eventWithTime[];
+  replay: ReplayReader;
 
   /**
    * Time, in seconds, when the video should start
@@ -134,7 +141,9 @@ function useCurrentTime(callback: () => number) {
   return currentTime;
 }
 
-export function Provider({children, events, initialTimeOffset = 0, value = {}}: Props) {
+export function Provider({children, replay, initialTimeOffset = 0, value = {}}: Props) {
+  const events = replay.getRRWebEvents();
+
   const theme = useTheme();
   const oldEvents = usePrevious(events);
   const replayerRef = useRef<Replayer>(null);
@@ -334,6 +343,7 @@ export function Provider({children, events, initialTimeOffset = 0, value = {}}:
         isBuffering,
         isPlaying,
         isSkippingInactive,
+        replay,
         setCurrentTime,
         setSpeed,
         speed,

+ 37 - 0
static/app/components/replays/replayCurrentUrl.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import TextCopyInput, {
+  StyledCopyButton,
+  StyledInput,
+} from 'sentry/components/forms/textCopyInput';
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import space from 'sentry/styles/space';
+import getCurrentUrl from 'sentry/utils/replays/getCurrentUrl';
+
+function ReplayCurrentUrl() {
+  const {currentTime, replay} = useReplayContext();
+  if (!replay) {
+    return null;
+  }
+
+  return <UrlCopyInput>{getCurrentUrl(replay, currentTime)}</UrlCopyInput>;
+}
+
+const UrlCopyInput = styled(TextCopyInput)`
+  ${StyledInput} {
+    background: white;
+    border: none;
+    padding: 0 ${space(0.75)};
+    font-size: ${p => p.theme.fontSizeMedium};
+    border-bottom-left-radius: 0;
+  }
+
+  ${StyledCopyButton} {
+    border-top: none;
+    border-right: none;
+    border-bottom: none;
+  }
+`;
+
+export default ReplayCurrentUrl;

+ 34 - 0
static/app/utils/replays/getCurrentUrl.tsx

@@ -0,0 +1,34 @@
+import last from 'lodash/last';
+
+import {transformCrumbs} from 'sentry/components/events/interfaces/breadcrumbs/utils';
+import {BreadcrumbType, BreadcrumbTypeNavigation} from 'sentry/types/breadcrumbs';
+import {EntryType, EventTag} from 'sentry/types/event';
+import type ReplayReader from 'sentry/utils/replays/replayReader';
+
+function findUrlTag(tags: EventTag[]) {
+  return tags.find(tag => tag.key === 'url');
+}
+
+function getCurrentUrl(replay: ReplayReader, currentTime: number) {
+  const event = replay.getEvent();
+  const crumbs = replay.getEntryType(EntryType.BREADCRUMBS)?.data.values || [];
+
+  const currentOffsetMs = Math.floor(currentTime);
+  const startTimestampMs = event.startTimestamp * 1000;
+  const currentTimeMs = startTimestampMs + currentOffsetMs;
+
+  const navigationCrumbs = transformCrumbs(crumbs).filter(
+    crumb => crumb.type === BreadcrumbType.NAVIGATION
+  ) as BreadcrumbTypeNavigation[];
+
+  const initialUrl = findUrlTag(event.tags)?.value || '';
+  const origin = initialUrl ? new URL(initialUrl).origin : '';
+
+  const mostRecentNavigation = last(
+    navigationCrumbs.filter(({timestamp}) => +new Date(timestamp || 0) < currentTimeMs)
+  )?.data?.to;
+
+  return mostRecentNavigation ? origin + mostRecentNavigation : initialUrl;
+}
+
+export default getCurrentUrl;

+ 5 - 4
static/app/views/replays/details.tsx

@@ -10,6 +10,7 @@ import ReplayBreadcrumbOverview from 'sentry/components/replays/breadcrumbs/repl
 import Scrobber from 'sentry/components/replays/player/scrobber';
 import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
 import ReplayController from 'sentry/components/replays/replayController';
+import ReplayCurrentUrl from 'sentry/components/replays/replayCurrentUrl';
 import ReplayPlayer from 'sentry/components/replays/replayPlayer';
 import useFullscreen from 'sentry/components/replays/useFullscreen';
 import {t} from 'sentry/locale';
@@ -81,10 +82,7 @@ function ReplayDetails() {
   }
 
   return (
-    <ReplayContextProvider
-      events={replay.getRRWebEvents()}
-      initialTimeOffset={initialTimeOffset}
-    >
+    <ReplayContextProvider replay={replay} initialTimeOffset={initialTimeOffset}>
       <DetailLayout
         event={replay.getEvent()}
         orgId={orgId}
@@ -93,6 +91,9 @@ function ReplayDetails() {
         <Layout.Body>
           <ReplayLayout ref={fullscreenRef}>
             <Panel>
+              <PanelHeader>
+                <ReplayCurrentUrl />
+              </PanelHeader>
               <PanelHeader disablePadding>
                 <ManualResize isFullscreen={isFullscreen}>
                   <ReplayPlayer />