Skip to content

feat(replay): Add "start recording" breadcrumb to replays #7004

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

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/browser';
import { Replay } from '@sentry/replay';

window.Sentry = Sentry;
window.Replay = new Replay({
flushMinDelay: 200,
flushMaxDelay: 200,
useCompression: false,
});

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: 0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,

integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button onclick="console.log('Test log')">Click me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect } from '@playwright/test';
import type { RecordingEvent } from '@sentry/replay/build/npm/types/types';

import { sentryTest } from '../../../utils/fixtures';
import { envelopeRequestParser } from '../../../utils/helpers';
import { getReplayBreadcrumbs, waitForReplayRequest } from '../../../utils/replayHelpers';

sentryTest('adds a start recording breadcrumb to the replay', async ({ getLocalTestPath, page }) => {
// Replay bundles are es6 only
if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) {
sentryTest.skip();
}

const reqPromise = waitForReplayRequest(page, 0);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);

const replayRecording = envelopeRequestParser(await reqPromise, 5) as RecordingEvent[];
const breadCrumbs = getReplayBreadcrumbs(replayRecording, 'replay.recording.start');

expect(breadCrumbs.length).toBe(1);
expect(breadCrumbs[0]).toEqual({
category: 'replay.recording.start',
data: {
url: expect.stringContaining('replay/startRecordingBreadcrumb/dist/index.html'),
},
timestamp: expect.any(Number),
type: 'default',
});
});
4 changes: 2 additions & 2 deletions packages/integration-tests/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export const envelopeParser = (request: Request | null): unknown[] => {
});
};

export const envelopeRequestParser = (request: Request | null): Event => {
return envelopeParser(request)[2] as Event;
export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): Event => {
return envelopeParser(request)[envelopeIndex] as Event;
};

export const envelopeHeaderRequestParser = (request: Request | null): EventEnvelopeHeaders => {
Expand Down
13 changes: 11 additions & 2 deletions packages/integration-tests/utils/replayHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReplayContainer } from '@sentry/replay/build/npm/types/types';
import type { Event, ReplayEvent } from '@sentry/types';
import type { RecordingEvent, ReplayContainer } from '@sentry/replay/build/npm/types/types';
import type { Breadcrumb, Event, ReplayEvent } from '@sentry/types';
import type { Page, Request } from 'playwright';

import { envelopeRequestParser } from './helpers';
Expand Down Expand Up @@ -56,3 +56,12 @@ export async function getReplaySnapshot(page: Page): Promise<ReplayContainer> {
}

export const REPLAY_DEFAULT_FLUSH_MAX_DELAY = 5_000;

export function getReplayBreadcrumbs(rrwebEvents: RecordingEvent[], category?: string): Breadcrumb[] {
return rrwebEvents
.filter(event => event.type === 5)
.map(event => event.data as { tag: string; payload: { category: string } })
.filter(data => data.tag === 'breadcrumb')
.map(data => data.payload)
.filter(payload => !category || payload.category === category);
}
22 changes: 20 additions & 2 deletions packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { createBreadcrumb } from './util/createBreadcrumb';
import { createPerformanceEntries } from './util/createPerformanceEntries';
import { createPerformanceSpans } from './util/createPerformanceSpans';
import { debounce } from './util/debounce';
import { getFullURL } from './util/getFullUrl';
import { isExpired } from './util/isExpired';
import { isSessionExpired } from './util/isSessionExpired';
import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent';
Expand Down Expand Up @@ -441,8 +442,7 @@ export class ReplayContainer implements ReplayContainerInterface {
* first flush.
*/
private _setInitialState(): void {
const urlPath = `${WINDOW.location.pathname}${WINDOW.location.hash}${WINDOW.location.search}`;
const url = `${WINDOW.location.origin}${urlPath}`;
const url = getFullURL();

this.performanceEvents = [];

Expand Down Expand Up @@ -541,6 +541,12 @@ export class ReplayContainer implements ReplayContainerInterface {
return false;
}

// We only do this for session mode, as error mode will be changed into session mode
// (which is when startRecording() is called again) once the error occurs.
if (this.recordingMode === 'session') {
this._addStartRecordingBreadcrumb();
}

// If there is a previousSessionId after a full snapshot occurs, then
// the replay session was started due to session expiration. The new session
// is started before triggering a new checkout and contains the id
Expand Down Expand Up @@ -898,4 +904,16 @@ export class ReplayContainer implements ReplayContainerInterface {
}, rateLimitDuration);
}
}

/**
* Creates a breadcrumb indicating the start of a replay recording
* and adds the current, full URL to the breadcrumb data.
*/
private _addStartRecordingBreadcrumb(): void {
const breadcrumb = createBreadcrumb({
category: 'replay.recording.start',
data: { url: getFullURL() },
});
this._createCustomBreadcrumb(breadcrumb);
}
}
10 changes: 10 additions & 0 deletions packages/replay/src/util/getFullUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { WINDOW } from '../constants';

/**
* Takes the full URL from `window.location` and returns it as a string.
*/
export function getFullURL(): string {
const urlPath = `${WINDOW.location.pathname}${WINDOW.location.hash}${WINDOW.location.search}`;
const url = `${WINDOW.location.origin}${urlPath}`;
return url;
}
35 changes: 32 additions & 3 deletions packages/replay/test/integration/errorSampleRate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,20 +109,33 @@ describe('Integration | errorSampleRate', () => {
},
},
}),
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5020, type: 2 }]),
});

jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);

// New checkout when we call `startRecording` again after uploading segment
// after an error occurs
const timestamp = BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 20;
expect(replay).toHaveLastSentReplay({
recordingData: JSON.stringify([
{
data: { isCheckout: true },
timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 20,
timestamp,
type: 2,
},
{
type: 5,
timestamp: timestamp / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: timestamp / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://localhost/' },
},
},
},
]),
});

Expand Down Expand Up @@ -460,9 +473,25 @@ it('sends a replay after loading the session multiple times', async () => {
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]),
});

const timestamp = BASE_TIMESTAMP + 5020;
// Latest checkout when we call `startRecording` again after uploading segment
// after an error occurs (e.g. when we switch to session replay recording)
expect(replay).toHaveLastSentReplay({
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5020, type: 2 }]),
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: timestamp, type: 2 },
{
type: 5,
timestamp: timestamp / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: timestamp / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://localhost/' },
},
},
},
]),
});
});
58 changes: 52 additions & 6 deletions packages/replay/test/integration/rateLimiting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe('Integration | rate-limiting behaviour', () => {
// @ts-ignore private API
jest.spyOn(replay, '_handleRateLimit');

const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };

mockTransportSend.mockImplementationOnce(() => {
return Promise.resolve(rateLimitResponse);
Expand Down Expand Up @@ -142,9 +142,23 @@ describe('Integration | rate-limiting behaviour', () => {
expect(replay.isPaused()).toBe(false);

expect(mockSendReplayRequest).toHaveBeenCalledTimes(2);
const checkoutTimestamp = BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 7;
expect(replay).toHaveLastSentReplay({
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 7, type: 2 },
{ data: { isCheckout: true }, timestamp: checkoutTimestamp, type: 2 },
{
type: 5,
timestamp: checkoutTimestamp / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: checkoutTimestamp / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://localhost/' },
},
},
},
]),
});

Expand Down Expand Up @@ -179,7 +193,7 @@ describe('Integration | rate-limiting behaviour', () => {
// @ts-ignore private API
jest.spyOn(replay, '_handleRateLimit');

const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };

mockTransportSend.mockImplementationOnce(() => {
return Promise.resolve({ statusCode: 429 });
Expand Down Expand Up @@ -227,9 +241,24 @@ describe('Integration | rate-limiting behaviour', () => {
expect(replay.isPaused()).toBe(false);

expect(mockSendReplayRequest).toHaveBeenCalledTimes(2);

const checkoutTimestamp = BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 13;
expect(replay).toHaveLastSentReplay({
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 13, type: 2 },
{ data: { isCheckout: true }, timestamp: checkoutTimestamp, type: 2 },
{
type: 5,
timestamp: checkoutTimestamp / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: checkoutTimestamp / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://localhost/' },
},
},
},
]),
});

Expand Down Expand Up @@ -282,11 +311,28 @@ describe('Integration | rate-limiting behaviour', () => {
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
expect(mockTransportSend).toHaveBeenCalledTimes(1);

expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) });
expect(replay).toHaveLastSentReplay({
recordingData: JSON.stringify([
TEST_EVENT,
{
type: 5,
timestamp: BASE_TIMESTAMP / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: BASE_TIMESTAMP / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://localhost/' },
},
},
},
]),
});

expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1);
expect(replay.resume).not.toHaveBeenCalled();
expect(replay.isPaused).toHaveBeenCalledTimes(2);
expect(replay.isPaused).toHaveBeenCalledTimes(3);
expect(replay.pause).not.toHaveBeenCalled();
});
});
39 changes: 39 additions & 0 deletions packages/replay/test/integration/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@ describe('Integration | session', () => {
expect(replay).toHaveLastSentReplay({
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: newTimestamp, type: 2 },
{
type: 5,
timestamp: newTimestamp / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: newTimestamp / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://localhost/' },
},
},
},
{
type: 5,
timestamp: breadcrumbTimestamp,
Expand Down Expand Up @@ -311,6 +324,19 @@ describe('Integration | session', () => {
recordingPayloadHeader: { segment_id: 0 },
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: newTimestamp, type: 2 },
{
type: 5,
timestamp: newTimestamp / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: newTimestamp / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://dummy/' },
},
},
},
{
type: 5,
timestamp: breadcrumbTimestamp,
Expand Down Expand Up @@ -422,6 +448,19 @@ describe('Integration | session', () => {
recordingPayloadHeader: { segment_id: 0 },
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: newTimestamp, type: 2 },
{
type: 5,
timestamp: newTimestamp / 1000,
data: {
tag: 'breadcrumb',
payload: {
timestamp: newTimestamp / 1000,
type: 'default',
category: 'replay.recording.start',
data: { url: 'http://dummy/' },
},
},
},
{
type: 5,
timestamp: breadcrumbTimestamp,
Expand Down
Loading