Skip to content

Commit 2ec3582

Browse files
mydeabillyvg
andauthored
feat(replay): Allow to treeshake & configure compression worker URL (#9409)
This PR does two things: 1. Allow to configure a `workerUrl` in replay config, which is expected to be an URL of a self-hosted worker script. a. Added an example worker script, which is a built version of the pako-based compression worker a. Users can basically host this file themselves and point to it in `workerUrl`, as long as it is on the same origin as the website itself. a. We can eventually document this in docs 1. Allows to configure `__SENTRY_EXCLUDE_REPLAY_WORKER__` in your build to strip the default included web worker. You can configure this if you're disabling compression anyhow, or if you want to configure a custom web worker as in the above step. Fixes #6739, and allows to reduce bundle size further. Once merged/released we can also add this to the bundler plugins `bundleSizeOptimizations` options. Note that we _do not recommend_ to disable the web worker completely. We only recommend to tree shake the worker code if you provide a custom worker URL - else, replay payloads will not be compressed, resulting in much larger payloads sent over the network, which is bad for your applications performance. Also note that when providing a custom worker, it is your own responsibility to keep it up to date - we try to keep the worker interface stable, and the worker is generally not updated often, but you should still check regularly when updating the SDK if the example worker has changed. --------- Co-authored-by: Billy Vong <[email protected]>
1 parent 1005925 commit 2ec3582

File tree

19 files changed

+4484
-23
lines changed

19 files changed

+4484
-23
lines changed

.size-limit.js

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ module.exports = [
2121
__RRWEB_EXCLUDE_CANVAS__: true,
2222
__RRWEB_EXCLUDE_SHADOW_DOM__: true,
2323
__RRWEB_EXCLUDE_IFRAME__: true,
24+
__SENTRY_EXCLUDE_REPLAY_WORKER__: true,
2425
}),
2526
);
2627
return config;

packages/browser-integration-tests/suites/replay/compression/subject.js

-6
This file was deleted.

packages/browser-integration-tests/suites/replay/compression/test.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
55
import {
66
getFullRecordingSnapshots,
77
getReplayEvent,
8+
replayEnvelopeIsCompressed,
89
shouldSkipReplayTest,
910
waitForReplayRequest,
1011
} from '../../../utils/replayHelpers';
1112

12-
sentryTest('replay recording should be compressed by default', async ({ getLocalTestPath, page }) => {
13+
sentryTest('replay recording should be compressed by default', async ({ getLocalTestPath, page, forceFlushReplay }) => {
1314
if (shouldSkipReplayTest()) {
1415
sentryTest.skip();
1516
}
@@ -27,10 +28,16 @@ sentryTest('replay recording should be compressed by default', async ({ getLocal
2728
const url = await getLocalTestPath({ testDir: __dirname });
2829

2930
await page.goto(url);
30-
const replayEvent0 = getReplayEvent(await reqPromise0);
31+
await forceFlushReplay();
32+
33+
const req0 = await reqPromise0;
34+
35+
const replayEvent0 = getReplayEvent(req0);
3136
expect(replayEvent0).toEqual(getExpectedReplayEvent());
3237

33-
const snapshots = getFullRecordingSnapshots(await reqPromise0);
38+
expect(replayEnvelopeIsCompressed(req0)).toEqual(true);
39+
40+
const snapshots = getFullRecordingSnapshots(req0);
3441
expect(snapshots.length).toEqual(1);
3542

3643
const stringifiedSnapshot = JSON.stringify(snapshots[0]);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
minReplayDuration: 0,
8+
useCompression: false,
9+
});
10+
11+
Sentry.init({
12+
dsn: 'https://[email protected]/1337',
13+
sampleRate: 0,
14+
replaysSessionSampleRate: 1.0,
15+
replaysOnErrorSampleRate: 0.0,
16+
17+
integrations: [window.Replay],
18+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="go-background">New Tab</button>
8+
</body>
9+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
5+
import {
6+
getFullRecordingSnapshots,
7+
getReplayEvent,
8+
replayEnvelopeIsCompressed,
9+
shouldSkipReplayTest,
10+
waitForReplayRequest,
11+
} from '../../../utils/replayHelpers';
12+
13+
sentryTest(
14+
'replay recording should allow to disable compression',
15+
async ({ getLocalTestPath, page, forceFlushReplay }) => {
16+
if (shouldSkipReplayTest()) {
17+
sentryTest.skip();
18+
}
19+
20+
const reqPromise0 = waitForReplayRequest(page, 0);
21+
22+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
23+
return route.fulfill({
24+
status: 200,
25+
contentType: 'application/json',
26+
body: JSON.stringify({ id: 'test-id' }),
27+
});
28+
});
29+
30+
const url = await getLocalTestPath({ testDir: __dirname });
31+
32+
await page.goto(url);
33+
await forceFlushReplay();
34+
35+
const req0 = await reqPromise0;
36+
37+
const replayEvent0 = getReplayEvent(req0);
38+
expect(replayEvent0).toEqual(getExpectedReplayEvent());
39+
40+
expect(replayEnvelopeIsCompressed(req0)).toEqual(false);
41+
42+
const snapshots = getFullRecordingSnapshots(req0);
43+
expect(snapshots.length).toEqual(1);
44+
45+
const stringifiedSnapshot = JSON.stringify(snapshots[0]);
46+
expect(stringifiedSnapshot).toContain('"tagName":"body"');
47+
expect(stringifiedSnapshot).toContain('"tagName":"html"');
48+
expect(stringifiedSnapshot).toContain('"tagName":"button"');
49+
expect(stringifiedSnapshot).toContain('"textContent":"*** ***"');
50+
expect(stringifiedSnapshot).toContain('"id":"go-background"');
51+
},
52+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
minReplayDuration: 0,
8+
useCompression: true,
9+
workerUrl: `${window.location.origin}/my-test-worker.js`,
10+
});
11+
12+
Sentry.init({
13+
dsn: 'https://[email protected]/1337',
14+
sampleRate: 0,
15+
replaysSessionSampleRate: 1.0,
16+
replaysOnErrorSampleRate: 0.0,
17+
18+
integrations: [window.Replay],
19+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="go-background">New Tab</button>
8+
</body>
9+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect } from '@playwright/test';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
import { sentryTest, TEST_HOST } from '../../../utils/fixtures';
6+
import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
7+
import {
8+
getFullRecordingSnapshots,
9+
getReplayEvent,
10+
replayEnvelopeIsCompressed,
11+
shouldSkipReplayTest,
12+
waitForReplayRequest,
13+
} from '../../../utils/replayHelpers';
14+
15+
sentryTest(
16+
'replay recording should be compressed if using custom workerUrl',
17+
async ({ getLocalTestUrl, page, forceFlushReplay }) => {
18+
if (shouldSkipReplayTest()) {
19+
sentryTest.skip();
20+
}
21+
22+
const reqPromise0 = waitForReplayRequest(page, 0);
23+
24+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
25+
return route.fulfill({
26+
status: 200,
27+
contentType: 'application/json',
28+
body: JSON.stringify({ id: 'test-id' }),
29+
});
30+
});
31+
32+
const url = await getLocalTestUrl({ testDir: __dirname });
33+
34+
let customCompressCalled = 0;
35+
36+
// Ensure to register this _after_ getLocalTestUrl is called, as that also registers a default route for TEST_HOST
37+
await page.route(`${TEST_HOST}/my-test-worker.js`, route => {
38+
const filePath = path.resolve(__dirname, '../../../../replay-worker/examples/worker.min.js');
39+
40+
customCompressCalled++;
41+
42+
return fs.existsSync(filePath) ? route.fulfill({ path: filePath }) : route.continue();
43+
});
44+
45+
await page.goto(url);
46+
await forceFlushReplay();
47+
48+
const req0 = await reqPromise0;
49+
50+
const replayEvent0 = getReplayEvent(req0);
51+
expect(replayEvent0).toEqual(getExpectedReplayEvent());
52+
53+
expect(replayEnvelopeIsCompressed(req0)).toEqual(true);
54+
expect(customCompressCalled).toBe(1);
55+
56+
const snapshots = getFullRecordingSnapshots(req0);
57+
expect(snapshots.length).toEqual(1);
58+
59+
const stringifiedSnapshot = JSON.stringify(snapshots[0]);
60+
expect(stringifiedSnapshot).toContain('"tagName":"body"');
61+
expect(stringifiedSnapshot).toContain('"tagName":"html"');
62+
expect(stringifiedSnapshot).toContain('"tagName":"button"');
63+
expect(stringifiedSnapshot).toContain('"textContent":"*** ***"');
64+
expect(stringifiedSnapshot).toContain('"id":"go-background"');
65+
},
66+
);

packages/browser-integration-tests/utils/replayEventTemplates.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const DEFAULT_REPLAY_EVENT = {
88
timestamp: expect.any(Number),
99
error_ids: [],
1010
trace_ids: [],
11-
urls: [expect.stringContaining('/dist/index.html')],
11+
urls: [expect.stringContaining('/index.html')],
1212
replay_id: expect.stringMatching(/\w{32}/),
1313
replay_start_timestamp: expect.any(Number),
1414
segment_id: 0,
@@ -31,7 +31,7 @@ const DEFAULT_REPLAY_EVENT = {
3131
name: 'sentry.javascript.browser',
3232
},
3333
request: {
34-
url: expect.stringContaining('/dist/index.html'),
34+
url: expect.stringContaining('/index.html'),
3535
headers: {
3636
'User-Agent': expect.stringContaining(''),
3737
},

packages/browser-integration-tests/utils/replayHelpers.ts

+32
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,38 @@ const replayEnvelopeRequestParser = (request: Request | null, envelopeIndex = 2)
302302
return envelope[envelopeIndex] as Event;
303303
};
304304

305+
export function replayEnvelopeIsCompressed(resOrReq: Request | Response): boolean {
306+
const request = getRequest(resOrReq);
307+
308+
// https://develop.sentry.dev/sdk/envelopes/
309+
const envelopeBytes = request.postDataBuffer() || '';
310+
311+
// first, we convert the bugger to string to split and go through the uncompressed lines
312+
const envelopeString = envelopeBytes.toString();
313+
314+
const lines: boolean[] = envelopeString.split('\n').map(line => {
315+
try {
316+
JSON.parse(line);
317+
} catch (error) {
318+
// If we fail to parse a line, we _might_ have found a compressed payload,
319+
// so let's check if this is actually the case.
320+
// This is quite hacky but we can't go through `line` because the prior operations
321+
// seem to have altered its binary content. Hence, we take the raw envelope and
322+
// look up the place where the zlib compression header(0x78 0x9c) starts
323+
for (let i = 0; i < envelopeBytes.length; i++) {
324+
if (envelopeBytes[i] === 0x78 && envelopeBytes[i + 1] === 0x9c) {
325+
// We found a zlib-compressed payload
326+
return true;
327+
}
328+
}
329+
}
330+
331+
return false;
332+
});
333+
334+
return lines.some(line => line);
335+
}
336+
305337
export const replayEnvelopeParser = (request: Request | null): unknown[] => {
306338
// https://develop.sentry.dev/sdk/envelopes/
307339
const envelopeBytes = request?.postDataBuffer() || '';

0 commit comments

Comments
 (0)