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.spec.tsx b/static/app/utils/replays/getDiffTimestamps.spec.tsx new file mode 100644 index 00000000000000..7be62c291769df --- /dev/null +++ b/static/app/utils/replays/getDiffTimestamps.spec.tsx @@ -0,0 +1,148 @@ +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: undefined | RawBreadcrumbFrame, + errors: ReplayError[] +) { + 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 ? [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 + }); + }); + + 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 new file mode 100644 index 00000000000000..1cefb2a3b91b04 --- /dev/null +++ b/static/app/utils/replays/getDiffTimestamps.tsx @@ -0,0 +1,58 @@ +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: 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 => + 'category' in breadcrumb && + breadcrumb.category === 'replay.hydrate-error' && + breadcrumb.timestampMs > eventTimestampMs && + breadcrumb.timestampMs < eventTimestampMs + 1000 + ); + + if (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}; +} 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..b167abfc42a54f 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,18 @@ 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: string; + data?: { + url?: string; + }; + message?: string; +}; + // TODO: more types get added here type MobileBreadcrumbTypes = | { @@ -55,6 +67,7 @@ type MobileBreadcrumbTypes = * because the mobile SDK does not send that property currently. */ type ExtraBreadcrumbTypes = + | StubBreadcrumbTypes | MobileBreadcrumbTypes | { category: 'navigation'; @@ -255,6 +268,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..e1117717787e45 100644 --- a/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts +++ b/tests/js/fixtures/replay/replayBreadcrumbFrameData.ts @@ -108,6 +108,18 @@ export function ReplaySlowClickFrameFixture( }; } +export function ReplayHydrationErrorFrameFixture( + fields: TestableFrame<'replay.hydrate-error'> +): MockFrame<'replay.hydrate-error'> { + return { + category: 'replay.hydrate-error', + message: '', + timestamp: fields.timestamp.getTime() / 1000, + data: fields.data ?? undefined, + type: BreadcrumbType.DEFAULT, + }; +} + 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,