From 1231957321fc47a688bb0f8561495652d6feb513 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 11 Jun 2024 14:54:05 -0700 Subject: [PATCH 1/4] feat(replay): Calculate hydration diff timestamps based on related hydration breadcrumbs --- .../eventHydrationDiff/replayDiffContent.tsx | 15 ++--- .../replays/breadcrumbs/breadcrumbItem.tsx | 10 ++-- .../openReplayComparisonButton.tsx | 12 ++-- .../breadcrumbs/replayComparisonModal.tsx | 12 ++-- static/app/components/replays/replayDiff.tsx | 16 +++--- .../app/utils/replays/getDiffTimestamps.tsx | 56 +++++++++++++++++++ 6 files changed, 87 insertions(+), 34 deletions(-) create mode 100644 static/app/utils/replays/getDiffTimestamps.tsx diff --git a/static/app/components/events/eventHydrationDiff/replayDiffContent.tsx b/static/app/components/events/eventHydrationDiff/replayDiffContent.tsx index b6e490aa0d3c98..cd3eae13e17850 100644 --- a/static/app/components/events/eventHydrationDiff/replayDiffContent.tsx +++ b/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 ( {t('Open Diff Viewer')} @@ -57,9 +54,9 @@ export default function ReplayDiffContent({event, group, orgSlug, replaySlug}: P diff --git a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx index 0c6ec6091a49d9..38de75f0968950 100644 --- a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx +++ b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx @@ -17,6 +17,7 @@ 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'; @@ -63,6 +64,8 @@ function BreadcrumbItem({ const forceSpan = 'category' in frame && FRAMES_WITH_BUTTONS.includes(frame.category); + const {leftOffsetMs, rightOffsetMs} = getReplayDiffOffsetsFromFrame(replay, frame); + return ( {t('Open Hydration Diff')} diff --git a/static/app/components/replays/breadcrumbs/openReplayComparisonButton.tsx b/static/app/components/replays/breadcrumbs/openReplayComparisonButton.tsx index 053c06fcabefe3..283ee16dfb2f19 100644 --- a/static/app/components/replays/breadcrumbs/openReplayComparisonButton.tsx +++ b/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({ diff --git a/static/app/components/replays/breadcrumbs/replayComparisonModal.tsx b/static/app/components/replays/breadcrumbs/replayComparisonModal.tsx index e5e87b728ee9ca..d300c5ff55833b 100644 --- a/static/app/components/replays/breadcrumbs/replayComparisonModal.tsx +++ b/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 ( @@ -55,8 +55,8 @@ export default function ReplayComparisonModal({ diff --git a/static/app/components/replays/replayDiff.tsx b/static/app/components/replays/replayDiff.tsx index 7a7e243ecd355d..913679bd3df4cc 100644 --- a/static/app/components/replays/replayDiff.tsx +++ b/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({ - {rightTimestamp > 0 ? ( + {rightOffsetMs > 0 ? ( ) : ( diff --git a/static/app/utils/replays/getDiffTimestamps.tsx b/static/app/utils/replays/getDiffTimestamps.tsx new file mode 100644 index 00000000000000..95ff6a7a07ade5 --- /dev/null +++ b/static/app/utils/replays/getDiffTimestamps.tsx @@ -0,0 +1,56 @@ +import type {Event} from 'sentry/types/event'; +import type ReplayReader from 'sentry/utils/replays/replayReader'; +import type {ReplayFrame} from 'sentry/utils/replays/types'; + +export function getReplayDiffOffsetsFromFrame( + replay: ReplayReader | null, + frame: ReplayFrame +) { + return { + leftOffsetMs: frame.offsetMs, + rightOffsetMs: + (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 + .getChapterFrames() + .find( + breadcrumb => + 'category' in breadcrumb && + breadcrumb.category === 'replay.hydrate-error' && + breadcrumb.timestampMs > eventTimestampMs && + breadcrumb.timestampMs < eventTimestampMs + 1000 + ); + + if (hydrationFrame) { + return getReplayDiffOffsetsFromFrame(replay, hydrationFrame); + } + + const frames = replay.getRRWebFrames(); + const replayStartTimestamp = replay?.getReplay().started_at.getTime() ?? 0; + + const leftReplayFrameIndex = replay + .getRRWebFrames() + .findIndex(frame => frame.timestamp < eventTimestampMs); + const leftFrame = frames.at(Math.max(0, leftReplayFrameIndex)); + const leftOffsetMs = replayStartTimestamp - (leftFrame?.timestamp ?? 0); + + const rightReplayFrameIndex = replay + .getRRWebFrames() + .findLastIndex(frame => frame.timestamp <= eventTimestampMs + 1); + + const rightFrame = frames.at(Math.min(frames.length, rightReplayFrameIndex + 1)); + const rightOffsetMs = + (rightFrame?.timestamp ?? eventTimestampMs) + 1 - replayStartTimestamp; + + return {leftOffsetMs, rightOffsetMs}; +} From 16629f93af8d9612c86f885d1192fc90728a9eb6 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 17 Jun 2024 16:58:15 -0700 Subject: [PATCH 2/4] add some tests and fixup types for hydration errors --- .../utils/replays/getDiffTimestamps.spec.tsx | 127 ++++++++++++++++++ .../app/utils/replays/getDiffTimestamps.tsx | 18 ++- .../app/utils/replays/replayReader.spec.tsx | 41 +++--- static/app/utils/replays/replayReader.tsx | 6 +- static/app/utils/replays/types.tsx | 17 ++- .../replay/replayBreadcrumbFrameData.ts | 11 ++ tests/js/fixtures/replay/replayFrameEvents.ts | 9 +- tests/js/fixtures/replay/rrweb.ts | 19 ++- 8 files changed, 211 insertions(+), 37 deletions(-) create mode 100644 static/app/utils/replays/getDiffTimestamps.spec.tsx diff --git a/static/app/utils/replays/getDiffTimestamps.spec.tsx b/static/app/utils/replays/getDiffTimestamps.spec.tsx new file mode 100644 index 00000000000000..0f8b4dec90473f --- /dev/null +++ b/static/app/utils/replays/getDiffTimestamps.spec.tsx @@ -0,0 +1,127 @@ +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, 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[], + crumbFrame: RawBreadcrumbFrame, + errors: ReplayError[] +) { + const hydrationCrumbEvent = ReplayBreadcrumbFrameEventFixture({ + timestamp: new Date(crumbFrame.timestamp), + data: { + payload: crumbFrame, + }, + }); + const attachments = [...rrwebEvents, hydrationCrumbEvent]; + + const {rrwebFrames} = hydrateFrames(attachments); + const [hydrationCrumb] = hydrateBreadcrumbs(replayRecord, [crumbFrame], rrwebFrames); + + const replay = ReplayReader.factory({ + replayRecord, + errors, + attachments, + }); + + return {replay, hydrationCrumb}; +} + +describe('getReplayDiffOffsetsFromFrame', () => { + it('should return the offset of the requested frame, and the next frame', () => { + const hydrationCrumbFrame = ReplayHydrationErrorFrameFixture({ + timestamp: CRUMB_1_DATE, + }); + const {replay, hydrationCrumb} = getMockReplay(RRWEB_EVENTS, hydrationCrumbFrame, []); + + expect(getReplayDiffOffsetsFromFrame(replay, hydrationCrumb)).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 hydrationCrumbFrame = ReplayHydrationErrorFrameFixture({ + timestamp: CRUMB_2_DATE, + }); + const {replay, hydrationCrumb} = getMockReplay(RRWEB_EVENTS, hydrationCrumbFrame, []); + + expect(getReplayDiffOffsetsFromFrame(replay, hydrationCrumb)).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 hydrationCrumbFrame = ReplayHydrationErrorFrameFixture({ + timestamp: CRUMB_1_DATE, + }); + const errorEvent = EventFixture({dateCreated: ERROR_DATE.toISOString()}); + const {replay} = getMockReplay(RRWEB_EVENTS, hydrationCrumbFrame, [ + 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 + }); + }); +}); diff --git a/static/app/utils/replays/getDiffTimestamps.tsx b/static/app/utils/replays/getDiffTimestamps.tsx index 95ff6a7a07ade5..4cb050cb746e2f 100644 --- a/static/app/utils/replays/getDiffTimestamps.tsx +++ b/static/app/utils/replays/getDiffTimestamps.tsx @@ -8,9 +8,13 @@ export function getReplayDiffOffsetsFromFrame( ) { return { leftOffsetMs: frame.offsetMs, - rightOffsetMs: + 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), + (replay?.getReplay().started_at.getTime() ?? 0) + ), }; } @@ -22,7 +26,7 @@ export function getReplayDiffOffsetsFromEvent(replay: ReplayReader, event: Event // `event.dateCreated` is the most common date to use, and it's in seconds not ms const hydrationFrame = replay - .getChapterFrames() + .getBreadcrumbFrames() .find( breadcrumb => 'category' in breadcrumb && @@ -42,15 +46,17 @@ export function getReplayDiffOffsetsFromEvent(replay: ReplayReader, event: Event .getRRWebFrames() .findIndex(frame => frame.timestamp < eventTimestampMs); const leftFrame = frames.at(Math.max(0, leftReplayFrameIndex)); - const leftOffsetMs = replayStartTimestamp - (leftFrame?.timestamp ?? 0); + const leftOffsetMs = Math.max(0, replayStartTimestamp - (leftFrame?.timestamp ?? 0)); const rightReplayFrameIndex = replay .getRRWebFrames() .findLastIndex(frame => frame.timestamp <= eventTimestampMs + 1); const rightFrame = frames.at(Math.min(frames.length, rightReplayFrameIndex + 1)); - const rightOffsetMs = - (rightFrame?.timestamp ?? eventTimestampMs) + 1 - replayStartTimestamp; + const rightOffsetMs = Math.max( + 0, + (rightFrame?.timestamp ?? eventTimestampMs) - replayStartTimestamp + ); return {leftOffsetMs, rightOffsetMs}; } diff --git a/static/app/utils/replays/replayReader.spec.tsx b/static/app/utils/replays/replayReader.spec.tsx index 1f180c8feed9e3..00d27706766a91 100644 --- a/static/app/utils/replays/replayReader.spec.tsx +++ b/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, }); diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index 9331e6a2172dfd..eec735318121ba 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/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, isLCPFrame, @@ -404,6 +404,8 @@ export default class ReplayReader { getRRWebFrames = () => this._sortedRRWebEvents; + getBreadcrumbFrames = () => this._sortedBreadcrumbFrames; + getRRWebMutations = () => this._sortedRRWebEvents.filter( event => diff --git a/static/app/utils/replays/types.tsx b/static/app/utils/replays/types.tsx index ee2c3b9b6726d6..84bfcad9b86455 100644 --- a/static/app/utils/replays/types.tsx +++ b/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,17 @@ 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! +type StubBreadcrumbTypes = { + category: 'replay.hydrate-error'; + timestamp: number; + type: ''; + data?: { + url?: string; + }; +}; + // TODO: more types get added here type MobileBreadcrumbTypes = | { @@ -55,6 +66,7 @@ type MobileBreadcrumbTypes = * because the mobile SDK does not send that property currently. */ type ExtraBreadcrumbTypes = + | StubBreadcrumbTypes | MobileBreadcrumbTypes | { category: 'navigation'; @@ -255,6 +267,7 @@ 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 = HydratedBreadcrumb<'replay.hydrate-error'>; export type NavFrame = HydratedBreadcrumb<'navigation'>; export type SlowClickFrame = HydratedBreadcrumb<'ui.slowClickDetected'>; export type DeviceBatteryFrame = HydratedBreadcrumb<'device.battery'>; diff --git a/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts b/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts index 210010c4db49e3..7bcef7d6b00482 100644 --- a/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts +++ b/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts @@ -108,6 +108,17 @@ export function ReplaySlowClickFrameFixture( }; } +export function ReplayHydrationErrorFrameFixture( + fields: TestableFrame<'replay.hydrate-error'> +): MockFrame<'replay.hydrate-error'> { + return { + category: 'replay.hydrate-error', + timestamp: fields.timestamp.getTime() / 1000, + data: fields.data ?? undefined, + type: '', + }; +} + export function ReplayMutationFrameFixture( fields: TestableFrame<'replay.mutations'> ): MockFrame<'replay.mutations'> { diff --git a/tests/js/fixtures/replay/replayFrameEvents.ts b/tests/js/fixtures/replay/replayFrameEvents.ts index a7681ec1981b02..f8238ede885ace 100644 --- a/tests/js/fixtures/replay/replayFrameEvents.ts +++ b/tests/js/fixtures/replay/replayFrameEvents.ts @@ -18,7 +18,7 @@ type TestableFrameEvent< >; /** - * `BreadcrumbFrameData` has factories to help construct the correct payloads. + * `replayBreadcrumbFrameData.tsx` has factories to help construct the correct payloads. * * ``` * ReplayBreadcrumbFrameEventFixture({ @@ -44,14 +44,13 @@ export function ReplayBreadcrumbFrameEventFixture( } /** - * `SpanFrame()` is a factories to help consturt valid payloads given an operation name. - * `ReplaySpanFrameData.*` contains more factories to build the required inner dataset. + * `replaySpanFrameData.tsx` has factories to help consturt valid payloads given an operation name. * * ``` - * SpanFrameEventFixture({ + * ReplaySpanFrameEventFixture({ * timestamp, * data: { - * payload: ReplaySpanFrameEventFixture({ + * payload: ReplayNavigationFrameFixture({ * data: {...} * }), * }, diff --git a/tests/js/fixtures/replay/rrweb.ts b/tests/js/fixtures/replay/rrweb.ts index a52425ceaac314..c7a0f33d5b7658 100644 --- a/tests/js/fixtures/replay/rrweb.ts +++ b/tests/js/fixtures/replay/rrweb.ts @@ -1,9 +1,12 @@ -import type {fullSnapshotEvent, serializedNodeWithId} from 'sentry/utils/replays/types'; +import type {fullSnapshotEvent, incrementalSnapshotEvent, serializedNodeWithId} from 'sentry/utils/replays/types'; import {EventType, NodeType} from 'sentry/utils/replays/types'; interface FullSnapshotEvent extends fullSnapshotEvent { timestamp: number; } +interface IncrementalSnapshotEvent extends incrementalSnapshotEvent { + timestamp: number; +} const nextRRWebId = (function () { let __rrwebID = 0; @@ -68,6 +71,20 @@ export function RRWebFullSnapshotFrameEventFixture({ }; } +export function RRWebIncrementalSnapshotFrameEventFixture({ + timestamp, + data, +}: { + timestamp: Date; + data: incrementalSnapshotEvent['data']; +}): IncrementalSnapshotEvent { + return { + type: EventType.IncrementalSnapshot, + timestamp: timestamp.getTime(), + data, + } +} + export function RRWebDOMFrameFixture({ id, tagName, From 3d05c80f435c1fc09813c1c08e7993208ade030d Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 17 Jun 2024 17:38:13 -0700 Subject: [PATCH 3/4] better tests --- .../utils/replays/getDiffTimestamps.spec.tsx | 39 ++++++++++++++----- .../app/utils/replays/getDiffTimestamps.tsx | 20 ++++------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/static/app/utils/replays/getDiffTimestamps.spec.tsx b/static/app/utils/replays/getDiffTimestamps.spec.tsx index 0f8b4dec90473f..7be62c291769df 100644 --- a/static/app/utils/replays/getDiffTimestamps.spec.tsx +++ b/static/app/utils/replays/getDiffTimestamps.spec.tsx @@ -60,19 +60,28 @@ const RRWEB_EVENTS = [ function getMockReplay( rrwebEvents: any[], - crumbFrame: RawBreadcrumbFrame, + crumbFrame: undefined | RawBreadcrumbFrame, errors: ReplayError[] ) { - const hydrationCrumbEvent = ReplayBreadcrumbFrameEventFixture({ - timestamp: new Date(crumbFrame.timestamp), - data: { - payload: crumbFrame, - }, - }); - const attachments = [...rrwebEvents, hydrationCrumbEvent]; + const attachments = [...rrwebEvents]; + + if (crumbFrame) { + attachments.push( + ReplayBreadcrumbFrameEventFixture({ + timestamp: new Date(crumbFrame.timestamp), + data: { + payload: crumbFrame, + }, + }) + ); + } const {rrwebFrames} = hydrateFrames(attachments); - const [hydrationCrumb] = hydrateBreadcrumbs(replayRecord, [crumbFrame], rrwebFrames); + const [hydrationCrumb] = hydrateBreadcrumbs( + replayRecord, + crumbFrame ? [crumbFrame] : [], + rrwebFrames + ); const replay = ReplayReader.factory({ replayRecord, @@ -124,4 +133,16 @@ describe('getReplayDiffOffsetsFromEvent', () => { 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, undefined, [ + 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 + }); + }); }); diff --git a/static/app/utils/replays/getDiffTimestamps.tsx b/static/app/utils/replays/getDiffTimestamps.tsx index 4cb050cb746e2f..1cefb2a3b91b04 100644 --- a/static/app/utils/replays/getDiffTimestamps.tsx +++ b/static/app/utils/replays/getDiffTimestamps.tsx @@ -39,23 +39,19 @@ export function getReplayDiffOffsetsFromEvent(replay: ReplayReader, event: Event return getReplayDiffOffsetsFromFrame(replay, hydrationFrame); } - const frames = replay.getRRWebFrames(); const replayStartTimestamp = replay?.getReplay().started_at.getTime() ?? 0; - const leftReplayFrameIndex = replay - .getRRWebFrames() - .findIndex(frame => frame.timestamp < eventTimestampMs); - const leftFrame = frames.at(Math.max(0, leftReplayFrameIndex)); - const leftOffsetMs = Math.max(0, replayStartTimestamp - (leftFrame?.timestamp ?? 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); - const rightReplayFrameIndex = replay - .getRRWebFrames() - .findLastIndex(frame => frame.timestamp <= eventTimestampMs + 1); - - const rightFrame = frames.at(Math.min(frames.length, rightReplayFrameIndex + 1)); + // Use the timestamp of the first mutation to happen after the timestamp of + // the error event. const rightOffsetMs = Math.max( 0, - (rightFrame?.timestamp ?? eventTimestampMs) - replayStartTimestamp + (replay.getRRWebMutations().find(frame => frame.timestamp > eventTimestampMs + 1000) + ?.timestamp ?? eventTimestampMs) - replayStartTimestamp ); return {leftOffsetMs, rightOffsetMs}; From bf9839355641874ffbac4bfbf31400b9fa446e60 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 18 Jun 2024 11:12:52 -0700 Subject: [PATCH 4/4] fix types --- static/app/utils/replays/types.tsx | 3 ++- tests/js/fixtures/replay/replayBreadcrumbFrameData.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/static/app/utils/replays/types.tsx b/static/app/utils/replays/types.tsx index 84bfcad9b86455..b167abfc42a54f 100644 --- a/static/app/utils/replays/types.tsx +++ b/static/app/utils/replays/types.tsx @@ -22,10 +22,11 @@ import type {HydratedA11yFrame} from 'sentry/utils/replays/hydrateA11yFrame'; type StubBreadcrumbTypes = { category: 'replay.hydrate-error'; timestamp: number; - type: ''; + type: string; data?: { url?: string; }; + message?: string; }; // TODO: more types get added here diff --git a/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts b/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts index 7bcef7d6b00482..e1117717787e45 100644 --- a/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts +++ b/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts @@ -113,9 +113,10 @@ export function ReplayHydrationErrorFrameFixture( ): MockFrame<'replay.hydrate-error'> { return { category: 'replay.hydrate-error', + message: '', timestamp: fields.timestamp.getTime() / 1000, data: fields.data ?? undefined, - type: '', + type: BreadcrumbType.DEFAULT, }; }