Skip to content

Commit f359ef3

Browse files
mydeabillyvg
andauthored
feat(replay): Throttle breadcrumbs to max 300/5s (#8086)
This updates custom breadcrumb handling to be throttled to max. 300 breadcrumbs/5s. If we exceed this amount of breadcrumbs, we drop any further breadcrumbs and instead add a single breadcrumb with `category: 'replay.throttled'`, which the UI can use to indicate that _something_ was dropped. Closes #8072 --------- Co-authored-by: Billy Vong <[email protected]>
1 parent 41fef4b commit f359ef3

File tree

12 files changed

+344
-18
lines changed

12 files changed

+344
-18
lines changed
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+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const COUNT = 400;
2+
3+
document.querySelector('[data-console]').addEventListener('click', () => {
4+
// Call console.log() many times
5+
for (let i = 0; i < COUNT; i++) {
6+
console.log(`testing ${i}`);
7+
}
8+
});
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 data-console>Trigger console.log</button>
8+
</body>
9+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
5+
6+
const THROTTLE_LIMIT = 300;
7+
8+
sentryTest(
9+
'throttles breadcrumbs when many `console.log` are made at the same time',
10+
async ({ getLocalTestUrl, page, forceFlushReplay, browserName }) => {
11+
if (shouldSkipReplayTest() || browserName !== 'chromium') {
12+
sentryTest.skip();
13+
}
14+
15+
const reqPromise0 = waitForReplayRequest(page, 0);
16+
const reqPromise1 = waitForReplayRequest(page, 1);
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 getLocalTestUrl({ testDir: __dirname });
27+
28+
await page.goto(url);
29+
await reqPromise0;
30+
31+
await page.click('[data-console]');
32+
await forceFlushReplay();
33+
34+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
35+
36+
// 1 click breadcrumb + 1 throttled breadcrumb is why console logs are less
37+
// than throttle limit
38+
expect(breadcrumbs.length).toBe(THROTTLE_LIMIT);
39+
expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'replay.throttled').length).toBe(1);
40+
},
41+
);

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

+22-8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@ export type IncrementalRecordingSnapshot = eventWithTime & {
3434

3535
export type RecordingSnapshot = FullRecordingSnapshot | IncrementalRecordingSnapshot;
3636

37+
/** Returns the replay event from the given request, or undefined if this is not a replay request. */
38+
export function getReplayEventFromRequest(req: Request): ReplayEvent | undefined {
39+
const postData = req.postData();
40+
if (!postData) {
41+
return undefined;
42+
}
43+
44+
try {
45+
const event = envelopeRequestParser(req);
46+
47+
if (!isReplayEvent(event)) {
48+
return undefined;
49+
}
50+
51+
return event;
52+
} catch {
53+
return undefined;
54+
}
55+
}
3756
/**
3857
* Waits for a replay request to be sent by the page and returns it.
3958
*
@@ -58,18 +77,13 @@ export function waitForReplayRequest(
5877
res => {
5978
const req = res.request();
6079

61-
const postData = req.postData();
62-
if (!postData) {
80+
const event = getReplayEventFromRequest(req);
81+
82+
if (!event) {
6383
return false;
6484
}
6585

6686
try {
67-
const event = envelopeRequestParser(req);
68-
69-
if (!isReplayEvent(event)) {
70-
return false;
71-
}
72-
7387
if (callback) {
7488
return callback(event, res);
7589
}

packages/replay/.eslintrc.js

+3-1
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

+1-2
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

+50-1
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. */
@@ -565,6 +581,39 @@ export class ReplayContainer implements ReplayContainerInterface {
565581
this._context.urls.push(url);
566582
}
567583

584+
/**
585+
* Add a breadcrumb event, that may be throttled.
586+
* If it was throttled, we add a custom breadcrumb to indicate that.
587+
*/
588+
public throttledAddEvent(
589+
event: RecordingEvent,
590+
isCheckout?: boolean,
591+
): typeof THROTTLED | typeof SKIPPED | Promise<AddEventResult | null> {
592+
const res = this._throttledAddEvent(event, isCheckout);
593+
594+
// If this is THROTTLED, it means we have throttled the event for the first time
595+
// In this case, we want to add a breadcrumb indicating that something was skipped
596+
if (res === THROTTLED) {
597+
const breadcrumb = createBreadcrumb({
598+
category: 'replay.throttled',
599+
});
600+
601+
this.addUpdate(() => {
602+
void addEvent(this, {
603+
type: EventType.Custom,
604+
timestamp: breadcrumb.timestamp || 0,
605+
data: {
606+
tag: 'breadcrumb',
607+
payload: breadcrumb,
608+
metric: true,
609+
},
610+
});
611+
});
612+
}
613+
614+
return res;
615+
}
616+
568617
/**
569618
* Initialize and start all listeners to varying events (DOM,
570619
* Performance Observer, Recording, Sentry SDK, etc)
@@ -803,7 +852,7 @@ export class ReplayContainer implements ReplayContainerInterface {
803852
*/
804853
private _createCustomBreadcrumb(breadcrumb: Breadcrumb): void {
805854
this.addUpdate(() => {
806-
void addEvent(this, {
855+
void this.throttledAddEvent({
807856
type: EventType.Custom,
808857
timestamp: breadcrumb.timestamp || 0,
809858
data: {

packages/replay/src/types.ts

+5
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;
@@ -522,6 +523,10 @@ export interface ReplayContainer {
522523
session: Session | undefined;
523524
recordingMode: ReplayRecordingMode;
524525
timeouts: Timeouts;
526+
throttledAddEvent: (
527+
event: RecordingEvent,
528+
isCheckout?: boolean,
529+
) => typeof THROTTLED | typeof SKIPPED | Promise<AddEventResult | null>;
525530
isEnabled(): boolean;
526531
isPaused(): boolean;
527532
getContext(): InternalEventContext;
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
}

packages/replay/src/util/throttle.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export const THROTTLED = '__THROTTLED';
2+
export const SKIPPED = '__SKIPPED';
3+
4+
/**
5+
* Create a throttled function off a given function.
6+
* When calling the throttled function, it will call the original function only
7+
* if it hasn't been called more than `maxCount` times in the last `durationSeconds`.
8+
*
9+
* Returns `THROTTLED` if throttled for the first time, after that `SKIPPED`,
10+
* or else the return value of the original function.
11+
*/
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
export function throttle<T extends (...rest: any[]) => any>(
14+
fn: T,
15+
maxCount: number,
16+
durationSeconds: number,
17+
): (...rest: Parameters<T>) => ReturnType<T> | typeof THROTTLED | typeof SKIPPED {
18+
const counter = new Map<number, number>();
19+
20+
const _cleanup = (now: number): void => {
21+
const threshold = now - durationSeconds;
22+
counter.forEach((_value, key) => {
23+
if (key < threshold) {
24+
counter.delete(key);
25+
}
26+
});
27+
};
28+
29+
const _getTotalCount = (): number => {
30+
return [...counter.values()].reduce((a, b) => a + b, 0);
31+
};
32+
33+
let isThrottled = false;
34+
35+
return (...rest: Parameters<T>): ReturnType<T> | typeof THROTTLED | typeof SKIPPED => {
36+
// Date in second-precision, which we use as basis for the throttling
37+
const now = Math.floor(Date.now() / 1000);
38+
39+
// First, make sure to delete any old entries
40+
_cleanup(now);
41+
42+
// If already over limit, do nothing
43+
if (_getTotalCount() >= maxCount) {
44+
const wasThrottled = isThrottled;
45+
isThrottled = true;
46+
return wasThrottled ? SKIPPED : THROTTLED;
47+
}
48+
49+
isThrottled = false;
50+
const count = counter.get(now) || 0;
51+
counter.set(now, count + 1);
52+
53+
return fn(...rest);
54+
};
55+
}

0 commit comments

Comments
 (0)