Browse Source

feat(replays): Deeplink directly to the error timestamp when linking from Issue Details to Replay Details (#39047)

### Changes
Calculate the initialTimeOffset of a Replay inside the Issues page, based on when this instance of the erorr happened relative to the start of the replay. Then start the replay at that timestamp, and include the initialTimeOffset in the `View Details` link (`?t=`).

Linking to Replay Details now also pre-selects the Console tab.

Closes #33887
Jesus Padron 2 years ago
parent
commit
c733c28468

+ 6 - 1
static/app/components/events/eventEntries.tsx

@@ -533,7 +533,12 @@ function MiniReplayView({
 
   if (replayId && hasSessionReplayFeature) {
     return (
-      <EventReplay replayId={replayId} orgSlug={orgSlug} projectSlug={projectSlug} />
+      <EventReplay
+        replayId={replayId}
+        orgSlug={orgSlug}
+        projectSlug={projectSlug}
+        event={event}
+      />
     );
   }
   if (hasEventAttachmentsFeature) {

+ 11 - 25
static/app/components/events/eventReplay/index.tsx

@@ -1,37 +1,23 @@
-import Button from 'sentry/components/button';
 import ErrorBoundary from 'sentry/components/errorBoundary';
-import EventDataSection from 'sentry/components/events/eventDataSection';
 import LazyLoad from 'sentry/components/lazyLoad';
-import {t} from 'sentry/locale';
+import {Event} from 'sentry/types/event';
 
 type Props = {
+  event: Event;
   orgSlug: string;
   projectSlug: string;
   replayId: string;
 };
 
-export default function EventReplay({replayId, orgSlug, projectSlug}: Props) {
+export default function EventReplay({replayId, orgSlug, projectSlug, event}: Props) {
   return (
-    <EventDataSection
-      type="replay"
-      title={t('Replay')}
-      actions={
-        <Button
-          size="sm"
-          priority="primary"
-          to={`/organizations/${orgSlug}/replays/${projectSlug}:${replayId}`}
-        >
-          {t('View Details')}
-        </Button>
-      }
-    >
-      <ErrorBoundary mini>
-        <LazyLoad
-          component={() => import('./replayContent')}
-          replaySlug={`${projectSlug}:${replayId}`}
-          orgSlug={orgSlug}
-        />
-      </ErrorBoundary>
-    </EventDataSection>
+    <ErrorBoundary mini>
+      <LazyLoad
+        component={() => import('./replayContent')}
+        replaySlug={`${projectSlug}:${replayId}`}
+        orgSlug={orgSlug}
+        event={event}
+      />
+    </ErrorBoundary>
   );
 }

+ 98 - 29
static/app/components/events/eventReplay/replayContent.spec.tsx

@@ -1,18 +1,27 @@
 import {render as baseRender, screen} from 'sentry-test/reactTestingLibrary';
 
+import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
 import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
-import ReplayReader from 'sentry/utils/replays/replayReader';
+import ReplayReader, {ReplayReaderParams} from 'sentry/utils/replays/replayReader';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 
 import ReplayContent from './replayContent';
 
-const testOrgSlug = 'sentry-emerging-tech';
-const testReplaySlug = 'replays:761104e184c64d439ee1014b72b4d83b';
+const mockOrgSlug = 'sentry-emerging-tech';
+const mockReplaySlug = 'replays:761104e184c64d439ee1014b72b4d83b';
 
-const mockStartedAt = 'Sep 12, 2022 11:29:13 PM UTC';
-const mockFinishedAt = 'Sep 15, 2022 17:22:07 PM UTC';
+const mockStartedAt = 'Sep 22, 2022 4:58:39 PM UTC';
+const mockFinishedAt = 'Sep 22, 2022 5:00:03 PM UTC';
 
-const mockReplayDuration = 670; // seconds (11 minutes, 10 seconds)
+const mockReplayDuration = 84; // seconds
+
+const mockEvent = {
+  ...TestStubs.Event(),
+  dateCreated: '2022-09-22T16:59:41.596000Z',
+};
+
+const mockButtonHref =
+  '/organizations/sentry-emerging-tech/replays/replays:761104e184c64d439ee1014b72b4d83b/?t=62&t_main=console';
 
 // Mock screenfull library
 jest.mock('screenfull', () => ({
@@ -24,9 +33,9 @@ jest.mock('screenfull', () => ({
   off: jest.fn(),
 }));
 
-// Mock replay object with the props we need for ReplayContent
-const mockReplay: Partial<ReplayReader> = {
-  getReplay: () => ({
+// Mock replay reader params object with the data we need
+const mockReplayReaderParams: ReplayReaderParams = {
+  replayRecord: {
     userAgent:
       'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
     title: '',
@@ -72,32 +81,57 @@ const mockReplay: Partial<ReplayReader> = {
     },
     urls: ['http://localhost:3000/'],
     countUrls: 1,
-  }),
-  getRRWebEvents: () => [
+  },
+  rrwebEvents: [
+    {
+      type: 0,
+      data: {},
+      timestamp: 1663865919000,
+      delay: -198487,
+    },
+    {
+      type: 1,
+      data: {},
+      timestamp: 1663865920587,
+      delay: -135199,
+    },
     {
       type: 4,
       data: {
         href: 'http://localhost:3000/',
         width: 1536,
-        height: 824,
+        height: 722,
       },
-      timestamp: 1663025353247,
+      timestamp: 1663865920587,
+      delay: -135199,
+    },
+  ],
+  breadcrumbs: [
+    {
+      timestamp: 1663865920.851,
+      type: BreadcrumbType.DEFAULT,
+      level: BreadcrumbLevelType.INFO,
+      category: 'ui.focus',
     },
     {
-      type: 4,
+      timestamp: 1663865922.024,
+      type: BreadcrumbType.DEFAULT,
+      level: BreadcrumbLevelType.INFO,
+      category: 'ui.click',
+      message:
+        'input.form-control[type="text"][name="url"][title="Fully qualified URL prefixed with http or https"]',
       data: {
-        href: 'http://localhost:3000/',
-        width: 1536,
-        height: 151,
+        nodeId: 37,
       },
-      timestamp: 1663025450243,
     },
   ],
-  getDurationMs() {
-    return mockReplayDuration * 1000; // milliseconds
-  },
+  spans: [],
+  errors: [],
 };
 
+// Get replay data with the mocked replay reader params
+const mockReplay = ReplayReader.factory(mockReplayReaderParams);
+
 // Mock useReplayData hook to return the mocked replay data
 jest.mock('sentry/utils/replays/hooks/useReplayData', () => {
   return {
@@ -115,7 +149,8 @@ const render: typeof baseRender = children => {
   return baseRender(
     <OrganizationContext.Provider value={TestStubs.Organization()}>
       {children}
-    </OrganizationContext.Provider>
+    </OrganizationContext.Provider>,
+    {context: TestStubs.routerContext()}
   );
 };
 
@@ -129,7 +164,13 @@ describe('ReplayContent', () => {
       };
     });
 
-    render(<ReplayContent orgSlug={testOrgSlug} replaySlug={testReplaySlug} />);
+    render(
+      <ReplayContent
+        orgSlug={mockOrgSlug}
+        replaySlug={mockReplaySlug}
+        event={mockEvent}
+      />
+    );
 
     expect(screen.getByTestId('replay-loading-placeholder')).toBeInTheDocument();
   });
@@ -145,26 +186,54 @@ describe('ReplayContent', () => {
     });
 
     expect(() =>
-      render(<ReplayContent orgSlug={testOrgSlug} replaySlug={testReplaySlug} />)
+      render(
+        <ReplayContent
+          orgSlug={mockOrgSlug}
+          replaySlug={mockReplaySlug}
+          event={mockEvent}
+        />
+      )
     ).toThrow();
   });
 
+  it('Should render details button when there is a replay', () => {
+    render(
+      <ReplayContent
+        orgSlug={mockOrgSlug}
+        replaySlug={mockReplaySlug}
+        event={mockEvent}
+      />
+    );
+
+    const detailButton = screen.getByTestId('replay-details-button');
+    expect(detailButton).toBeInTheDocument();
+
+    // Expect the details button to have the correct href
+    expect(detailButton).toHaveAttribute('href', mockButtonHref);
+  });
+
   it('Should render all its elements correctly', () => {
-    render(<ReplayContent orgSlug={testOrgSlug} replaySlug={testReplaySlug} />);
+    render(
+      <ReplayContent
+        orgSlug={mockOrgSlug}
+        replaySlug={mockReplaySlug}
+        event={mockEvent}
+      />
+    );
 
     // Expect replay view to be rendered
-    expect(screen.getByText('Replay')).toBeInTheDocument();
+    expect(screen.getAllByText('Replay')).toHaveLength(2);
     expect(screen.getByTestId('player-container')).toBeInTheDocument();
 
     // Expect Id to be correct
     expect(screen.getByText('Id')).toBeInTheDocument();
     expect(screen.getByTestId('replay-id')).toHaveTextContent(
-      mockReplay.getReplay?.().id ?? ''
+      mockReplay?.getReplay?.().id ?? ''
     );
 
     // Expect Duration value to be correct
     expect(screen.getByText('URL')).toBeInTheDocument();
-    expect(screen.getByTestId('replay-duration')).toHaveTextContent('11 minutes');
+    expect(screen.getByTestId('replay-duration')).toHaveTextContent('1 minute');
 
     // Expect Timestamp value to be correct
     expect(screen.getByText('Timestamp')).toBeInTheDocument();
@@ -173,7 +242,7 @@ describe('ReplayContent', () => {
     // Expect the URL value to be correct
     expect(screen.getByText('Duration')).toBeInTheDocument();
     expect(screen.getByTestId('replay-url')).toHaveTextContent(
-      mockReplay.getReplay?.().urls[0] ?? ''
+      mockReplay?.getReplay?.().urls[0] ?? ''
     );
   });
 });

+ 101 - 56
static/app/components/events/eventReplay/replayContent.tsx

@@ -1,27 +1,36 @@
+import {useMemo} from 'react';
 import styled from '@emotion/styled';
 
+import Button from 'sentry/components/button';
 import DateTime from 'sentry/components/dateTime';
 import Duration from 'sentry/components/duration';
+import EventDataSection from 'sentry/components/events/eventDataSection';
 import Placeholder from 'sentry/components/placeholder';
 import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
 import ReplayView from 'sentry/components/replays/replayView';
+import {relativeTimeInMs} from 'sentry/components/replays/utils';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
+import {Event} from 'sentry/types/event';
 import useFullscreen from 'sentry/utils/replays/hooks/useFullscreen';
 import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
 import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
 
 type Props = {
+  event: Event;
   orgSlug: string;
   replaySlug: string;
 };
 
-function ReplayContent({orgSlug, replaySlug}: Props) {
+function ReplayContent({orgSlug, replaySlug, event}: Props) {
   const {fetching, replay, fetchError} = useReplayData({
     orgSlug,
     replaySlug,
   });
   const {ref: fullscreenRef, toggle: toggleFullscreen} = useFullscreen();
+  const eventTimestamp = event.dateCreated
+    ? Math.floor(new Date(event.dateCreated).getTime() / 1000) * 1000
+    : 0;
 
   if (fetchError) {
     throw new Error('Failed to load Replay');
@@ -29,63 +38,99 @@ function ReplayContent({orgSlug, replaySlug}: Props) {
 
   const replayRecord = replay?.getReplay();
 
-  if (fetching || !replayRecord) {
-    return (
-      <StyledPlaceholder
-        testId="replay-loading-placeholder"
-        height="400px"
-        width="100%"
-      />
-    );
-  }
+  const startTimestampMs = replayRecord?.startedAt.getTime() ?? 0;
+
+  const initialTimeOffset = useMemo(() => {
+    if (eventTimestamp && startTimestampMs) {
+      return relativeTimeInMs(eventTimestamp, startTimestampMs) / 1000;
+    }
+
+    return 0;
+  }, [eventTimestamp, startTimestampMs]);
 
   return (
-    <table className="table key-value">
-      <tbody>
-        <tr key="replay">
-          <td className="key">{t('Replay')}</td>
-          <td className="value">
-            <ReplayContextProvider replay={replay} initialTimeOffset={0}>
-              <PlayerContainer ref={fullscreenRef} data-test-id="player-container">
-                <ReplayView toggleFullscreen={toggleFullscreen} showAddressBar={false} />
-              </PlayerContainer>
-            </ReplayContextProvider>
-          </td>
-        </tr>
-        <tr key="id">
-          <td className="key">{t('Id')}</td>
-          <td className="value">
-            <pre className="val-string" data-test-id="replay-id">
-              {replayRecord.id}
-            </pre>
-          </td>
-        </tr>
-        <tr key="url">
-          <td className="key">{t('URL')}</td>
-          <td className="value">
-            <pre className="val-string" data-test-id="replay-url">
-              {replayRecord.urls[0]}
-            </pre>
-          </td>
-        </tr>
-        <tr key="timestamp">
-          <td className="key">{t('Timestamp')}</td>
-          <td className="value">
-            <pre className="val-string" data-test-id="replay-timestamp">
-              <DateTime year seconds utc date={replayRecord.startedAt} />
-            </pre>
-          </td>
-        </tr>
-        <tr key="duration">
-          <td className="key">{t('Duration')}</td>
-          <td className="value">
-            <pre className="val-string" data-test-id="replay-duration">
-              <Duration seconds={replayRecord.duration} fixedDigits={0} />
-            </pre>
-          </td>
-        </tr>
-      </tbody>
-    </table>
+    <EventDataSection
+      type="replay"
+      title={t('Replay')}
+      actions={
+        <Button
+          size="sm"
+          priority="primary"
+          to={{
+            pathname: `/organizations/${orgSlug}/replays/${replaySlug}/`,
+            query: {
+              t_main: 'console',
+              f_c_search: undefined,
+              ...(initialTimeOffset ? {t: initialTimeOffset} : {}),
+            },
+          }}
+          data-test-id="replay-details-button"
+        >
+          {t('View Details')}
+        </Button>
+      }
+    >
+      {fetching || !replayRecord ? (
+        <StyledPlaceholder
+          testId="replay-loading-placeholder"
+          height="400px"
+          width="100%"
+        />
+      ) : (
+        <table className="table key-value">
+          <tbody>
+            <tr key="replay">
+              <td className="key">{t('Replay')}</td>
+              <td className="value">
+                <ReplayContextProvider
+                  replay={replay}
+                  initialTimeOffset={initialTimeOffset}
+                >
+                  <PlayerContainer ref={fullscreenRef} data-test-id="player-container">
+                    <ReplayView
+                      toggleFullscreen={toggleFullscreen}
+                      showAddressBar={false}
+                    />
+                  </PlayerContainer>
+                </ReplayContextProvider>
+              </td>
+            </tr>
+            <tr key="id">
+              <td className="key">{t('Id')}</td>
+              <td className="value">
+                <pre className="val-string" data-test-id="replay-id">
+                  {replayRecord.id}
+                </pre>
+              </td>
+            </tr>
+            <tr key="url">
+              <td className="key">{t('URL')}</td>
+              <td className="value">
+                <pre className="val-string" data-test-id="replay-url">
+                  {replayRecord.urls[0]}
+                </pre>
+              </td>
+            </tr>
+            <tr key="timestamp">
+              <td className="key">{t('Timestamp')}</td>
+              <td className="value">
+                <pre className="val-string" data-test-id="replay-timestamp">
+                  <DateTime year seconds utc date={replayRecord.startedAt} />
+                </pre>
+              </td>
+            </tr>
+            <tr key="duration">
+              <td className="key">{t('Duration')}</td>
+              <td className="value">
+                <pre className="val-string" data-test-id="replay-duration">
+                  <Duration seconds={replayRecord.duration} fixedDigits={0} />
+                </pre>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      )}
+    </EventDataSection>
   );
 }
 

+ 1 - 1
static/app/utils/replays/replayReader.tsx

@@ -14,7 +14,7 @@ import type {
   ReplaySpan,
 } from 'sentry/views/replays/types';
 
-interface ReplayReaderParams {
+export interface ReplayReaderParams {
   breadcrumbs: ReplayCrumb[] | undefined;
   errors: ReplayError[] | undefined;
 

+ 3 - 6
static/app/views/replays/types.tsx

@@ -136,12 +136,9 @@ export type MemorySpanType = ReplaySpan<{
   };
 }>;
 
-export type ReplayCrumb = RawCrumb & {
-  /**
-   * Replay crumbs are unprocessed and come in as unix timestamp in seconds
-   */
-  timestamp: number;
-};
+type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
+
+export type ReplayCrumb = Overwrite<RawCrumb, {timestamp: number}>;
 
 /**
  * This is a result of a custom discover query