Skip to content

Commit

Permalink
feat(replay): Capture replay mutation breadcrumbs & add experiment (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
mydea committed Mar 24, 2023
1 parent 68c2301 commit a2103f3
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/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,41 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="button-add">Add items</button>
<button id="button-modify">Modify items</button>
<button id="button-remove">Remove items</button>
<ul class="list"></ul>

<script>
document.querySelector('#button-add').addEventListener('click', () => {
const list = document.querySelector('.list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `test list item: ${i}`;
li.setAttribute('id', `${i}`);
list.appendChild(li);
}
});

document.querySelector('#button-modify').addEventListener('click', () => {
document.querySelectorAll('li').forEach(li => {
el.setAttribute('js-is-checked', new Date().toISOString());
el.setAttribute('js-is-checked-2', new Date().toISOString());
el.setAttribute('js-is-checked-3', 'yes');
el.setAttribute('js-is-checked-4', 'yes');
el.setAttribute('js-is-checked-5', 'yes');
el.setAttribute('js-is-checked-6', 'yes');
});
});

document.querySelector('#button-remove').addEventListener('click', () => {
document.querySelectorAll('li').forEach(li => {
document.querySelector('ul').removeChild(li);
});
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';
import { getReplayRecordingContent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';

sentryTest(
'handles large mutations with default options',
async ({ getLocalTestPath, page, forceFlushReplay, browserName }) => {
if (shouldSkipReplayTest() || ['webkit', 'firefox'].includes(browserName)) {
sentryTest.skip();
}

const reqPromise0 = waitForReplayRequest(page, 0);
const reqPromise0b = waitForReplayRequest(page, 1);

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);
await forceFlushReplay();
const res0 = await reqPromise0;
await reqPromise0b;
// A second request is sent right after initial snapshot, we want to wait for that to settle before we continue

const reqPromise1 = waitForReplayRequest(page);

void page.click('#button-add');
await forceFlushReplay();
const res1 = await reqPromise1;

const reqPromise2 = waitForReplayRequest(page);

void page.click('#button-modify');
await forceFlushReplay();
const res2 = await reqPromise2;

const reqPromise3 = waitForReplayRequest(page);

void page.click('#button-remove');
await forceFlushReplay();
const res3 = await reqPromise3;

const replayData0 = getReplayRecordingContent(res0);
const replayData1 = getReplayRecordingContent(res1);
const replayData2 = getReplayRecordingContent(res2);
const replayData3 = getReplayRecordingContent(res3);

expect(replayData0.fullSnapshots.length).toBe(1);
expect(replayData0.incrementalSnapshots.length).toBe(0);

expect(replayData1.fullSnapshots.length).toBe(0);
expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0);

expect(replayData2.fullSnapshots.length).toBe(0);
expect(replayData2.incrementalSnapshots.length).toBeGreaterThan(0);

expect(replayData3.fullSnapshots.length).toBe(0);
expect(replayData3.incrementalSnapshots.length).toBeGreaterThan(0);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
_experiments: {
mutationLimit: 250,
},
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,
debug: true,

integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="button-add">Add items</button>
<button id="button-modify">Modify items</button>
<button id="button-remove">Remove items</button>
<ul class="list"></ul>

<script>
document.querySelector('#button-add').addEventListener('click', () => {
const list = document.querySelector('.list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `test list item: ${i}`;
li.setAttribute('id', `${i}`);
list.appendChild(li);
}
});

document.querySelector('#button-modify').addEventListener('click', () => {
document.querySelectorAll('li').forEach(li => {
el.setAttribute('js-is-checked', new Date().toISOString());
el.setAttribute('js-is-checked-2', new Date().toISOString());
el.setAttribute('js-is-checked-3', 'yes');
el.setAttribute('js-is-checked-4', 'yes');
el.setAttribute('js-is-checked-5', 'yes');
el.setAttribute('js-is-checked-6', 'yes');
});
});

document.querySelector('#button-remove').addEventListener('click', () => {
document.querySelectorAll('li').forEach(li => {
document.querySelector('ul').removeChild(li);
});
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';
import { getReplayRecordingContent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';

sentryTest(
'handles large mutations with _experiments.mutationLimit configured',
async ({ getLocalTestPath, page, forceFlushReplay, browserName }) => {
if (shouldSkipReplayTest() || ['webkit', 'firefox'].includes(browserName)) {
sentryTest.skip();
}

const reqPromise0 = waitForReplayRequest(page, 0);
const reqPromise0b = waitForReplayRequest(page, 1);

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 res0 = await reqPromise0;
await reqPromise0b;
// A second request is sent right after initial snapshot, we want to wait for that to settle before we continue

const reqPromise1 = waitForReplayRequest(page);

void page.click('#button-add');
await forceFlushReplay();
const res1 = await reqPromise1;

const reqPromise2 = waitForReplayRequest(page);

void page.click('#button-modify');
await forceFlushReplay();
const res2 = await reqPromise2;

const reqPromise3 = waitForReplayRequest(page);

void page.click('#button-remove');
await forceFlushReplay();
const res3 = await reqPromise3;

const replayData0 = getReplayRecordingContent(res0);
const replayData1 = getReplayRecordingContent(res1);
const replayData2 = getReplayRecordingContent(res2);
const replayData3 = getReplayRecordingContent(res3);

expect(replayData0.fullSnapshots.length).toBe(1);
expect(replayData0.incrementalSnapshots.length).toBe(0);

// This includes both a full snapshot as well as some incremental snapshots
expect(replayData1.fullSnapshots.length).toBe(1);
expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0);

// This does not trigger mutations, for whatever reason - so no full snapshot either!
expect(replayData2.fullSnapshots.length).toBe(0);
expect(replayData2.incrementalSnapshots.length).toBeGreaterThan(0);

// This includes both a full snapshot as well as some incremental snapshots
expect(replayData3.fullSnapshots.length).toBe(1);
expect(replayData3.incrementalSnapshots.length).toBeGreaterThan(0);
},
);
53 changes: 34 additions & 19 deletions packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,23 +207,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// instead, we'll always keep the last 60 seconds of replay before an error happened
...(this.recordingMode === 'error' && { checkoutEveryNms: ERROR_CHECKOUT_TIME }),
emit: getHandleRecordingEmit(this),
onMutation: (mutations: unknown[]) => {
if (this._options._experiments.captureMutationSize) {
const count = mutations.length;

if (count > 500) {
const breadcrumb = createBreadcrumb({
category: 'replay.mutations',
data: {
count,
},
});
this._createCustomBreadcrumb(breadcrumb);
}
}
// `true` means we use the regular mutation handling by rrweb
return true;
},
onMutation: this._onMutationHandler,
});
} catch (err) {
this._handleException(err);
Expand Down Expand Up @@ -622,10 +606,10 @@ export class ReplayContainer implements ReplayContainerInterface {
* Trigger rrweb to take a full snapshot which will cause this plugin to
* create a new Replay event.
*/
private _triggerFullSnapshot(): void {
private _triggerFullSnapshot(checkout = true): void {
try {
__DEBUG_BUILD__ && logger.log('[Replay] Taking full rrweb snapshot');
record.takeFullSnapshot(true);
record.takeFullSnapshot(checkout);
} catch (err) {
this._handleException(err);
}
Expand Down Expand Up @@ -839,4 +823,35 @@ export class ReplayContainer implements ReplayContainerInterface {
saveSession(this.session);
}
}

/** Handler for rrweb.record.onMutation */
private _onMutationHandler = (mutations: unknown[]): boolean => {
const count = mutations.length;

const mutationLimit = this._options._experiments.mutationLimit || 0;
const mutationBreadcrumbLimit = this._options._experiments.mutationBreadcrumbLimit || 1000;
const overMutationLimit = mutationLimit && count > mutationLimit;

// Create a breadcrumb if a lot of mutations happen at the same time
// We can show this in the UI as an information with potential performance improvements
if (count > mutationBreadcrumbLimit || overMutationLimit) {
const breadcrumb = createBreadcrumb({
category: 'replay.mutations',
data: {
count,
},
});
this._createCustomBreadcrumb(breadcrumb);
}

if (overMutationLimit) {
// We want to skip doing an incremental snapshot if there are too many mutations
// Instead, we do a full snapshot
this._triggerFullSnapshot(false);
return false;
}

// `true` means we use the regular mutation handling by rrweb
return true;
};
}
3 changes: 2 additions & 1 deletion packages/replay/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ export interface ReplayPluginOptions extends SessionOptions {
_experiments: Partial<{
captureExceptions: boolean;
traceInternals: boolean;
captureMutationSize: boolean;
mutationLimit: number;
mutationBreadcrumbLimit: number;
}>;
}

Expand Down

0 comments on commit a2103f3

Please sign in to comment.