Skip to content

Commit cb68d30

Browse files
authored
ref(replays): Hydrate Replay Frame* types for a more typesafe ui (#50707)
The idea here is that we can have some standard base-types (BreadcrumbFrame, SpanFrame, and ErrorFrame) which we can return from our ReplayReader instance. With memoized returns we can reduce the amount of iterations that we run against the arrays whenever we're switching tabs in the Replay Details view, this will come at a cost of some more memory. This PR focuses on adding the new hydrated type definitions, and doing the up-front work to insert missing fields to make things consistent and easy to use. More fields could be added over time too (like bringing back `transformCrumbs()` or some more specific version.) To follow up we will need to go through each component and convert types into the new *Frame system. The only tough areas will be the Breadcrumb List and Timeline. Both of those expect arrays of type Breadcrumb, but not they'll be getting Breadcrumb, Spans, and Errors mixed together. It'll be a small matter of some if-statements and then using the correct field. Related to #47991 Fixes #50590 Fixes #46130
1 parent 7713678 commit cb68d30

21 files changed

+830
-96
lines changed

fixtures/js-stubs/replay.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as Error from './replay/error';
12
import * as Helpers from './replay/helpers';
23
import * as BreadcrumbFrameData from './replay/replayBreadcrumbFrameData';
34
import * as ReplayFrameEvents from './replay/replayFrameEvents';
@@ -6,6 +7,7 @@ import * as RRweb from './replay/rrweb';
67

78
export const Replay = {
89
...BreadcrumbFrameData,
10+
...Error,
911
...Helpers,
1012
...ReplayFrameEvents,
1113
...ReplaySpanFrameData,

fixtures/js-stubs/replay/error.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type {RawReplayError as TRawReplayError} from 'sentry/utils/replays/types';
2+
3+
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
4+
5+
export function RawReplayError(
6+
error: Overwrite<Partial<TRawReplayError>, {timestamp: Date}>
7+
): TRawReplayError {
8+
return {
9+
'error.type': [] as string[],
10+
id: error.id ?? 'e123',
11+
issue: error.issue ?? 'JS-374',
12+
'issue.id': 3740335939,
13+
'project.name': 'javascript',
14+
timestamp: error.timestamp.toISOString(),
15+
title: 'A Redirect with :orgId param on customer domain',
16+
};
17+
}

fixtures/js-stubs/replay/replayBreadcrumbFrameData.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {BreadcrumbType} from 'sentry/types/breadcrumbs';
2-
import {BreadcrumbFrame as TBreadcrumbFrame} from 'sentry/utils/replays/types';
2+
import {RawBreadcrumbFrame as TBreadcrumbFrame} from 'sentry/utils/replays/types';
33

44
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
55

fixtures/js-stubs/replay/replaySpanFrameData.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {SpanFrame as TSpanFrame} from 'sentry/utils/replays/types';
1+
import {RawSpanFrame as TSpanFrame} from 'sentry/utils/replays/types';
22

33
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
44

@@ -16,8 +16,8 @@ function BaseFrame<T extends TSpanFrame['op']>(
1616
return {
1717
op,
1818
description: fields.description ?? '',
19-
startTimestamp: fields.startTimestamp.getTime() / 100,
20-
endTimestamp: fields.endTimestamp.getTime() / 100,
19+
startTimestamp: fields.startTimestamp.getTime() / 1000,
20+
endTimestamp: fields.endTimestamp.getTime() / 1000,
2121
data: fields.data,
2222
} as MockFrame<T>;
2323
}

fixtures/js-stubs/replay/rrweb.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ export function RRWebInitFrameEvents({
1616
timestamp,
1717
width = 800,
1818
}: {
19-
height: number;
20-
href: string;
2119
timestamp: Date;
22-
width: number;
20+
height?: number;
21+
href?: string;
22+
width?: number;
2323
}) {
2424
return [
2525
{

static/app/utils/analytics/replayAnalyticsEvents.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ export type ReplayEventParameters = {
55
'replay.details-data-loaded': {
66
be_errors: number;
77
fe_errors: number;
8+
finished_at_delta: number; // Log the change (positive number==later date) in finished_at
89
project_platform: string;
910
replay_errors: number;
11+
replay_id: string;
12+
started_at_delta: number; // Log the change (negative number==earlier date) in started_at
1013
total_errors: number;
1114
};
1215
'replay.details-layout-changed': {

static/app/utils/replays/hooks/useLogReplayDataLoaded.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ function useLogReplayDataLoaded({fetchError, fetching, projectSlug, replay}: Pro
3333
project_platform: project.platform!,
3434
replay_errors: 0,
3535
total_errors: allErrors.length,
36+
started_at_delta: replay.timestampDeltas.startedAtDelta,
37+
finished_at_delta: replay.timestampDeltas.finishedAtDelta,
38+
replay_id: replay.getReplay().id,
3639
});
3740
}, [organization, project, fetchError, fetching, projectSlug, replay]);
3841
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import hydrateBreadcrumbs, {
2+
replayInitBreadcrumb,
3+
} from 'sentry/utils/replays/hydrateBreadcrumbs';
4+
import {BreadcrumbFrame} from 'sentry/utils/replays/types';
5+
6+
const ONE_DAY_MS = 60 * 60 * 24 * 1000;
7+
8+
describe('hydrateBreadcrumbs', () => {
9+
it('should set the timestampMs and offsetMs for each breadcrumb in the list', () => {
10+
const replayRecord = TestStubs.ReplayRecord({started_at: new Date('2023/12/23')});
11+
const breadcrumbs = [
12+
TestStubs.Replay.ConsoleFrame({timestamp: new Date('2023/12/23')}),
13+
TestStubs.Replay.ConsoleFrame({timestamp: new Date('2023/12/24')}),
14+
TestStubs.Replay.ConsoleFrame({timestamp: new Date('2023/12/25')}),
15+
];
16+
17+
expect(hydrateBreadcrumbs(replayRecord, breadcrumbs)).toStrictEqual([
18+
{
19+
category: 'console',
20+
data: {logger: 'unknown'},
21+
level: 'fatal',
22+
message: '',
23+
type: 'debug',
24+
timestamp: new Date('2023/12/23'),
25+
timestampMs: 1703307600000,
26+
offsetMs: 0,
27+
},
28+
{
29+
category: 'console',
30+
data: {logger: 'unknown'},
31+
level: 'fatal',
32+
message: '',
33+
type: 'debug',
34+
timestamp: new Date('2023/12/24'),
35+
timestampMs: 1703307600000 + ONE_DAY_MS,
36+
offsetMs: ONE_DAY_MS,
37+
},
38+
{
39+
category: 'console',
40+
data: {logger: 'unknown'},
41+
level: 'fatal',
42+
message: '',
43+
type: 'debug',
44+
timestamp: new Date('2023/12/25'),
45+
timestampMs: 1703307600000 + ONE_DAY_MS * 2,
46+
offsetMs: ONE_DAY_MS * 2,
47+
},
48+
]);
49+
});
50+
51+
describe('replayInitBreadcrumb', () => {
52+
it('should return a RecordingFrame', () => {
53+
const replayRecord = TestStubs.ReplayRecord({});
54+
55+
const frame: BreadcrumbFrame = replayInitBreadcrumb(replayRecord);
56+
expect(frame).toStrictEqual({
57+
category: 'replay.init',
58+
message: 'http://localhost:3000/',
59+
offsetMs: 0,
60+
timestamp: replayRecord.started_at,
61+
timestampMs: 1663865919000,
62+
type: 'init',
63+
});
64+
});
65+
});
66+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {BreadcrumbType} from 'sentry/types/breadcrumbs';
2+
import type {BreadcrumbFrame, RawBreadcrumbFrame} from 'sentry/utils/replays/types';
3+
import type {ReplayRecord} from 'sentry/views/replays/types';
4+
5+
export default function hydrateBreadcrumbs(
6+
replayRecord: ReplayRecord,
7+
breadcrumbFrames: RawBreadcrumbFrame[]
8+
): BreadcrumbFrame[] {
9+
const startTimestampMs = replayRecord.started_at.getTime();
10+
11+
return breadcrumbFrames.map((frame: RawBreadcrumbFrame) => {
12+
const time = new Date(frame.timestamp * 1000);
13+
return {
14+
...frame,
15+
offsetMs: Math.abs(time.getTime() - startTimestampMs),
16+
timestamp: time,
17+
timestampMs: time.getTime(),
18+
};
19+
});
20+
}
21+
22+
export function replayInitBreadcrumb(replayRecord: ReplayRecord): BreadcrumbFrame {
23+
const initialUrl = replayRecord.urls?.[0] ?? replayRecord.tags.url?.join(', ');
24+
25+
return {
26+
category: 'replay.init',
27+
message: initialUrl,
28+
offsetMs: 0,
29+
timestamp: replayRecord.started_at,
30+
timestampMs: replayRecord.started_at.getTime(),
31+
type: BreadcrumbType.INIT, // For compatibility reasons. See BreadcrumbType
32+
};
33+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import hydrateErrors from 'sentry/utils/replays/hydrateErrors';
2+
3+
const ONE_DAY_MS = 60 * 60 * 24 * 1000;
4+
5+
describe('hydrateErrors', () => {
6+
it('should set the timestamp & offsetMs for each span in the list', () => {
7+
const replayRecord = TestStubs.ReplayRecord({started_at: new Date('2023/12/23')});
8+
const errors = [
9+
TestStubs.Replay.RawReplayError({timestamp: new Date('2023/12/23')}),
10+
TestStubs.Replay.RawReplayError({timestamp: new Date('2023/12/24')}),
11+
TestStubs.Replay.RawReplayError({timestamp: new Date('2023/12/25')}),
12+
];
13+
14+
expect(hydrateErrors(replayRecord, errors)).toStrictEqual([
15+
{
16+
category: 'issue',
17+
data: {
18+
eventId: 'e123',
19+
groupId: 3740335939,
20+
groupShortId: 'JS-374',
21+
label: '',
22+
projectSlug: 'javascript',
23+
},
24+
message: 'A Redirect with :orgId param on customer domain',
25+
offsetMs: 0,
26+
timestamp: new Date('2023/12/23'),
27+
timestampMs: 1703307600000,
28+
type: 'error',
29+
},
30+
{
31+
category: 'issue',
32+
data: {
33+
eventId: 'e123',
34+
groupId: 3740335939,
35+
groupShortId: 'JS-374',
36+
label: '',
37+
projectSlug: 'javascript',
38+
},
39+
message: 'A Redirect with :orgId param on customer domain',
40+
offsetMs: ONE_DAY_MS,
41+
timestamp: new Date('2023/12/24'),
42+
timestampMs: 1703307600000 + ONE_DAY_MS,
43+
type: 'error',
44+
},
45+
{
46+
category: 'issue',
47+
data: {
48+
eventId: 'e123',
49+
groupId: 3740335939,
50+
groupShortId: 'JS-374',
51+
label: '',
52+
projectSlug: 'javascript',
53+
},
54+
message: 'A Redirect with :orgId param on customer domain',
55+
offsetMs: ONE_DAY_MS * 2,
56+
timestamp: new Date('2023/12/25'),
57+
timestampMs: 1703307600000 + ONE_DAY_MS * 2,
58+
type: 'error',
59+
},
60+
]);
61+
});
62+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {BreadcrumbType} from 'sentry/types/breadcrumbs';
2+
import type {ErrorFrame, RawReplayError} from 'sentry/utils/replays/types';
3+
import type {ReplayRecord} from 'sentry/views/replays/types';
4+
5+
export default function hydrateErrors(
6+
replayRecord: ReplayRecord,
7+
errors: RawReplayError[]
8+
): ErrorFrame[] {
9+
const startTimestampMs = replayRecord.started_at.getTime();
10+
11+
return errors.map(error => {
12+
const time = new Date(error.timestamp);
13+
return {
14+
category: 'issue',
15+
data: {
16+
eventId: error.id,
17+
groupId: error['issue.id'],
18+
groupShortId: error.issue,
19+
label:
20+
(Array.isArray(error['error.type'])
21+
? error['error.type'][0]
22+
: error['error.type']) ?? '',
23+
projectSlug: error['project.name'],
24+
},
25+
message: error.title,
26+
offsetMs: Math.abs(time.getTime() - startTimestampMs),
27+
timestamp: time,
28+
timestampMs: time.getTime(),
29+
type: BreadcrumbType.ERROR, // For compatibility reasons. See BreadcrumbType
30+
};
31+
});
32+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import hydrateFrames from 'sentry/utils/replays/hydrateFrames';
2+
3+
describe('hydrateFrames', () => {
4+
it('should split breadcrumbs, spans, option and rrweb frames apart', () => {
5+
const crumbProps = {timestamp: new Date()};
6+
const spanProps = {startTimestamp: new Date(), endTimestamp: new Date()};
7+
8+
const optionsFrame = TestStubs.Replay.OptionFrame({});
9+
const attachments = [
10+
...TestStubs.Replay.RRWebInitFrameEvents(crumbProps),
11+
TestStubs.Replay.OptionFrameEvent({
12+
timestamp: new Date(),
13+
data: {payload: optionsFrame},
14+
}),
15+
TestStubs.Replay.ConsoleEvent(crumbProps),
16+
TestStubs.Replay.ConsoleEvent(crumbProps),
17+
TestStubs.Replay.MemoryEvent(spanProps),
18+
TestStubs.Replay.MemoryEvent(spanProps),
19+
TestStubs.Replay.MemoryEvent(spanProps),
20+
TestStubs.Replay.MemoryEvent(spanProps),
21+
];
22+
23+
const {breadcrumbFrames, optionFrame, rrwebFrames, spanFrames} =
24+
hydrateFrames(attachments);
25+
26+
expect(breadcrumbFrames).toHaveLength(2);
27+
expect(optionFrame).toStrictEqual(optionsFrame);
28+
expect(rrwebFrames).toHaveLength(3);
29+
expect(spanFrames).toHaveLength(4);
30+
});
31+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type {
2+
OptionFrame,
3+
RawBreadcrumbFrame,
4+
RawSpanFrame,
5+
RecordingFrame,
6+
} from 'sentry/utils/replays/types';
7+
import {
8+
isBreadcrumbFrameEvent,
9+
isOptionFrameEvent,
10+
isRecordingFrame,
11+
isSpanFrameEvent,
12+
} from 'sentry/utils/replays/types';
13+
14+
export default function hydrateFrames(attachments: unknown[]) {
15+
const rrwebFrames: RecordingFrame[] = [];
16+
const breadcrumbFrames: RawBreadcrumbFrame[] = [];
17+
const spanFrames: RawSpanFrame[] = [];
18+
let optionFrame = undefined as OptionFrame | undefined;
19+
20+
attachments.forEach(attachment => {
21+
if (!attachment) {
22+
return;
23+
}
24+
if (isBreadcrumbFrameEvent(attachment)) {
25+
breadcrumbFrames.push(attachment.data.payload);
26+
} else if (isSpanFrameEvent(attachment)) {
27+
spanFrames.push(attachment.data.payload);
28+
} else if (isOptionFrameEvent(attachment)) {
29+
optionFrame = attachment.data.payload;
30+
} else if (isRecordingFrame(attachment)) {
31+
rrwebFrames.push(attachment);
32+
}
33+
});
34+
35+
return {
36+
breadcrumbFrames,
37+
optionFrame,
38+
rrwebFrames,
39+
spanFrames,
40+
};
41+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
recordingEndFrame,
3+
recordingStartFrame,
4+
} from 'sentry/utils/replays/hydrateRRWebRecordingFrames';
5+
import {RecordingFrame} from 'sentry/utils/replays/types';
6+
7+
describe('hydrateRRWebRecordingFrames', () => {
8+
const replayRecord = TestStubs.ReplayRecord();
9+
10+
describe('recordingStartFrame', () => {
11+
it('should return a RecordingFrame', () => {
12+
const frame: RecordingFrame = recordingStartFrame(replayRecord);
13+
expect(frame).toStrictEqual({
14+
type: 5,
15+
timestamp: replayRecord.started_at.getTime(),
16+
data: {
17+
tag: 'replay.start',
18+
payload: {},
19+
},
20+
});
21+
});
22+
});
23+
24+
describe('recordingEndFrame', () => {
25+
it('should return a RecordingFrame', () => {
26+
const frame: RecordingFrame = recordingEndFrame(replayRecord);
27+
expect(frame).toStrictEqual({
28+
type: 5,
29+
timestamp: replayRecord.finished_at.getTime(),
30+
data: {
31+
tag: 'replay.end',
32+
payload: {},
33+
},
34+
});
35+
});
36+
});
37+
});

0 commit comments

Comments
 (0)