Просмотр исходного кода

feat(replay): Visually compare & diff react hydration errors on the Replay Details page (#61477)

Reads the new `session-replay-show-hydration-errors` feature flag which
controls if we show `replay.hydrate-error` crumbs on the Replay Details
page.

These crumbs have a poor design right now, but they have a button which
will open a Modal that shows a visual side-by-side comparison of before
& after the hydration error happened. Also, it shows a diff of the html.

<img width="1426" alt="SCR-20231208-ninr"
src="https://github.com/getsentry/sentry/assets/187460/2b5b172d-024d-4535-98d9-86493405c523">


Depends on https://github.com/getsentry/sentry/pull/61612
See related SDK change:
https://github.com/getsentry/sentry-javascript/pull/9759

fixes #61613

---------

Co-authored-by: Scott Cooper <scttcper@gmail.com>
Ryan Albrecht 1 год назад
Родитель
Сommit
7dea10a1e0

+ 8 - 5
static/app/components/events/eventReplay/index.spec.tsx

@@ -19,11 +19,14 @@ jest.mock('sentry/utils/replays/hooks/useReplayReader');
 jest.mock('sentry/utils/useProjects');
 
 const now = new Date();
-const mockReplay = ReplayReader.factory({
-  replayRecord: ReplayRecordFixture({started_at: now}),
-  errors: [],
-  attachments: RRWebInitFrameEvents({timestamp: now}),
-});
+const mockReplay = ReplayReader.factory(
+  {
+    replayRecord: ReplayRecordFixture({started_at: now}),
+    errors: [],
+    attachments: RRWebInitFrameEvents({timestamp: now}),
+  },
+  {}
+);
 
 jest.mocked(useReplayReader).mockImplementation(() => {
   return {

+ 15 - 12
static/app/components/events/eventReplay/replayPreview.spec.tsx

@@ -36,18 +36,21 @@ jest.mock('screenfull', () => ({
 }));
 
 // Get replay data with the mocked replay reader params
-const mockReplay = ReplayReader.factory({
-  replayRecord: ReplayRecordFixture({
-    browser: {
-      name: 'Chrome',
-      version: '110.0.0',
-    },
-  }),
-  errors: [],
-  attachments: RRWebInitFrameEvents({
-    timestamp: new Date('Sep 22, 2022 4:58:39 PM UTC'),
-  }),
-});
+const mockReplay = ReplayReader.factory(
+  {
+    replayRecord: ReplayRecordFixture({
+      browser: {
+        name: 'Chrome',
+        version: '110.0.0',
+      },
+    }),
+    errors: [],
+    attachments: RRWebInitFrameEvents({
+      timestamp: new Date('Sep 22, 2022 4:58:39 PM UTC'),
+    }),
+  },
+  {}
+);
 
 mockUseReplayReader.mockImplementation(() => {
   return {

+ 21 - 1
static/app/components/replays/breadcrumbs/breadcrumbItem.tsx

@@ -6,6 +6,8 @@ import {CodeSnippet} from 'sentry/components/codeSnippet';
 import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import ObjectInspector from 'sentry/components/objectInspector';
 import PanelItem from 'sentry/components/panels/panelItem';
+import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton';
+import {useReplayContext} from 'sentry/components/replays/replayContext';
 import {Tooltip} from 'sentry/components/tooltip';
 import {space} from 'sentry/styles/space';
 import {Extraction} from 'sentry/utils/replays/extractDomNodes';
@@ -20,6 +22,8 @@ import TimestampButton from 'sentry/views/replays/detail/timestampButton';
 
 type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent<HTMLElement>) => void;
 
+const FRAMES_WITH_BUTTONS = ['replay.hydrate-error'];
+
 interface Props {
   extraction: Extraction | undefined;
   frame: ReplayFrame;
@@ -63,10 +67,13 @@ function BreadcrumbItem({
 }: Props) {
   const {color, description, projectSlug, title, icon, timestampMs} =
     getCrumbOrFrameData(frame);
+  const {replay} = useReplayContext();
+
+  const forceSpan = 'category' in frame && FRAMES_WITH_BUTTONS.includes(frame.category);
 
   return (
     <CrumbItem
-      as={onClick ? 'button' : 'span'}
+      as={onClick && !forceSpan ? 'button' : 'span'}
       onClick={e => onClick?.(frame, e)}
       onMouseEnter={e => onMouseEnter(frame, e)}
       onMouseLeave={e => onMouseLeave(frame, e)}
@@ -105,6 +112,19 @@ function BreadcrumbItem({
           </InspectorWrapper>
         )}
 
+        {'data' in frame && frame.data && 'mutations' in frame.data ? (
+          <div>
+            <OpenReplayComparisonButton
+              replay={replay}
+              leftTimestamp={frame.offsetMs}
+              rightTimestamp={
+                (frame.data.mutations.next.timestamp as number) -
+                (replay?.getReplay().started_at.getTime() ?? 0)
+              }
+            />
+          </div>
+        ) : null}
+
         {extraction?.html ? (
           <CodeContainer>
             <CodeSnippet language="html" hideCopyButton>

+ 69 - 0
static/app/components/replays/breadcrumbs/openReplayComparisonButton.tsx

@@ -0,0 +1,69 @@
+import {Fragment, lazy, Suspense} from 'react';
+import {css} from '@emotion/react';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import ReplayReader from 'sentry/utils/replays/replayReader';
+import useOrganization from 'sentry/utils/useOrganization';
+
+const LazyComparisonModal = lazy(
+  () => import('sentry/components/replays/breadcrumbs/replayComparisonModal')
+);
+
+interface Props {
+  leftTimestamp: number;
+  replay: null | ReplayReader;
+  rightTimestamp: number;
+}
+
+export function OpenReplayComparisonButton({
+  leftTimestamp,
+  replay,
+  rightTimestamp,
+}: Props) {
+  const organization = useOrganization();
+
+  return (
+    <Button
+      role="button"
+      size="xs"
+      onClick={() => {
+        openModal(
+          deps => (
+            <Suspense
+              fallback={
+                <Fragment>
+                  <deps.Header closeButton>
+                    <deps.Header>{t('Hydration Error')}</deps.Header>
+                  </deps.Header>
+                  <deps.Body>
+                    <LoadingIndicator />
+                  </deps.Body>
+                </Fragment>
+              }
+            >
+              <LazyComparisonModal
+                replay={replay}
+                organization={organization}
+                leftTimestamp={leftTimestamp}
+                rightTimestamp={rightTimestamp}
+                {...deps}
+              />
+            </Suspense>
+          ),
+          {modalCss}
+        );
+      }}
+    >
+      {t('Open Hydration Diff')}
+    </Button>
+  );
+}
+
+const modalCss = css`
+  width: 95vw;
+  min-height: 80vh;
+  max-height: 95vh;
+`;

+ 145 - 0
static/app/components/replays/breadcrumbs/replayComparisonModal.tsx

@@ -0,0 +1,145 @@
+import {useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+import {DiffEditor} from '@monaco-editor/react';
+import beautify from 'js-beautify';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {Flex} from 'sentry/components/profiling/flex';
+import {
+  Provider as ReplayContextProvider,
+  useReplayContext,
+} from 'sentry/components/replays/replayContext';
+import ReplayPlayer from 'sentry/components/replays/replayPlayer';
+import {TabList} from 'sentry/components/tabs';
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import {useLegacyStore} from 'sentry/stores/useLegacyStore';
+import {space} from 'sentry/styles/space';
+import {Organization} from 'sentry/types';
+import ReplayReader from 'sentry/utils/replays/replayReader';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+
+interface Props extends ModalRenderProps {
+  leftTimestamp: number;
+  organization: Organization;
+  replay: null | ReplayReader;
+  rightTimestamp: number;
+}
+
+export default function ReplayComparisonModal({
+  Body,
+  Header,
+  leftTimestamp,
+  organization,
+  replay,
+  rightTimestamp,
+}: Props) {
+  const fetching = false;
+
+  const config = useLegacyStore(ConfigStore);
+  const isDark = config.theme === 'dark';
+
+  const [activeTab, setActiveTab] = useState<'visual' | 'html'>('html');
+
+  const [leftBody, setLeftBody] = useState(null);
+  const [rightBody, setRightBody] = useState(null);
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      <Header closeButton>
+        <h4>{t('Hydration Error')}</h4>
+      </Header>
+      <Body>
+        <Flex gap={space(2)} column>
+          <TabList
+            hideBorder
+            selectedKey={activeTab}
+            onSelectionChange={tab => setActiveTab(tab as 'visual' | 'html')}
+          >
+            <TabList.Item key="html">Html Diff</TabList.Item>
+            <TabList.Item key="visual">Visual Diff</TabList.Item>
+          </TabList>
+          <Flex
+            gap={space(2)}
+            style={{
+              // Using css to hide since the splitdiff uses the html from the iframes
+              // TODO: This causes a bit of a flash when switching tabs
+              display: activeTab === 'visual' ? undefined : 'none',
+            }}
+          >
+            <ReplayContextProvider
+              isFetching={fetching}
+              replay={replay}
+              initialTimeOffsetMs={{offsetMs: leftTimestamp - 1}}
+            >
+              <ComparisonSideWrapper id="leftSide">
+                <ReplaySide
+                  selector="#leftSide iframe"
+                  expectedTime={leftTimestamp - 1}
+                  onLoad={setLeftBody}
+                />
+              </ComparisonSideWrapper>
+            </ReplayContextProvider>
+            <ReplayContextProvider
+              isFetching={fetching}
+              replay={replay}
+              initialTimeOffsetMs={{offsetMs: rightTimestamp + 1}}
+            >
+              <ComparisonSideWrapper id="rightSide">
+                <ReplaySide
+                  selector="#rightSide iframe"
+                  expectedTime={rightTimestamp + 1}
+                  onLoad={setRightBody}
+                />
+              </ComparisonSideWrapper>
+            </ReplayContextProvider>
+          </Flex>
+          {activeTab === 'html' && leftBody && rightBody ? (
+            <div>
+              <DiffEditor
+                height="60vh"
+                theme={isDark ? 'vs-dark' : 'light'}
+                language="html"
+                original={leftBody}
+                modified={rightBody}
+                options={{
+                  // Options - https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IDiffEditorConstructionOptions.html
+                  scrollBeyondLastLine: false,
+                  readOnly: true,
+                }}
+              />
+            </div>
+          ) : null}
+        </Flex>
+      </Body>
+    </OrganizationContext.Provider>
+  );
+}
+
+function ReplaySide({expectedTime, selector, onLoad}) {
+  const {currentTime} = useReplayContext();
+
+  useEffect(() => {
+    if (currentTime === expectedTime) {
+      setTimeout(() => {
+        const iframe = document.querySelector(selector) as HTMLIFrameElement;
+        const body = iframe.contentWindow?.document.body;
+        if (body) {
+          onLoad(
+            beautify.html(body.innerHTML, {
+              indent_size: 2,
+              wrap_line_length: 80,
+            })
+          );
+        }
+      }, 0);
+    }
+  }, [currentTime, expectedTime, selector, onLoad]);
+  return <ReplayPlayer isPreview />;
+}
+
+const ComparisonSideWrapper = styled('div')`
+  display: contents;
+  flex-grow: 1;
+  max-width: 50%;
+`;

+ 4 - 2
static/app/utils/replays/getFrameDetails.tsx

@@ -151,9 +151,11 @@ const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
     title: 'Replay',
     icon: <IconWarning size="xs" />,
   }),
-  'replay.hydrate': frame => ({
+  'replay.hydrate-error': () => ({
     color: 'red300',
-    description: frame.data.mutations,
+    description: t(
+      'There was a conflict between the server rendered html and the first client render.'
+    ),
     tabKey: TabKey.BREADCRUMBS,
     title: 'Hydration Error',
     icon: <IconFire size="xs" />,

+ 1 - 1
static/app/utils/replays/getReplayEvent.spec.tsx

@@ -7,7 +7,7 @@ import {
 } from 'sentry/utils/replays/getReplayEvent';
 import hydrateBreadcrumbs from 'sentry/utils/replays/hydrateBreadcrumbs';
 
-const mockRRWebFrames = []; // This is only needed for replay.hydrate breadcrumbs.
+const mockRRWebFrames = []; // This is only needed for replay.hydrate-error breadcrumbs.
 
 const frames = hydrateBreadcrumbs(
   ReplayRecordFixture({

+ 9 - 0
static/app/utils/replays/hooks/useReplayReader.spec.tsx

@@ -2,6 +2,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
 import {reactHooks} from 'sentry-test/reactTestingLibrary';
 
 import useReplayReader from 'sentry/utils/replays/hooks/useReplayReader';
+import {OrganizationContext} from 'sentry/views/organizationContext';
 
 jest.mock('sentry/utils/replays/hooks/useReplayData', () => ({
   __esModule: true,
@@ -10,6 +11,12 @@ jest.mock('sentry/utils/replays/hooks/useReplayData', () => ({
 
 const {organization, project} = initializeOrg();
 
+const wrapper = ({children}: {children?: React.ReactNode}) => (
+  <OrganizationContext.Provider value={organization}>
+    {children}
+  </OrganizationContext.Provider>
+);
+
 describe('useReplayReader', () => {
   beforeEach(() => {
     MockApiClient.clearMockResponses();
@@ -17,6 +24,7 @@ describe('useReplayReader', () => {
 
   it('should accept a replaySlug with project and id parts', () => {
     const {result} = reactHooks.renderHook(useReplayReader, {
+      wrapper,
       initialProps: {
         orgSlug: organization.slug,
         replaySlug: `${project.slug}:123`,
@@ -32,6 +40,7 @@ describe('useReplayReader', () => {
 
   it('should accept a replaySlug with only the replay-id', () => {
     const {result} = reactHooks.renderHook(useReplayReader, {
+      wrapper,
       initialProps: {
         orgSlug: organization.slug,
         replaySlug: `123`,

+ 9 - 2
static/app/utils/replays/hooks/useReplayReader.tsx

@@ -2,6 +2,7 @@ import {useMemo} from 'react';
 
 import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
 import ReplayReader from 'sentry/utils/replays/replayReader';
+import useOrganization from 'sentry/utils/useOrganization';
 
 type Props = {
   orgSlug: string;
@@ -10,6 +11,11 @@ type Props = {
 
 export default function useReplayReader({orgSlug, replaySlug}: Props) {
   const replayId = parseReplayId(replaySlug);
+  const organization = useOrganization();
+
+  const showHydrationErrors = organization.features.includes(
+    'session-replay-show-hydration-errors'
+  );
 
   const {attachments, errors, replayRecord, ...replayData} = useReplayData({
     orgSlug,
@@ -17,8 +23,9 @@ export default function useReplayReader({orgSlug, replaySlug}: Props) {
   });
 
   const replay = useMemo(
-    () => ReplayReader.factory({attachments, errors, replayRecord}),
-    [attachments, errors, replayRecord]
+    () =>
+      ReplayReader.factory({attachments, errors, replayRecord}, {showHydrationErrors}),
+    [attachments, errors, replayRecord, showHydrationErrors]
   );
 
   return {

+ 1 - 1
static/app/utils/replays/hydrateBreadcrumbs.spec.tsx

@@ -8,7 +8,7 @@ import {BreadcrumbFrame} from 'sentry/utils/replays/types';
 
 const ONE_DAY_MS = 60 * 60 * 24 * 1000;
 
-const mockRRWebFrames = []; // This is only needed for replay.hydrate breadcrumbs.
+const mockRRWebFrames = []; // This is only needed for replay.hydrate-error breadcrumbs.
 
 describe('hydrateBreadcrumbs', () => {
   const replayRecord = ReplayRecordFixture({started_at: new Date('2023/12/23')});

Некоторые файлы не были показаны из-за большого количества измененных файлов