Skip to content

ref(replays): Hydrate Replay Frame* types for a more typesafe ui #50707

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 16, 2023
Merged
2 changes: 2 additions & 0 deletions fixtures/js-stubs/replay.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Error from './replay/error';
import * as Helpers from './replay/helpers';
import * as BreadcrumbFrameData from './replay/replayBreadcrumbFrameData';
import * as ReplayFrameEvents from './replay/replayFrameEvents';
Expand All @@ -6,6 +7,7 @@ import * as RRweb from './replay/rrweb';

export const Replay = {
...BreadcrumbFrameData,
...Error,
...Helpers,
...ReplayFrameEvents,
...ReplaySpanFrameData,
Expand Down
17 changes: 17 additions & 0 deletions fixtures/js-stubs/replay/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type {RawReplayError as TRawReplayError} from 'sentry/utils/replays/types';

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

export function RawReplayError(
error: Overwrite<Partial<TRawReplayError>, {timestamp: Date}>
): TRawReplayError {
return {
'error.type': [] as string[],
id: error.id ?? 'e123',
issue: error.issue ?? 'JS-374',
'issue.id': 3740335939,
'project.name': 'javascript',
timestamp: error.timestamp.toISOString(),
title: 'A Redirect with :orgId param on customer domain',
};
}
2 changes: 1 addition & 1 deletion fixtures/js-stubs/replay/replayBreadcrumbFrameData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {BreadcrumbType} from 'sentry/types/breadcrumbs';
import {BreadcrumbFrame as TBreadcrumbFrame} from 'sentry/utils/replays/types';
import {RawBreadcrumbFrame as TBreadcrumbFrame} from 'sentry/utils/replays/types';

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

Expand Down
6 changes: 3 additions & 3 deletions fixtures/js-stubs/replay/replaySpanFrameData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {SpanFrame as TSpanFrame} from 'sentry/utils/replays/types';
import {RawSpanFrame as TSpanFrame} from 'sentry/utils/replays/types';

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

Expand All @@ -16,8 +16,8 @@ function BaseFrame<T extends TSpanFrame['op']>(
return {
op,
description: fields.description ?? '',
startTimestamp: fields.startTimestamp.getTime() / 100,
endTimestamp: fields.endTimestamp.getTime() / 100,
startTimestamp: fields.startTimestamp.getTime() / 1000,
endTimestamp: fields.endTimestamp.getTime() / 1000,
data: fields.data,
} as MockFrame<T>;
}
Expand Down
6 changes: 3 additions & 3 deletions fixtures/js-stubs/replay/rrweb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export function RRWebInitFrameEvents({
timestamp,
width = 800,
}: {
height: number;
href: string;
timestamp: Date;
width: number;
height?: number;
href?: string;
width?: number;
}) {
return [
{
Expand Down
3 changes: 3 additions & 0 deletions static/app/utils/analytics/replayAnalyticsEvents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ export type ReplayEventParameters = {
'replay.details-data-loaded': {
be_errors: number;
fe_errors: number;
finished_at_delta: number; // Log the change (positive number==later date) in finished_at
project_platform: string;
replay_errors: number;
replay_id: string;
started_at_delta: number; // Log the change (negative number==earlier date) in started_at
total_errors: number;
};
'replay.details-layout-changed': {
Expand Down
3 changes: 3 additions & 0 deletions static/app/utils/replays/hooks/useLogReplayDataLoaded.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ function useLogReplayDataLoaded({fetchError, fetching, projectSlug, replay}: Pro
project_platform: project.platform!,
replay_errors: 0,
total_errors: allErrors.length,
started_at_delta: replay.timestampDeltas.startedAtDelta,
finished_at_delta: replay.timestampDeltas.finishedAtDelta,
replay_id: replay.getReplay().id,
});
}, [organization, project, fetchError, fetching, projectSlug, replay]);
}
Expand Down
66 changes: 66 additions & 0 deletions static/app/utils/replays/hydrateBreadcrumbs.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import hydrateBreadcrumbs, {
replayInitBreadcrumb,
} from 'sentry/utils/replays/hydrateBreadcrumbs';
import {BreadcrumbFrame} from 'sentry/utils/replays/types';

const ONE_DAY_MS = 60 * 60 * 24 * 1000;

describe('hydrateBreadcrumbs', () => {
it('should set the timestampMs and offsetMs for each breadcrumb in the list', () => {
const replayRecord = TestStubs.ReplayRecord({started_at: new Date('2023/12/23')});
const breadcrumbs = [
TestStubs.Replay.ConsoleFrame({timestamp: new Date('2023/12/23')}),
TestStubs.Replay.ConsoleFrame({timestamp: new Date('2023/12/24')}),
TestStubs.Replay.ConsoleFrame({timestamp: new Date('2023/12/25')}),
];

expect(hydrateBreadcrumbs(replayRecord, breadcrumbs)).toStrictEqual([
{
category: 'console',
data: {logger: 'unknown'},
level: 'fatal',
message: '',
type: 'debug',
timestamp: new Date('2023/12/23'),
timestampMs: 1703307600000,
offsetMs: 0,
},
{
category: 'console',
data: {logger: 'unknown'},
level: 'fatal',
message: '',
type: 'debug',
timestamp: new Date('2023/12/24'),
timestampMs: 1703307600000 + ONE_DAY_MS,
offsetMs: ONE_DAY_MS,
},
{
category: 'console',
data: {logger: 'unknown'},
level: 'fatal',
message: '',
type: 'debug',
timestamp: new Date('2023/12/25'),
timestampMs: 1703307600000 + ONE_DAY_MS * 2,
offsetMs: ONE_DAY_MS * 2,
},
]);
});

describe('replayInitBreadcrumb', () => {
it('should return a RecordingFrame', () => {
const replayRecord = TestStubs.ReplayRecord({});

const frame: BreadcrumbFrame = replayInitBreadcrumb(replayRecord);
expect(frame).toStrictEqual({
category: 'replay.init',
message: 'http://localhost:3000/',
offsetMs: 0,
timestamp: replayRecord.started_at,
timestampMs: 1663865919000,
type: 'init',
});
});
});
});
33 changes: 33 additions & 0 deletions static/app/utils/replays/hydrateBreadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {BreadcrumbType} from 'sentry/types/breadcrumbs';
import type {BreadcrumbFrame, RawBreadcrumbFrame} from 'sentry/utils/replays/types';
import type {ReplayRecord} from 'sentry/views/replays/types';

export default function hydrateBreadcrumbs(
replayRecord: ReplayRecord,
breadcrumbFrames: RawBreadcrumbFrame[]
): BreadcrumbFrame[] {
const startTimestampMs = replayRecord.started_at.getTime();

return breadcrumbFrames.map((frame: RawBreadcrumbFrame) => {
const time = new Date(frame.timestamp * 1000);
return {
...frame,
offsetMs: Math.abs(time.getTime() - startTimestampMs),
timestamp: time,
timestampMs: time.getTime(),
};
});
}

export function replayInitBreadcrumb(replayRecord: ReplayRecord): BreadcrumbFrame {
const initialUrl = replayRecord.urls?.[0] ?? replayRecord.tags.url?.join(', ');

return {
category: 'replay.init',
message: initialUrl,
offsetMs: 0,
timestamp: replayRecord.started_at,
timestampMs: replayRecord.started_at.getTime(),
type: BreadcrumbType.INIT, // For compatibility reasons. See BreadcrumbType
};
}
62 changes: 62 additions & 0 deletions static/app/utils/replays/hydrateErrors.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import hydrateErrors from 'sentry/utils/replays/hydrateErrors';

const ONE_DAY_MS = 60 * 60 * 24 * 1000;

describe('hydrateErrors', () => {
it('should set the timestamp & offsetMs for each span in the list', () => {
const replayRecord = TestStubs.ReplayRecord({started_at: new Date('2023/12/23')});
const errors = [
TestStubs.Replay.RawReplayError({timestamp: new Date('2023/12/23')}),
TestStubs.Replay.RawReplayError({timestamp: new Date('2023/12/24')}),
TestStubs.Replay.RawReplayError({timestamp: new Date('2023/12/25')}),
];

expect(hydrateErrors(replayRecord, errors)).toStrictEqual([
{
category: 'issue',
data: {
eventId: 'e123',
groupId: 3740335939,
groupShortId: 'JS-374',
label: '',
projectSlug: 'javascript',
},
message: 'A Redirect with :orgId param on customer domain',
offsetMs: 0,
timestamp: new Date('2023/12/23'),
timestampMs: 1703307600000,
type: 'error',
},
{
category: 'issue',
data: {
eventId: 'e123',
groupId: 3740335939,
groupShortId: 'JS-374',
label: '',
projectSlug: 'javascript',
},
message: 'A Redirect with :orgId param on customer domain',
offsetMs: ONE_DAY_MS,
timestamp: new Date('2023/12/24'),
timestampMs: 1703307600000 + ONE_DAY_MS,
type: 'error',
},
{
category: 'issue',
data: {
eventId: 'e123',
groupId: 3740335939,
groupShortId: 'JS-374',
label: '',
projectSlug: 'javascript',
},
message: 'A Redirect with :orgId param on customer domain',
offsetMs: ONE_DAY_MS * 2,
timestamp: new Date('2023/12/25'),
timestampMs: 1703307600000 + ONE_DAY_MS * 2,
type: 'error',
},
]);
});
});
32 changes: 32 additions & 0 deletions static/app/utils/replays/hydrateErrors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {BreadcrumbType} from 'sentry/types/breadcrumbs';
import type {ErrorFrame, RawReplayError} from 'sentry/utils/replays/types';
import type {ReplayRecord} from 'sentry/views/replays/types';

export default function hydrateErrors(
replayRecord: ReplayRecord,
errors: RawReplayError[]
): ErrorFrame[] {
const startTimestampMs = replayRecord.started_at.getTime();

return errors.map(error => {
const time = new Date(error.timestamp);
return {
category: 'issue',
data: {
eventId: error.id,
groupId: error['issue.id'],
groupShortId: error.issue,
label:
(Array.isArray(error['error.type'])
? error['error.type'][0]
: error['error.type']) ?? '',
projectSlug: error['project.name'],
},
message: error.title,
offsetMs: Math.abs(time.getTime() - startTimestampMs),
timestamp: time,
timestampMs: time.getTime(),
type: BreadcrumbType.ERROR, // For compatibility reasons. See BreadcrumbType
};
});
}
31 changes: 31 additions & 0 deletions static/app/utils/replays/hydrateFrames.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import hydrateFrames from 'sentry/utils/replays/hydrateFrames';

describe('hydrateFrames', () => {
it('should split breadcrumbs, spans, option and rrweb frames apart', () => {
const crumbProps = {timestamp: new Date()};
const spanProps = {startTimestamp: new Date(), endTimestamp: new Date()};

const optionsFrame = TestStubs.Replay.OptionFrame({});
const attachments = [
...TestStubs.Replay.RRWebInitFrameEvents(crumbProps),
TestStubs.Replay.OptionFrameEvent({
timestamp: new Date(),
data: {payload: optionsFrame},
}),
TestStubs.Replay.ConsoleEvent(crumbProps),
TestStubs.Replay.ConsoleEvent(crumbProps),
TestStubs.Replay.MemoryEvent(spanProps),
TestStubs.Replay.MemoryEvent(spanProps),
TestStubs.Replay.MemoryEvent(spanProps),
TestStubs.Replay.MemoryEvent(spanProps),
];

const {breadcrumbFrames, optionFrame, rrwebFrames, spanFrames} =
hydrateFrames(attachments);

expect(breadcrumbFrames).toHaveLength(2);
expect(optionFrame).toStrictEqual(optionsFrame);
expect(rrwebFrames).toHaveLength(3);
expect(spanFrames).toHaveLength(4);
});
});
41 changes: 41 additions & 0 deletions static/app/utils/replays/hydrateFrames.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type {
OptionFrame,
RawBreadcrumbFrame,
RawSpanFrame,
RecordingFrame,
} from 'sentry/utils/replays/types';
import {
isBreadcrumbFrameEvent,
isOptionFrameEvent,
isRecordingFrame,
isSpanFrameEvent,
} from 'sentry/utils/replays/types';

export default function hydrateFrames(attachments: unknown[]) {
const rrwebFrames: RecordingFrame[] = [];
const breadcrumbFrames: RawBreadcrumbFrame[] = [];
const spanFrames: RawSpanFrame[] = [];
let optionFrame = undefined as OptionFrame | undefined;

attachments.forEach(attachment => {
if (!attachment) {
return;
}
if (isBreadcrumbFrameEvent(attachment)) {
breadcrumbFrames.push(attachment.data.payload);
} else if (isSpanFrameEvent(attachment)) {
spanFrames.push(attachment.data.payload);
} else if (isOptionFrameEvent(attachment)) {
optionFrame = attachment.data.payload;
} else if (isRecordingFrame(attachment)) {
rrwebFrames.push(attachment);
}
});

return {
breadcrumbFrames,
optionFrame,
rrwebFrames,
spanFrames,
};
}
37 changes: 37 additions & 0 deletions static/app/utils/replays/hydrateRRWebRecordingFrames.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
recordingEndFrame,
recordingStartFrame,
} from 'sentry/utils/replays/hydrateRRWebRecordingFrames';
import {RecordingFrame} from 'sentry/utils/replays/types';

describe('hydrateRRWebRecordingFrames', () => {
const replayRecord = TestStubs.ReplayRecord();

describe('recordingStartFrame', () => {
it('should return a RecordingFrame', () => {
const frame: RecordingFrame = recordingStartFrame(replayRecord);
expect(frame).toStrictEqual({
type: 5,
timestamp: replayRecord.started_at.getTime(),
data: {
tag: 'replay.start',
payload: {},
},
});
});
});

describe('recordingEndFrame', () => {
it('should return a RecordingFrame', () => {
const frame: RecordingFrame = recordingEndFrame(replayRecord);
expect(frame).toStrictEqual({
type: 5,
timestamp: replayRecord.finished_at.getTime(),
data: {
tag: 'replay.end',
payload: {},
},
});
});
});
});
Loading