8000 feat(replay): Capture replay mutation breadcrumbs & add experiment (#… · lapa182/sentry-javascript@a2103f3 · GitHub
[go: up one dir, main page]

Skip to content

Commit a2103f3

Browse files
authored
feat(replay): Capture replay mutation breadcrumbs & add experiment (getsentry#7568)
1 parent 68c2301 commit a2103f3

File tree

8 files changed

+290
-20
lines changed

8 files changed

+290
-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: 500,
6+
flushMaxDelay: 500,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://public@dsn.ingest.sentry.io/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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 default options',
8+
async ({ getLocalTestPath, page, forceFlushReplay, browserName }) => {
9+
if (shouldSkipReplayTest() || ['webkit', 'firefox'].includes(browserName)) {
10+
sentryTest.skip();
11+
}
12+
13+
const reqPromise0 = waitForReplayRequest(page, 0);
14+
const reqPromise0b = waitForReplayRequest(page, 1);
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+
await forceFlushReplay();
28+
const res0 = await reqPromise0;
29+
await reqPromise0b;
30+
// A second request is sent right after initial snapshot, we want to wait for that to settle before we continue
31+
32+
const reqPromise1 = waitForReplayRequest(page);
33+
34+
void page.click('#button-add');
35+
await forceFlushReplay();
36+
const res1 = await reqPromise1;
37+
38+
const reqPromise2 = waitForReplayRequest(page);
39+
40+
void page.click('#button-modify');
41+
await forceFlushReplay();
42+
const res2 = await reqPromise2;
43+
44+
const reqPromise3 = waitForReplayRequest(page);
45+
46+
void page.click('#button-remove');
47+
await forceFlushReplay();
48+
const res3 = await reqPromise3;
49+
50+
const replayData0 = getReplayRecordingContent(res0);
51+
const replayData1 = getReplayRecordingContent(res1);
52+
const replayData2 = getReplayRecordingContent(res2);
53+
const replayData3 = getReplayRecordingContent(res3);
54+
55+
expect(replayData0.fullSnapshots.length).toBe(1);
56+
expect(replayData0.incrementalSnapshots.length).toBe(0);
57+
58+
expect(replayData1.fullSnapshots.length).toBe(0);
59+
expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0);
60+
61+
expect(replayData2.fullSnapshots.length).toBe(0);
62+
expect(replayData2.incrementalSnapshots.length).toBeGreaterThan(0);
63+
64+
expect(replayData3.fullSnapshots.length).toBe(0);
65+
expect(replayData3.incrementalSnapshots.length).toBeGreaterThan(0);
66+
},
67+
);
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: 500,
6+
flushMaxDelay: 500,
7+
_experiments: {
8+
mutationLimit: 250,
9+
},
10+
});
11+
12+
Sentry.init({
13+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
14+
sampleRate: 0,
15+
replaysSessionSampleRate: 1.0,
16+
replaysOnErrorSampleRate: 0.0,
17+
debug: true,
18+
19+
integrations: [window.Replay],
20+
});
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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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.mutationLimit configured',
8+
async ({ getLocalTestPath, page, forceFlushReplay, browserName }) => {
9+
if (shouldSkipReplayTest() || ['webkit', 'firefox'].includes(browserName)) {
10+
sentryTest.skip();
11+
}
12+
13+
const reqPromise0 = waitForReplayRequest(page, 0);
14+
const reqPromise0b = waitForReplayRequest(page, 1);
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+
await reqPromise0b;
29+
// A second request is sent right after initial snapshot, we want to wait for that to settle before we continue
30+
31+
const reqPromise1 = waitForReplayRequest(page);
32+
33+
void page.click('#button-add');
34+
await forceFlushReplay();
35+
const res1 = await reqPromise1;
36+
37+
const reqPromise2 = waitForReplayRequest(page);
38+
39+
void page.click('#button-modify');
40+
await forceFlushReplay();
41+
const res2 = await reqPromise2;
42+
43+
const reqPromise3 = waitForReplayRequest(page);
44+
45+
void page.click('#button-remove');
46+
await forceFlushReplay();
47+
const res3 = await reqPromise3;
48+
49+
const replayData0 = getReplayRecordingContent(res0);
50+
const replayData1 = getReplayRecordingContent(res1);
51+
const replayData2 = getReplayRecordingContent(res2);
52+
const replayData3 = getReplayRecordingContent(res3);
53+
54+
expect(replayData0.fullSnapshots.length).toBe(1);
55+
expect(replayData0.incrementalSnapshots.length).toBe(0);
56+
57+
// This includes both a full snapshot as well as some incremental snapshots
58+
expect(replayData1.fullSnapshots.length).toBe(1);
59+
expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0);
60+
61+
// This does not trigger mutations, for whatever reason - so no full snapshot either!
62+
expect(replayData2.fullSnapshots.length).toBe(0);
63+
expect(replayData2.incrementalSnapshots.length).toBeGreaterThan(0);
64+
65+
// This includes both a full snapshot as well as some incremental snapshots
66+
expect(replayData3.fullSnapshots.length).toBe(1);
67+
expect(replayData3.incrementalSnapshots.length).toBeGreaterThan(0);
68+
},
69+
);

packages/replay/src/replay.ts

Lines changed: 34 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,35 @@ 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 mutationLimit = this._options._experiments.mutationLimit || 0;
832+
const mutationBreadcrumbLimit = this._options._experiments.mutationBreadcrumbLimit || 1000;
833+
const overMutationLimit = mutationLimit && count > mutationLimit;
834+
835+
// Create a breadcrumb if a lot of mutations happen at the same time
836+
// We can show this in the UI as an information with potential performance improvements
837+
if (count > mutationBreadcrumbLimit || overMutationLimit) {
838+
const breadcrumb = createBreadcrumb({
839+
category: 'replay.mutations',
840+
data: {
841+
count,
842+
},
843+
});
844+
this._createCustomBreadcrumb(breadcrumb);
845+
}
846+
847+
if (overMutationLimit) {
848+
// We want to skip doing an incremental snapshot if there are too many mutations
849+
// Instead, we do a full snapshot
850+
this._triggerFullSnapshot(false);
851+
return false;
852+
}
853+
854+
// `true` means we use the regular mutation handling by rrweb
855+
return true;
856+
};
842857
}

packages/replay/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ export interface ReplayPluginOptions extends SessionOptions {
110110
_experiments: Partial<{
111111
captureExceptions: boolean;
112112
traceInternals: boolean;
113-
captureMutationSize: boolean;
113+
mutationLimit: number;
114+
mutationBreadcrumbLimit: number;
114115
}>;
115116
}
116117

0 commit comments

Comments
 (0)
0