Skip to content

Commit cde997f

Browse files
committed
feat(replay): Capture replay mutation breadcrumbs & add experiment
Adds `_experiments.fullSnapshotOnMutationsOver` to experiment with this.
1 parent 1418582 commit cde997f

File tree

8 files changed

+271
-20
lines changed

8 files changed

+271
-20
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
sampleRate: 0,
12+
replaysSessionSampleRate: 1.0,
13+
replaysOnErrorSampleRate: 0.0,
14+
15+
integrations: [window.Replay],
16+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="button-add">Add items</button>
8+
<button id="button-modify">Modify items</button>
9+
<button id="button-remove">Remove items</button>
10+
<ul class="list"></ul>
11+
12+
<script>
13+
document.querySelector('#button-add').addEventListener('click', () => {
14+
const list = document.querySelector('.list');
15+
for (let i = 0; i < 1000; i++) {
16+
const li = document.createElement('li');
17+
li.textContent = `test list item: ${i}`;
18+
li.setAttribute('id', `${i}`);
19+
list.appendChild(li);
20+
}
21+
});
22+
23+
document.querySelector('#button-modify').addEventListener('click', () => {
24+
document.querySelectorAll('li').forEach(li => {
25+
el.setAttribute('js-is-checked', new Date().toISOString());
26+
el.setAttribute('js-is-checked-2', new Date().toISOString());
27+
el.setAttribute('js-is-checked-3', 'yes');
28+
el.setAttribute('js-is-checked-4', 'yes');
29+
el.setAttribute('js-is-checked-5', 'yes');
30+
el.setAttribute('js-is-checked-6', 'yes');
31+
});
32+
});
33+
34+
document.querySelector('#button-remove').addEventListener('click', () => {
35+
document.querySelectorAll('li').forEach(li => {
36+
document.querySelector('ul').removeChild(li);
37+
});
38+
});
39+
</script>
40+
</body>
41+
</html>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplayRecordingContent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('handles large mutations with default options', async ({ getLocalTestPath, page, forceFlushReplay }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const reqPromise0 = waitForReplayRequest(page, 0);
12+
const reqPromise1 = waitForReplayRequest(page, 1);
13+
const reqPromise2 = waitForReplayRequest(page, 2);
14+
const reqPromise3 = waitForReplayRequest(page, 3);
15+
16+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
17+
return route.fulfill({
18+
status: 200,
19+
contentType: 'application/json',
20+
body: JSON.stringify({ id: 'test-id' }),
21+
});
22+
});
23+
24+
const url = await getLocalTestPath({ testDir: __dirname });
25+
26+
await page.goto(url);
27+
const res0 = await reqPromise0;
28+
29+
await page.click('#button-add');
30+
await forceFlushReplay();
31+
const res1 = await reqPromise1;
32+
33+
await page.click('#button-modify');
34+
await forceFlushReplay();
35+
const res2 = await reqPromise2;
36+
37+
await page.click('#button-remove');
38+
await forceFlushReplay();
39+
const res3 = await reqPromise3;
40+
41+
const replayData0 = getReplayRecordingContent(res0);
42+
const replayData1 = getReplayRecordingContent(res1);
43+
const replayData2 = getReplayRecordingContent(res2);
44+
const replayData3 = getReplayRecordingContent(res3);
45+
46+
expect(replayData0.fullSnapshots.length).toBe(1);
47+
expect(replayData0.incrementalSnapshots.length).toBe(0);
48+
49+
expect(replayData1.fullSnapshots.length).toBe(0);
50+
expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0);
51+
52+
expect(replayData2.fullSnapshots.length).toBe(0);
53+
expect(replayData2.incrementalSnapshots.length).toBeGreaterThan(0);
54+
55+
expect(replayData3.fullSnapshots.length).toBe(0);
56+
expect(replayData3.incrementalSnapshots.length).toBeGreaterThan(0);
57+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
_experiments: {
8+
fullSnapshotOnMutationsOver: 250,
9+
},
10+
});
11+
12+
Sentry.init({
13+
dsn: 'https://[email protected]/1337',
14+
sampleRate: 0,
15+
replaysSessionSampleRate: 1.0,
16+
replaysOnErrorSampleRate: 0.0,
17+
debug: true,
18+
19+
integrations: [window.Replay],
20+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="button-add">Add items</button>
8+
<button id="button-modify">Modify items</button>
9+
<button id="button-remove">Remove items</button>
10+
<ul class="list"></ul>
11+
12+
<script>
13+
document.querySelector('#button-add').addEventListener('click', () => {
14+
const list = document.querySelector('.list');
15+
for (let i = 0; i < 1000; i++) {
16+
const li = document.createElement('li');
17+
li.textContent = `test list item: ${i}`;
18+
li.setAttribute('id', `${i}`);
19+
list.appendChild(li);
20+
}
21+
});
22+
23+
document.querySelector('#button-modify').addEventListener('click', () => {
24+
document.querySelectorAll('li').forEach(li => {
25+
el.setAttribute('js-is-checked', new Date().toISOString());
26+
el.setAttribute('js-is-checked-2', new Date().toISOString());
27+
el.setAttribute('js-is-checked-3', 'yes');
28+
el.setAttribute('js-is-checked-4', 'yes');
29+
el.setAttribute('js-is-checked-5', 'yes');
30+
el.setAttribute('js-is-checked-6', 'yes');
31+
});
32+
});
33+
34+
document.querySelector('#button-remove').addEventListener('click', () => {
35+
document.querySelectorAll('li').forEach(li => {
36+
document.querySelector('ul').removeChild(li);
37+
});
38+
});
39+
</script>
40+
</body>
41+
</html>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplayRecordingContent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest(
7+
'handles large mutations with _experiments.fullSnapshotOnMutationsOver configured',
8+
async ({ getLocalTestPath, page, forceFlushReplay }) => {
9+
if (shouldSkipReplayTest()) {
10+
sentryTest.skip();
11+
}
12+
13+
const reqPromise0 = waitForReplayRequest(page, 0);
14+
const reqPromise1 = waitForReplayRequest(page, 1);
15+
const reqPromise2 = waitForReplayRequest(page, 2);
16+
const reqPromise3 = waitForReplayRequest(page, 3);
17+
18+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
19+
return route.fulfill({
20+
status: 200,
21+
contentType: 'application/json',
22+
body: JSON.stringify({ id: 'test-id' }),
23+
});
24+
});
25+
26+
const url = await getLocalTestPath({ testDir: __dirname });
27+
28+
await page.goto(url);
29+
const res0 = await reqPromise0;
30+
31+
await page.click('#button-add');
32+
await forceFlushReplay();
33+
const res1 = await reqPromise1;
34+
35+
await page.click('#button-modify');
36+
await forceFlushReplay();
37+
const res2 = await reqPromise2;
38+
39+
await page.click('#button-remove');
40+
await forceFlushReplay();
41+
const res3 = await reqPromise3;
42+
43+
const replayData0 = getReplayRecordingContent(res0);
44+
const replayData1 = getReplayRecordingContent(res1);
45+
const replayData2 = getReplayRecordingContent(res2);
46+
const replayData3 = getReplayRecordingContent(res3);
47+
48+
expect(replayData0.fullSnapshots.length).toBe(1);
49+
expect(replayData0.incrementalSnapshots.length).toBe(0);
50+
51+
// This includes both a full snapshot as well as some incremental snapshots
52+
expect(replayData1.fullSnapshots.length).toBe(1);
53+
expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0);
54+
55+
// This does not trigger mutations, for whatever reason - so no full snapshot either!
56+
expect(replayData2.fullSnapshots.length).toBe(0);
57+
expect(replayData2.incrementalSnapshots.length).toBeGreaterThan(0);
58+
59+
// This includes both a full snapshot as well as some incremental snapshots
60+
expect(replayData3.fullSnapshots.length).toBe(1);
61+
expect(replayData3.incrementalSnapshots.length).toBeGreaterThan(0);
62+
},
63+
);

packages/replay/src/replay.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -207,23 +207,7 @@ export class ReplayContainer implements ReplayContainerInterface {
207207
// instead, we'll always keep the last 60 seconds of replay before an error happened
208208
...(this.recordingMode === 'error' && { checkoutEveryNms: ERROR_CHECKOUT_TIME }),
209209
emit: getHandleRecordingEmit(this),
210-
onMutation: (mutations: unknown[]) => {
211-
if (this._options._experiments.captureMutationSize) {
212-
const count = mutations.length;
213-
214-
if (count > 500) {
215-
const breadcrumb = createBreadcrumb({
216-
category: 'replay.mutations',
217-
data: {
218-
count,
219-
},
220-
});
221-
this._createCustomBreadcrumb(breadcrumb);
222-
}
223-
}
224-
// `true` means we use the regular mutation handling by rrweb
225-
return true;
226-
},
210+
onMutation: this._onMutationHandler,
227211
});
228212
} catch (err) {
229213
this._handleException(err);
@@ -622,10 +606,10 @@ export class ReplayContainer implements ReplayContainerInterface {
622606
* Trigger rrweb to take a full snapshot which will cause this plugin to
623607
* create a new Replay event.
624608
*/
625-
private _triggerFullSnapshot(): void {
609+
private _triggerFullSnapshot(checkout = true): void {
626610
try {
627611
__DEBUG_BUILD__ && logger.log('[Replay] Taking full rrweb snapshot');
628-
record.takeFullSnapshot(true);
612+
record.takeFullSnapshot(checkout);
629613
} catch (err) {
630614
this._handleException(err);
631615
}
@@ -839,4 +823,33 @@ export class ReplayContainer implements ReplayContainerInterface {
839823
saveSession(this.session);
840824
}
841825
}
826+
827+
/** Handler for rrweb.record.onMutation */
828+
private _onMutationHandler = (mutations: unknown[]): boolean => {
829+
const count = mutations.length;
830+
831+
const fullSnapshotOnMutationsOver = this._options._experiments.fullSnapshotOnMutationsOver || 0;
832+
833+
// Create a breadcrumb if a lot of mutations happen at the same time
834+
// We can show this in the UI as an information with potential performance improvements
835+
if (count > 500 || (fullSnapshotOnMutationsOver && count > fullSnapshotOnMutationsOver)) {
836+
const breadcrumb = createBreadcrumb({
837+
category: 'replay.mutations',
838+
data: {
839+
count,
840+
},
841+
});
842+
this._createCustomBreadcrumb(breadcrumb);
843+
}
844+
845+
if (fullSnapshotOnMutationsOver && count > fullSnapshotOnMutationsOver) {
846+
// We want to skip doing an incremental snapshot if there are too many mutations
847+
// Instead, we do a full snapshot
848+
this._triggerFullSnapshot(false);
849+
return false;
850+
}
851+
852+
// `true` means we use the regular mutation handling by rrweb
853+
return true;
854+
};
842855
}

packages/replay/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export interface ReplayPluginOptions extends SessionOptions {
110110
_experiments: Partial<{
111111
captureExceptions: boolean;
112112
traceInternals: boolean;
113-
captureMutationSize: boolean;
113+
fullSnapshotOnMutationsOver: number;
114114
}>;
115115
}
116116

0 commit comments

Comments
 (0)