Skip to content

Commit e61da64

Browse files
committed
feat(replay): Throttle breadcrumbs to max 300/5s
1 parent 48ef411 commit e61da64

File tree

11 files changed

+442
-10
lines changed

11 files changed

+442
-10
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 5000,
6+
flushMaxDelay: 5000,
7+
useCompression: false,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 0,
13+
replaysSessionSampleRate: 1.0,
14+
replaysOnErrorSampleRate: 0.0,
15+
16+
integrations: [window.Replay],
17+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
window.loaded = [];
2+
const head = document.querySelector('head');
3+
4+
const COUNT = 250;
5+
6+
window.__isLoaded = (run = 1) => {
7+
return window.loaded.length === COUNT * 2 * run;
8+
};
9+
10+
document.querySelector('[data-network]').addEventListener('click', () => {
11+
const offset = window.loaded.length;
12+
13+
// Create many scripts
14+
for (let i = offset; i < offset + COUNT; i++) {
15+
const script = document.createElement('script');
16+
script.src = `/virtual-assets/script-${i}.js`;
17+
script.setAttribute('crossorigin', 'anonymous');
18+
head.appendChild(script);
19+
20+
script.addEventListener('load', () => {
21+
window.loaded.push(`script-${i}`);
22+
});
23+
}
24+
});
25+
26+
document.querySelector('[data-fetch]').addEventListener('click', () => {
27+
const offset = window.loaded.length;
28+
29+
// Make many fetch requests
30+
for (let i = offset; i < offset + COUNT; i++) {
31+
fetch(`/virtual-assets/fetch-${i}.json`).then(() => {
32+
window.loaded.push(`fetch-${i}`);
33+
});
34+
}
35+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button data-fetch>Trigger fetch requests</button>
8+
<button data-network>Trigger network requests</button>
9+
</body>
10+
</html>
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
5+
6+
const COUNT = 250;
7+
const THROTTLE_LIMIT = 300;
8+
9+
sentryTest(
10+
'throttles breadcrumbs when many requests are made at the same time',
11+
async ({ getLocalTestUrl, page, forceFlushReplay, browserName }) => {
12+
if (shouldSkipReplayTest() || browserName !== 'chromium') {
13+
sentryTest.skip();
14+
}
15+
16+
const reqPromise0 = waitForReplayRequest(page, 0);
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+
let scriptsLoaded = 0;
27+
let fetchLoaded = 0;
28+
29+
await page.route('**/virtual-assets/script-**', route => {
30+
scriptsLoaded++;
31+
return route.fulfill({
32+
status: 200,
33+
contentType: 'text/javascript',
34+
body: `const aha = ${'xx'.repeat(20_000)};`,
35+
});
36+
});
37+
38+
await page.route('**/virtual-assets/fetch-**', route => {
39+
fetchLoaded++;
40+
return route.fulfill({
41+
status: 200,
42+
contentType: 'application/json',
43+
body: JSON.stringify({ fetchResponse: 'aa'.repeat(20_000) }),
44+
});
45+
});
46+
47+
const url = await getLocalTestUrl({ testDir: __dirname });
48+
49+
await page.goto(url);
50+
await reqPromise0;
51+
52+
const reqPromise1 = waitForReplayRequest(
53+
page,
54+
(_event, res) => {
55+
const { performanceSpans } = getCustomRecordingEvents(res);
56+
57+
return performanceSpans.some(span => span.op === 'resource.script');
58+
},
59+
10_000,
60+
);
61+
const reqPromise1Breadcrumbs = waitForReplayRequest(
62+
page,
63+
(_event, res) => {
64+
const { breadcrumbs } = getCustomRecordingEvents(res);
65+
66+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'replay.throttled');
67+
},
68+
10_000,
69+
);
70+
71+
await page.click('[data-network]');
72+
await page.click('[data-fetch]');
73+
74+
await page.waitForFunction('window.__isLoaded()');
75+
await forceFlushReplay();
76+
77+
const { performanceSpans } = getCustomRecordingEvents(await reqPromise1);
78+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1Breadcrumbs);
79+
80+
// All assets have been _loaded_
81+
expect(scriptsLoaded).toBe(COUNT);
82+
expect(fetchLoaded).toBe(COUNT);
83+
84+
// But only some have been captured by replay
85+
// We check for <= THROTTLE_LIMIT, as there have been some captured before, which take up some of the throttle limit
86+
expect(performanceSpans.length).toBeLessThanOrEqual(THROTTLE_LIMIT);
87+
expect(performanceSpans.length).toBeGreaterThan(THROTTLE_LIMIT - 50);
88+
89+
expect(breadcrumbs.filter(({ category }) => category === 'replay.throttled').length).toBe(1);
90+
91+
// Now we wait for 6s (5s + some wiggle room), and make some requests again
92+
await page.waitForTimeout(6_000);
93+
await forceFlushReplay();
94+
95+
const reqPromise2 = waitForReplayRequest(
96+
page,
97+
(_event, res) => {
98+
const { performanceSpans } = getCustomRecordingEvents(res);
99+
100+
return performanceSpans.some(span => span.op === 'resource.script');
101+
},
102+
10_000,
103+
);
104+
const reqPromise2Breadcrumbs = waitForReplayRequest(
105+
page,
106+
(_event, res) => {
107+
const { breadcrumbs } = getCustomRecordingEvents(res);
108+
109+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'replay.throttled');
110+
},
111+
10_000,
112+
);
113+
114+
await page.click('[data-network]');
115+
await page.click('[data-fetch]');
116+
117+
await page.waitForFunction('window.__isLoaded(2)');
118+
await forceFlushReplay();
119+
120+
const { performanceSpans: performanceSpans2 } = getCustomRecordingEvents(await reqPromise2);
121+
const { breadcrumbs: breadcrumbs2 } = getCustomRecordingEvents(await reqPromise2Breadcrumbs);
122+
123+
// All assets have been _loaded_
124+
expect(scriptsLoaded).toBe(COUNT * 2);
125+
expect(fetchLoaded).toBe(COUNT * 2);
126+
127+
// But only some have been captured by replay
128+
// We check for <= THROTTLE_LIMIT, as there have been some captured before, which take up some of the throttle limit
129+
expect(performanceSpans2.length).toBeLessThanOrEqual(THROTTLE_LIMIT);
130+
expect(performanceSpans2.length).toBeGreaterThan(THROTTLE_LIMIT - 50);
131+
132+
expect(breadcrumbs2.filter(({ category }) => category === 'replay.throttled').length).toBe(1);
133+
},
134+
);

packages/replay/.eslintrc.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ module.exports = {
88
overrides: [
99
{
1010
files: ['src/**/*.ts'],
11-
rules: {},
11+
rules: {
12+
'@sentry-internal/sdk/no-unsupported-es6-methods': 'off',
13+
},
1214
},
1315
{
1416
files: ['jest.setup.ts', 'jest.config.ts'],

packages/replay/src/coreHandlers/util/addBreadcrumbEvent.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { Breadcrumb } from '@sentry/types';
33
import { normalize } from '@sentry/utils';
44

55
import type { ReplayContainer } from '../../types';
6-
import { addEvent } from '../../util/addEvent';
76

87
/**
98
* Add a breadcrumb event to replay.
@@ -20,7 +19,7 @@ export function addBreadcrumbEvent(replay: ReplayContainer, breadcrumb: Breadcru
2019
}
2120

2221
replay.addUpdate(() => {
23-
void addEvent(replay, {
22+
void replay.throttledAddEvent({
2423
type: EventType.Custom,
2524
// TODO: We were converting from ms to seconds for breadcrumbs, spans,
2625
// but maybe we should just keep them as milliseconds

packages/replay/src/replay.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
EventBuffer,
2525
InternalEventContext,
2626
PopEventContext,
27+
RecordingEvent,
2728
RecordingOptions,
2829
ReplayContainer as ReplayContainerInterface,
2930
ReplayPluginOptions,
@@ -42,6 +43,8 @@ import { getHandleRecordingEmit } from './util/handleRecordingEmit';
4243
import { isExpired } from './util/isExpired';
4344
import { isSessionExpired } from './util/isSessionExpired';
4445
import { sendReplay } from './util/sendReplay';
46+
import type { SKIPPED } from './util/throttle';
47+
import { throttle, THROTTLED } from './util/throttle';
4548

4649
/**
4750
* The main replay container class, which holds all the state and methods for recording and sending replays.
@@ -75,6 +78,11 @@ export class ReplayContainer implements ReplayContainerInterface {
7578
maxSessionLife: MAX_SESSION_LIFE,
7679
} as const;
7780

81+
private _throttledAddEvent: (
82+
event: RecordingEvent,
83+
isCheckout?: boolean,
84+
) => typeof THROTTLED | typeof SKIPPED | Promise<AddEventResult | null>;
85+
7886
/**
7987
* Options to pass to `rrweb.record()`
8088
*/
@@ -136,6 +144,14 @@ export class ReplayContainer implements ReplayContainerInterface {
136144
this._debouncedFlush = debounce(() => this._flush(), this._options.flushMinDelay, {
137145
maxWait: this._options.flushMaxDelay,
138146
});
147+
148+
this._throttledAddEvent = throttle(
149+
(event: RecordingEvent, isCheckout?: boolean) => addEvent(this, event, isCheckout),
150+
// Max 300 events...
151+
300,
152+
// ... per 5s
153+
5,
154+
);
139155
}
140156

141157
/** Get the event context. */
@@ -546,6 +562,38 @@ export class ReplayContainer implements ReplayContainerInterface {
546562
this._context.urls.push(url);
547563
}
548564

565+
/**
566+
* Add a breadcrumb event, that may be throttled.
567+
* If it was throttled, we add a custom breadcrumb to indicate that.
568+
*/
569+
public throttledAddEvent(
570+
event: RecordingEvent,
571+
isCheckout?: boolean,
572+
): typeof THROTTLED | typeof SKIPPED | Promise<AddEventResult | null> {
573+
const res = this._throttledAddEvent(event, isCheckout);
574+
575+
// If this is THROTTLED, it means we have throttled the event for the first time
576+
// In this case, we want to add a breadcrumb indicating that something was skipped
577+
if (res === THROTTLED) {
578+
const breadcrumb = createBreadcrumb({
579+
category: 'replay.throttled',
580+
});
581+
582+
this.addUpdate(() => {
583+
void addEvent(this, {
584+
type: EventType.Custom,
585+
timestamp: breadcrumb.timestamp || 0,
586+
data: {
587+
tag: 'breadcrumb',
588+
payload: breadcrumb,
589+
},
590+
});
591+
});
592+
}
593+
594+
return res;
595+
}
596+
549597
/**
550598
* Initialize and start all listeners to varying events (DOM,
551599
* Performance Observer, Recording, Sentry SDK, etc)
@@ -784,7 +832,7 @@ export class ReplayContainer implements ReplayContainerInterface {
784832
*/
785833
private _createCustomBreadcrumb(breadcrumb: Breadcrumb): void {
786834
this.addUpdate(() => {
787-
void addEvent(this, {
835+
void this.throttledAddEvent({
788836
type: EventType.Custom,
789837
timestamp: breadcrumb.timestamp || 0,
790838
data: {

packages/replay/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '@sentry/types';
99

1010
import type { eventWithTime, recordOptions } from './types/rrweb';
11+
import type { SKIPPED, THROTTLED } from './util/throttle';
1112

1213
export type RecordingEvent = eventWithTime;
1314
export type RecordingOptions = recordOptions;
@@ -498,6 +499,10 @@ export interface ReplayContainer {
498499
session: Session | undefined;
499500
recordingMode: ReplayRecordingMode;
500501
timeouts: Timeouts;
502+
throttledAddEvent: (
503+
event: RecordingEvent,
504+
isCheckout?: boolean,
505+
) => typeof THROTTLED | typeof SKIPPED | Promise<AddEventResult | null>;
501506
isEnabled(): boolean;
502507
isPaused(): boolean;
503508
getContext(): InternalEventContext;
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import { EventType } from '@sentry-internal/rrweb';
22

33
import type { AddEventResult, AllEntryData, ReplayContainer, ReplayPerformanceEntry } from '../types';
4-
import { addEvent } from './addEvent';
54

65
/**
7-
* Create a "span" for each performance entry. The parent transaction is `this.replayEvent`.
6+
* Create a "span" for each performance entry.
87
*/
98
export function createPerformanceSpans(
109
replay: ReplayContainer,
1110
entries: ReplayPerformanceEntry<AllEntryData>[],
1211
): Promise<AddEventResult | null>[] {
13-
return entries.map(({ type, start, end, name, data }) =>
14-
addEvent(replay, {
12+
return entries.map(({ type, start, end, name, data }) => {
13+
const response = replay.throttledAddEvent({
1514
type: EventType.Custom,
1615
timestamp: start,
1716
data: {
@@ -24,6 +23,9 @@ export function createPerformanceSpans(
2423
data,
2524
},
2625
},
27-
}),
28-
);
26+
});
27+
28+
// If response is a string, it means its either THROTTLED or SKIPPED
29+
return typeof response === 'string' ? Promise.resolve(null) : response;
30+
});
2931
}

0 commit comments

Comments
 (0)