Browse Source

feat(replay): Calculate hydration diff timestamps based on related hydration breadcrumbs (#73095)

This is the 2nd try at https://github.com/getsentry/sentry/pull/72561
The original was reverted in
https://github.com/getsentry/sentry/commit/986296cf13c5e17ac158e58d9bf45955dc85f5d1
because of some errors that popped up:
-
https://sentry.sentry.io/issues/5505399172/?project=11276&referrer=github-pr-bot
-
https://sentry.sentry.io/issues/5505401256/?project=11276&referrer=github-pr-bot
-
https://sentry.sentry.io/issues/5505436186/?project=11276&referrer=github-pr-bot
-
https://sentry.sentry.io/issues/5505439753/?project=11276&referrer=github-pr-bot
-
https://sentry.sentry.io/issues/5505459069/?project=11276&referrer=github-pr-bot

The difference now is that I've improved the types to include
`data.mutations.next`. The real but though was that before, in
`breadcrumbItem.tsx`, we were doing the left/right timestamp math only
for crumbs that have that mutations.next field...

That problem is fixed now because we're checking the crumb type first,
then defer to the new `<CrumbHydrationButton>` which will get the
offsets and render all at once.
Without that fix, we were basically trying to get the left/right offsets
for any breadcrumb type, which would easily explode.

Related to https://github.com/getsentry/sentry/issues/70199
Ryan Albrecht 8 months ago
parent
commit
50fdd0ec12

+ 6 - 9
static/app/components/events/eventHydrationDiff/replayDiffContent.tsx

@@ -7,6 +7,7 @@ import {ReplayGroupContextProvider} from 'sentry/components/replays/replayGroupC
 import {t} from 'sentry/locale';
 import type {Event} from 'sentry/types/event';
 import type {Group} from 'sentry/types/group';
+import {getReplayDiffOffsetsFromEvent} from 'sentry/utils/replays/getDiffTimestamps';
 import useReplayReader from 'sentry/utils/replays/hooks/useReplayReader';
 
 interface Props {
@@ -31,11 +32,7 @@ export default function ReplayDiffContent({event, group, orgSlug, replaySlug}: P
     return null;
   }
 
-  // TODO: base the event timestamp off the replay data itself.
-  const startTimestampMS =
-    'startTimestamp' in event ? event.startTimestamp * 1000 : undefined;
-  const timeOfEvent = event.dateCreated ?? startTimestampMS ?? event.dateReceived;
-  const eventTimestampMs = timeOfEvent ? Math.floor(new Date(timeOfEvent).getTime()) : 0;
+  const {leftOffsetMs, rightOffsetMs} = getReplayDiffOffsetsFromEvent(replay, event);
 
   return (
     <EventDataSection
@@ -44,9 +41,9 @@ export default function ReplayDiffContent({event, group, orgSlug, replaySlug}: P
       actions={
         <OpenReplayComparisonButton
           key="open-modal-button"
-          leftTimestamp={0}
+          leftOffsetMs={leftOffsetMs}
           replay={replay}
-          rightTimestamp={eventTimestampMs}
+          rightOffsetMs={rightOffsetMs}
           size="xs"
         >
           {t('Open Diff Viewer')}
@@ -57,9 +54,9 @@ export default function ReplayDiffContent({event, group, orgSlug, replaySlug}: P
         <ReplayGroupContextProvider groupId={group?.id} eventId={event.id}>
           <ReplayDiff
             defaultTab={DiffType.VISUAL}
-            leftTimestamp={0}
+            leftOffsetMs={leftOffsetMs}
             replay={replay}
-            rightTimestamp={eventTimestampMs}
+            rightOffsetMs={rightOffsetMs}
           />
         </ReplayGroupContextProvider>
       </ErrorBoundary>

+ 40 - 17
static/app/components/replays/breadcrumbs/breadcrumbItem.tsx

@@ -20,9 +20,21 @@ import {Tooltip} from 'sentry/components/tooltip';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
+import {getReplayDiffOffsetsFromFrame} from 'sentry/utils/replays/getDiffTimestamps';
 import getFrameDetails from 'sentry/utils/replays/getFrameDetails';
-import type {ErrorFrame, FeedbackFrame, ReplayFrame} from 'sentry/utils/replays/types';
-import {isErrorFrame, isFeedbackFrame} from 'sentry/utils/replays/types';
+import type ReplayReader from 'sentry/utils/replays/replayReader';
+import type {
+  ErrorFrame,
+  FeedbackFrame,
+  HydrationErrorFrame,
+  ReplayFrame,
+} from 'sentry/utils/replays/types';
+import {
+  isBreadcrumbFrame,
+  isErrorFrame,
+  isFeedbackFrame,
+  isHydrationErrorFrame,
+} from 'sentry/utils/replays/types';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
 import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
@@ -88,22 +100,10 @@ function BreadcrumbItem({
   }, [description, expandPaths, onInspectorExpanded]);
 
   const renderComparisonButton = useCallback(() => {
-    return frame?.data && 'mutations' in frame.data ? (
-      <div>
-        <OpenReplayComparisonButton
-          replay={replay}
-          leftTimestamp={frame.offsetMs}
-          rightTimestamp={
-            (frame.data.mutations.next?.timestamp ?? 0) -
-            (replay?.getReplay().started_at.getTime() ?? 0)
-          }
-          size="xs"
-        >
-          {t('Open Hydration Diff')}
-        </OpenReplayComparisonButton>
-      </div>
+    return isBreadcrumbFrame(frame) && isHydrationErrorFrame(frame) ? (
+      <CrumbHydrationButton replay={replay} frame={frame} />
     ) : null;
-  }, [frame?.data, frame.offsetMs, replay]);
+  }, [frame, replay]);
 
   const renderCodeSnippet = useCallback(() => {
     return extraction?.html ? (
@@ -187,6 +187,29 @@ function BreadcrumbItem({
   );
 }
 
+function CrumbHydrationButton({
+  replay,
+  frame,
+}: {
+  frame: HydrationErrorFrame;
+  replay: ReplayReader | null;
+}) {
+  const {leftOffsetMs, rightOffsetMs} = getReplayDiffOffsetsFromFrame(replay, frame);
+
+  return (
+    <div>
+      <OpenReplayComparisonButton
+        replay={replay}
+        leftOffsetMs={leftOffsetMs}
+        rightOffsetMs={rightOffsetMs}
+        size="xs"
+      >
+        {t('Open Hydration Diff')}
+      </OpenReplayComparisonButton>
+    </div>
+  );
+}
+
 function CrumbErrorIssue({frame}: {frame: FeedbackFrame | ErrorFrame}) {
   const organization = useOrganization();
   const project = useProjectFromSlug({organization, projectSlug: frame.data.projectSlug});

+ 6 - 6
static/app/components/replays/breadcrumbs/openReplayComparisonButton.tsx

@@ -14,17 +14,17 @@ const LazyComparisonModal = lazy(
 
 interface Props {
   children: ReactNode;
-  leftTimestamp: number;
+  leftOffsetMs: number;
   replay: null | ReplayReader;
-  rightTimestamp: number;
+  rightOffsetMs: number;
   size?: ButtonProps['size'];
 }
 
 export function OpenReplayComparisonButton({
   children,
-  leftTimestamp,
+  leftOffsetMs,
   replay,
-  rightTimestamp,
+  rightOffsetMs,
   size,
 }: Props) {
   const organization = useOrganization();
@@ -59,8 +59,8 @@ export function OpenReplayComparisonButton({
               <LazyComparisonModal
                 replay={replay}
                 organization={organization}
-                leftTimestamp={leftTimestamp}
-                rightTimestamp={rightTimestamp}
+                leftOffsetMs={leftOffsetMs}
+                rightOffsetMs={rightOffsetMs}
                 {...deps}
               />
             </Suspense>

+ 6 - 6
static/app/components/replays/breadcrumbs/replayComparisonModal.tsx

@@ -11,19 +11,19 @@ import type ReplayReader from 'sentry/utils/replays/replayReader';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 
 interface Props extends ModalRenderProps {
-  leftTimestamp: number;
+  leftOffsetMs: number;
   organization: Organization;
   replay: null | ReplayReader;
-  rightTimestamp: number;
+  rightOffsetMs: number;
 }
 
 export default function ReplayComparisonModal({
   Body,
   Header,
-  leftTimestamp,
+  leftOffsetMs,
   organization,
   replay,
-  rightTimestamp,
+  rightOffsetMs,
 }: Props) {
   return (
     <OrganizationContext.Provider value={organization}>
@@ -55,8 +55,8 @@ export default function ReplayComparisonModal({
         </StyledParagraph>
         <ReplayDiff
           replay={replay}
-          leftTimestamp={leftTimestamp}
-          rightTimestamp={rightTimestamp}
+          leftOffsetMs={leftOffsetMs}
+          rightOffsetMs={rightOffsetMs}
         />
       </Body>
     </OrganizationContext.Provider>

+ 8 - 8
static/app/components/replays/replayDiff.tsx

@@ -20,9 +20,9 @@ import type ReplayReader from 'sentry/utils/replays/replayReader';
 const MAX_CLAMP_TO_START = 2000;
 
 interface Props {
-  leftTimestamp: number;
+  leftOffsetMs: number;
   replay: null | ReplayReader;
-  rightTimestamp: number;
+  rightOffsetMs: number;
   defaultTab?: DiffType;
 }
 
@@ -33,16 +33,16 @@ export enum DiffType {
 
 export default function ReplayDiff({
   defaultTab = DiffType.VISUAL,
-  leftTimestamp,
+  leftOffsetMs,
   replay,
-  rightTimestamp,
+  rightOffsetMs,
 }: Props) {
   const fetching = false;
 
   const [leftBody, setLeftBody] = useState(null);
   const [rightBody, setRightBody] = useState(null);
 
-  let startOffset = leftTimestamp - 1;
+  let startOffset = leftOffsetMs - 1;
   // If the error occurs close to the start of the replay, clamp the start offset to 1
   // to help compare with the html provided by the server, This helps with some errors on localhost.
   if (startOffset < MAX_CLAMP_TO_START) {
@@ -96,16 +96,16 @@ export default function ReplayDiff({
                 </ReplayContextProvider>
                 <ReplayContextProvider
                   analyticsContext="replay_comparison_modal_right"
-                  initialTimeOffsetMs={{offsetMs: rightTimestamp + 1}}
+                  initialTimeOffsetMs={{offsetMs: rightOffsetMs + 1}}
                   isFetching={fetching}
                   prefsStrategy={StaticReplayPreferences}
                   replay={replay}
                 >
                   <ComparisonSideWrapper id="rightSide">
-                    {rightTimestamp > 0 ? (
+                    {rightOffsetMs > 0 ? (
                       <ReplaySide
                         selector="#rightSide iframe"
-                        expectedTime={rightTimestamp + 1}
+                        expectedTime={rightOffsetMs + 1}
                         onLoad={setRightBody}
                       />
                     ) : (

+ 169 - 0
static/app/utils/replays/getDiffTimestamps.spec.tsx

@@ -0,0 +1,169 @@
+import invariant from 'invariant';
+import {EventFixture} from 'sentry-fixture/event';
+import {ReplayHydrationErrorFrameFixture} from 'sentry-fixture/replay/replayBreadcrumbFrameData';
+import {ReplayBreadcrumbFrameEventFixture} from 'sentry-fixture/replay/replayFrameEvents';
+import {
+  RRWebDOMFrameFixture,
+  RRWebFullSnapshotFrameEventFixture,
+  RRWebIncrementalSnapshotFrameEventFixture,
+  RRWebInitFrameEventsFixture,
+} from 'sentry-fixture/replay/rrweb';
+import {ReplayRecordFixture} from 'sentry-fixture/replayRecord';
+
+import {
+  getReplayDiffOffsetsFromEvent,
+  getReplayDiffOffsetsFromFrame,
+} from 'sentry/utils/replays/getDiffTimestamps';
+import hydrateBreadcrumbs from 'sentry/utils/replays/hydrateBreadcrumbs';
+import hydrateFrames from 'sentry/utils/replays/hydrateFrames';
+import ReplayReader from 'sentry/utils/replays/replayReader';
+import {
+  IncrementalSource,
+  isHydrationErrorFrame,
+  type RawBreadcrumbFrame,
+} from 'sentry/utils/replays/types';
+import type {ReplayError} from 'sentry/views/replays/types';
+
+const START_DATE = new Date('2022-06-15T00:40:00.000Z');
+const INIT_DATE = new Date('2022-06-15T00:40:00.100Z');
+const FULL_DATE = new Date('2022-06-15T00:40:00.200Z');
+const ERROR_DATE = new Date('2022-06-15T00:40:01.000Z'); // errors do not have ms precision
+const CRUMB_1_DATE = new Date('2022-06-15T00:40:01.350Z');
+const INCR_DATE = new Date('2022-06-15T00:40:05.000Z');
+const CRUMB_2_DATE = new Date('2022-06-15T00:40:05.350Z');
+const END_DATE = new Date('2022-06-15T00:50:00.555Z');
+
+const replayRecord = ReplayRecordFixture({
+  started_at: START_DATE,
+  finished_at: END_DATE,
+});
+
+const RRWEB_EVENTS = [
+  ...RRWebInitFrameEventsFixture({
+    timestamp: INIT_DATE,
+  }),
+  RRWebFullSnapshotFrameEventFixture({timestamp: FULL_DATE}),
+  RRWebIncrementalSnapshotFrameEventFixture({
+    timestamp: INCR_DATE,
+    data: {
+      source: IncrementalSource.Mutation,
+      adds: [
+        {
+          node: RRWebDOMFrameFixture({
+            tagName: 'canvas',
+          }),
+          parentId: 0,
+          nextId: null,
+        },
+      ],
+      removes: [],
+      texts: [],
+      attributes: [],
+    },
+  }),
+];
+
+function getMockReplay(rrwebEvents: any[], errors: ReplayError[]) {
+  const attachments = [...rrwebEvents];
+  const replay = ReplayReader.factory({
+    replayRecord,
+    errors,
+    attachments,
+  });
+
+  return {replay};
+}
+
+function getMockReplayWithCrumbFrame(
+  rrwebEvents: any[],
+  crumbFrame: RawBreadcrumbFrame,
+  errors: ReplayError[]
+) {
+  const attachments = [...rrwebEvents];
+
+  attachments.push(
+    ReplayBreadcrumbFrameEventFixture({
+      timestamp: new Date(crumbFrame.timestamp),
+      data: {
+        payload: crumbFrame,
+      },
+    })
+  );
+
+  const {rrwebFrames} = hydrateFrames(attachments);
+  const [hydrationErrorFrame] = hydrateBreadcrumbs(
+    replayRecord,
+    crumbFrame ? [crumbFrame] : [],
+    rrwebFrames
+  );
+
+  const replay = ReplayReader.factory({
+    replayRecord,
+    errors,
+    attachments,
+  });
+
+  invariant(isHydrationErrorFrame(hydrationErrorFrame), '');
+  return {hydrationErrorFrame, replay};
+}
+
+describe('getReplayDiffOffsetsFromFrame', () => {
+  it('should return the offset of the requested frame, and the next frame', () => {
+    const rawHydrationCrumbFrame = ReplayHydrationErrorFrameFixture({
+      timestamp: CRUMB_1_DATE,
+    });
+    const {replay, hydrationErrorFrame} = getMockReplayWithCrumbFrame(
+      RRWEB_EVENTS,
+      rawHydrationCrumbFrame,
+      []
+    );
+
+    expect(getReplayDiffOffsetsFromFrame(replay, hydrationErrorFrame)).toEqual({
+      leftOffsetMs: 1_350, // offset of CRUMB_1_DATE
+      rightOffsetMs: 5_000, // offset of the INCR_DATE
+    });
+  });
+
+  it('should return the offset of the requested frame, and 0 if there is no next frame', () => {
+    const rawHydrationCrumbFrame = ReplayHydrationErrorFrameFixture({
+      timestamp: CRUMB_2_DATE,
+    });
+    const {replay, hydrationErrorFrame} = getMockReplayWithCrumbFrame(
+      RRWEB_EVENTS,
+      rawHydrationCrumbFrame,
+      []
+    );
+
+    expect(getReplayDiffOffsetsFromFrame(replay, hydrationErrorFrame)).toEqual({
+      leftOffsetMs: 5_350, // offset of CRUMB_2_DATE
+      rightOffsetMs: 0, // no next mutation date, so offset is 0
+    });
+  });
+});
+
+describe('getReplayDiffOffsetsFromEvent', () => {
+  it('should get offsets based on a hydration breadcrumb that occurs within the same second of the error', () => {
+    const rawHydrationCrumbFrame = ReplayHydrationErrorFrameFixture({
+      timestamp: CRUMB_1_DATE,
+    });
+    const errorEvent = EventFixture({dateCreated: ERROR_DATE.toISOString()});
+    const {replay} = getMockReplayWithCrumbFrame(RRWEB_EVENTS, rawHydrationCrumbFrame, [
+      errorEvent as any as ReplayError,
+    ]);
+
+    expect(getReplayDiffOffsetsFromEvent(replay!, errorEvent)).toEqual({
+      leftOffsetMs: 1_350, // offset of CRUMB_1_DATE
+      rightOffsetMs: 5_000, // offset of the INCR_DATE
+    });
+  });
+
+  it('should get offsets when no hydration breadcrumb exists', () => {
+    const errorEvent = EventFixture({dateCreated: ERROR_DATE.toISOString()});
+    const {replay} = getMockReplay(RRWEB_EVENTS, [errorEvent as any as ReplayError]);
+
+    expect(getReplayDiffOffsetsFromEvent(replay!, errorEvent)).toEqual({
+      leftOffsetMs: 1_000, // offset of ERROR_DATE
+      rightOffsetMs: 5_000, // offset of the INCR_DATE
+    });
+  });
+});

+ 60 - 0
static/app/utils/replays/getDiffTimestamps.tsx

@@ -0,0 +1,60 @@
+import type {Event} from 'sentry/types/event';
+import type ReplayReader from 'sentry/utils/replays/replayReader';
+import {
+  type HydrationErrorFrame,
+  isHydrationErrorFrame,
+} from 'sentry/utils/replays/types';
+
+export function getReplayDiffOffsetsFromFrame(
+  replay: ReplayReader | null,
+  frame: HydrationErrorFrame
+) {
+  return {
+    leftOffsetMs: frame.offsetMs,
+    rightOffsetMs: Math.max(
+      0,
+      // `next.timestamp` is a timestamp since the unix epoch, so we remove the
+      // replay start timestamp to get an offset
+      (frame.data.mutations.next?.timestamp ?? 0) -
+        (replay?.getReplay().started_at.getTime() ?? 0)
+    ),
+  };
+}
+
+export function getReplayDiffOffsetsFromEvent(replay: ReplayReader, event: Event) {
+  const startTimestampMS =
+    'startTimestamp' in event ? event.startTimestamp * 1000 : undefined;
+  const timeOfEvent = event.dateCreated ?? startTimestampMS ?? event.dateReceived;
+  const eventTimestampMs = timeOfEvent ? Math.floor(new Date(timeOfEvent).getTime()) : 0;
+  // `event.dateCreated` is the most common date to use, and it's in seconds not ms
+
+  const hydrationFrame = replay
+    .getBreadcrumbFrames()
+    .find(
+      breadcrumb =>
+        isHydrationErrorFrame(breadcrumb) &&
+        breadcrumb.timestampMs > eventTimestampMs &&
+        breadcrumb.timestampMs < eventTimestampMs + 1000
+    );
+
+  if (hydrationFrame && isHydrationErrorFrame(hydrationFrame)) {
+    return getReplayDiffOffsetsFromFrame(replay, hydrationFrame);
+  }
+
+  const replayStartTimestamp = replay?.getReplay().started_at.getTime() ?? 0;
+
+  // Use the event timestamp for the left side.
+  // Event has only second precision, therefore the hydration error happened
+  // sometime after this timestamp.
+  const leftOffsetMs = Math.max(0, eventTimestampMs - replayStartTimestamp);
+
+  // Use the timestamp of the first mutation to happen after the timestamp of
+  // the error event.
+  const rightOffsetMs = Math.max(
+    0,
+    (replay.getRRWebMutations().find(frame => frame.timestamp > eventTimestampMs + 1000)
+      ?.timestamp ?? eventTimestampMs) - replayStartTimestamp
+  );
+
+  return {leftOffsetMs, rightOffsetMs};
+}

+ 20 - 21
static/app/utils/replays/replayReader.spec.tsx

@@ -1,4 +1,3 @@
-import {EventType, IncrementalSource} from '@sentry-internal/rrweb';
 import {
   ReplayClickEventFixture,
   ReplayConsoleEventFixture,
@@ -17,12 +16,14 @@ import {ReplayRequestFrameFixture} from 'sentry-fixture/replay/replaySpanFrameDa
 import {
   RRWebDOMFrameFixture,
   RRWebFullSnapshotFrameEventFixture,
+  RRWebIncrementalSnapshotFrameEventFixture,
 } from 'sentry-fixture/replay/rrweb';
 import {ReplayErrorFixture} from 'sentry-fixture/replayError';
 import {ReplayRecordFixture} from 'sentry-fixture/replayRecord';
 
 import {BreadcrumbType} from 'sentry/types/breadcrumbs';
 import ReplayReader from 'sentry/utils/replays/replayReader';
+import {EventType, IncrementalSource} from 'sentry/utils/replays/types';
 
 describe('ReplayReader', () => {
   const replayRecord = ReplayRecordFixture();
@@ -332,29 +333,27 @@ describe('ReplayReader', () => {
     const timestamp = new Date('2023-12-25T00:02:00');
 
     const snapshot = RRWebFullSnapshotFrameEventFixture({timestamp});
-    const attachments = [
-      snapshot,
-      {
-        type: EventType.IncrementalSnapshot,
-        timestamp,
-        data: {
-          source: IncrementalSource.Mutation,
-          adds: [
-            {
-              node: RRWebDOMFrameFixture({
-                tagName: 'canvas',
-              }),
-            },
-          ],
-          removes: [],
-          texts: [],
-          attributes: [],
-        },
+    const increment = RRWebIncrementalSnapshotFrameEventFixture({
+      timestamp,
+      data: {
+        source: IncrementalSource.Mutation,
+        adds: [
+          {
+            node: RRWebDOMFrameFixture({
+              tagName: 'canvas',
+            }),
+            parentId: 0,
+            nextId: null,
+          },
+        ],
+        removes: [],
+        texts: [],
+        attributes: [],
       },
-    ];
+    });
 
     const replay = ReplayReader.factory({
-      attachments,
+      attachments: [snapshot, increment],
       errors: [],
       replayRecord,
     });

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

@@ -1,6 +1,4 @@
 import * as Sentry from '@sentry/react';
-import type {incrementalSnapshotEvent} from '@sentry-internal/rrweb';
-import {IncrementalSource} from '@sentry-internal/rrweb';
 import memoize from 'lodash/memoize';
 import {type Duration, duration} from 'moment';
 
@@ -25,6 +23,7 @@ import type {
   ClipWindow,
   ErrorFrame,
   fullSnapshotEvent,
+  incrementalSnapshotEvent,
   MemoryFrame,
   OptionFrame,
   RecordingFrame,
@@ -36,6 +35,7 @@ import type {
 import {
   BreadcrumbCategories,
   EventType,
+  IncrementalSource,
   isDeadClick,
   isDeadRageClick,
   isPaintFrame,
@@ -425,6 +425,8 @@ export default class ReplayReader {
 
   getRRWebFrames = () => this._sortedRRWebEvents;
 
+  getBreadcrumbFrames = () => this._sortedBreadcrumbFrames;
+
   getRRWebMutations = () =>
     this._sortedRRWebEvents.filter(
       event =>

+ 40 - 2
static/app/utils/replays/types.tsx

@@ -1,10 +1,10 @@
 import {EventType, type eventWithTime as TEventWithTime} from '@sentry-internal/rrweb';
 
 export type {serializedNodeWithId} from '@sentry-internal/rrweb-snapshot';
-export type {fullSnapshotEvent} from '@sentry-internal/rrweb';
+export type {fullSnapshotEvent, incrementalSnapshotEvent} from '@sentry-internal/rrweb';
 
 export {NodeType} from '@sentry-internal/rrweb-snapshot';
-export {EventType} from '@sentry-internal/rrweb';
+export {EventType, IncrementalSource} from '@sentry-internal/rrweb';
 
 import type {
   ReplayBreadcrumbFrame as TRawBreadcrumbFrame,
@@ -17,6 +17,24 @@ import invariant from 'invariant';
 
 import type {HydratedA11yFrame} from 'sentry/utils/replays/hydrateA11yFrame';
 
+// These stub types should be coming from the sdk, but they're hard-coded until
+// the SDK updates to the latest version... once that happens delete this!
+// Needed for tests
+// TODO[ryan953]: Remove this once the SDK is exporting the type as part of ReplayBreadcrumbFrame
+export type RawHydrationErrorFrame = {
+  category: 'replay.hydrate-error';
+  timestamp: number;
+  type: string;
+  data?: {
+    url?: string;
+  };
+  message?: string;
+};
+
+// These stub types should be coming from the sdk, but they're hard-coded until
+// the SDK updates to the latest version... once that happens delete this!
+type StubBreadcrumbTypes = RawHydrationErrorFrame;
+
 // TODO: more types get added here
 type MobileBreadcrumbTypes =
   | {
@@ -55,6 +73,7 @@ type MobileBreadcrumbTypes =
  * because the mobile SDK does not send that property currently.
  */
 type ExtraBreadcrumbTypes =
+  | StubBreadcrumbTypes
   | MobileBreadcrumbTypes
   | {
       category: 'navigation';
@@ -168,6 +187,12 @@ export function isRageClick(frame: MultiClickFrame) {
   return frame.data.clickCount >= 5;
 }
 
+export function isHydrationErrorFrame(
+  frame: BreadcrumbFrame
+): frame is HydrationErrorFrame {
+  return frame.category === 'replay.hydrate-error';
+}
+
 type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
 
 type HydratedTimestamp = {
@@ -255,6 +280,19 @@ export type InputFrame = HydratedBreadcrumb<'ui.input'>;
 export type KeyboardEventFrame = HydratedBreadcrumb<'ui.keyDown'>;
 export type MultiClickFrame = HydratedBreadcrumb<'ui.multiClick'>;
 export type MutationFrame = HydratedBreadcrumb<'replay.mutations'>;
+export type HydrationErrorFrame = Overwrite<
+  HydratedBreadcrumb<'replay.hydrate-error'>,
+  {
+    data: {
+      description: string;
+      mutations: {
+        next: RecordingFrame | null;
+        prev: RecordingFrame | null;
+      };
+      url?: string;
+    };
+  }
+>;
 export type NavFrame = HydratedBreadcrumb<'navigation'>;
 export type SlowClickFrame = HydratedBreadcrumb<'ui.slowClickDetected'>;
 export type DeviceBatteryFrame = HydratedBreadcrumb<'device.battery'>;

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