Skip to content

Commit b13e86d

Browse files
committed
ref(replay): Pause recording when replay requests are rate-limited
1 parent 318b750 commit b13e86d

File tree

2 files changed

+138
-5
lines changed

2 files changed

+138
-5
lines changed

packages/replay/src/replay.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
/* eslint-disable max-lines */ // TODO: We might want to split this file up
22
import { addGlobalEventProcessor, captureException, getCurrentHub, setContext } from '@sentry/core';
33
import type { Breadcrumb, ReplayEvent, ReplayRecordingMode, TransportMakeRequestResponse } from '@sentry/types';
4-
import { addInstrumentationHandler, logger } from '@sentry/utils';
4+
import {
5+
addInstrumentationHandler,
6+
disabledUntil,
7+
isRateLimited,
8+
logger,
9+
RateLimits,
10+
updateRateLimits,
11+
} from '@sentry/utils';
512
import { EventType, record } from 'rrweb';
613

714
import {
@@ -127,6 +134,11 @@ export class ReplayContainer implements ReplayContainerInterface {
127134
initialUrl: '',
128135
};
129136

137+
/**
138+
* A RateLimits object holding the rate-limit durations in case a sent replay event was rate-limited.
139+
*/
140+
private _rateLimits: RateLimits = {};
141+
130142
constructor({ options, recordingOptions }: { options: ReplayPluginOptions; recordingOptions: RecordingOptions }) {
131143
this._recordingOptions = recordingOptions;
132144
this._options = options;
@@ -984,7 +996,15 @@ export class ReplayContainer implements ReplayContainerInterface {
984996
const envelope = createReplayEnvelope(replayEvent, recordingData, dsn, client.getOptions().tunnel);
985997

986998
try {
987-
return await transport.send(envelope);
999+
const response = await transport.send(envelope);
1000+
// TODO (v8): we can remove this guard once transport.end's type signature doesn't include void anymore
1001+
if (response) {
1002+
this._rateLimits = updateRateLimits(this._rateLimits, response);
1003+
if (isRateLimited(this._rateLimits, 'replay_event') || isRateLimited(this._rateLimits, 'replay_recording')) {
1004+
this._handleRateLimit();
1005+
}
1006+
}
1007+
return response;
9881008
} catch {
9891009
throw new Error(UNABLE_TO_SEND_REPLAY);
9901010
}
@@ -1036,9 +1056,8 @@ export class ReplayContainer implements ReplayContainerInterface {
10361056
throw new Error(`${UNABLE_TO_SEND_REPLAY} - max retries exceeded`);
10371057
}
10381058

1039-
this._retryCount = this._retryCount + 1;
10401059
// will retry in intervals of 5, 10, 30
1041-
this._retryInterval = this._retryCount * this._retryInterval;
1060+
this._retryInterval = ++this._retryCount * this._retryInterval;
10421061

10431062
return await new Promise((resolve, reject) => {
10441063
setTimeout(async () => {
@@ -1065,4 +1084,28 @@ export class ReplayContainer implements ReplayContainerInterface {
10651084
saveSession(this.session);
10661085
}
10671086
}
1087+
1088+
/**
1089+
* Pauses the replay and resumes it after the rate-limit duration is over.
1090+
*/
1091+
private _handleRateLimit(): void {
1092+
const rateLimitEnd = Math.max(
1093+
disabledUntil(this._rateLimits, 'replay_event'),
1094+
disabledUntil(this._rateLimits, 'replay_recording'),
1095+
);
1096+
1097+
const rateLimitDuration = rateLimitEnd - Date.now();
1098+
1099+
if (rateLimitDuration > 0) {
1100+
__DEBUG_BUILD__ && logger.warn('[Replay]', `Rate limit hit, pausing replay for ${rateLimitDuration}ms`);
1101+
this.pause();
1102+
this._debouncedFlush && this._debouncedFlush.cancel();
1103+
1104+
setTimeout(() => {
1105+
__DEBUG_BUILD__ && logger.info('[Replay]', 'Resuming replay after rate limit');
1106+
this.resume();
1107+
this._debouncedFlush();
1108+
}, rateLimitDuration);
1109+
}
1110+
}
10681111
}

packages/replay/test/unit/index.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getCurrentHub, Hub } from '@sentry/core';
2-
import { Event, Scope } from '@sentry/types';
2+
import { Event, Scope, TransportMakeRequestResponse } from '@sentry/types';
33
import { EventType } from 'rrweb';
44

55
import {
@@ -874,6 +874,96 @@ describe('Replay', () => {
874874
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
875875
expect(replay.flush).toHaveBeenCalledTimes(1);
876876
});
877+
878+
describe('rate-limiting behaviour', () => {
879+
it('pauses recording and flushing a rate limit is hit and resumes both after the rate limit duration is over', async () => {
880+
expect(replay.session?.segmentId).toBe(0);
881+
jest.spyOn(replay, 'sendReplay');
882+
jest.spyOn(replay, 'pause');
883+
jest.spyOn(replay, 'resume');
884+
// @ts-ignore private API
885+
jest.spyOn(replay, '_handleRateLimit');
886+
887+
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
888+
889+
mockTransportSend.mockImplementationOnce(() => {
890+
return Promise.resolve({
891+
statusCode: 429,
892+
headers: {
893+
'x-sentry-rate-limits': null,
894+
'retry-after': `30`,
895+
},
896+
} as TransportMakeRequestResponse);
897+
});
898+
899+
mockRecord._emitter(TEST_EVENT);
900+
901+
// T = base + 5
902+
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
903+
904+
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
905+
expect(mockTransportSend).toHaveBeenCalledTimes(1);
906+
expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) });
907+
908+
expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1);
909+
// resume() was called once before we even started
910+
expect(replay.resume).not.toHaveBeenCalled();
911+
expect(replay.pause).toHaveBeenCalledTimes(1);
912+
913+
// No user activity to trigger an update
914+
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
915+
expect(replay.session?.segmentId).toBe(1);
916+
917+
// let's simulate the rate-limit time of inactivity (30secs) and check that we don't do anything in the meantime
918+
const TEST_EVENT2 = { data: {}, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY, type: 3 };
919+
for (let i = 0; i < 5; i++) {
920+
const ev = {
921+
...TEST_EVENT2,
922+
timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * (i + 1),
923+
};
924+
mockRecord._emitter(ev);
925+
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
926+
expect(replay.isPaused()).toBe(true);
927+
expect(replay.sendReplay).toHaveBeenCalledTimes(1);
928+
expect(mockTransportSend).toHaveBeenCalledTimes(1);
929+
}
930+
931+
// T = base + 35
932+
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
933+
934+
// now, recording should resume and first, we expect a checkout event to be sent, as resume()
935+
// should trigger a full snapshot
936+
expect(replay.resume).toHaveBeenCalledTimes(1);
937+
expect(replay.isPaused()).toBe(false);
938+
939+
expect(replay.sendReplay).toHaveBeenCalledTimes(2);
940+
expect(replay).toHaveLastSentReplay({
941+
events: '[{"data":{"isCheckout":true},"timestamp":1580598035000,"type":2}]',
942+
});
943+
944+
// and let's also emit a new event and check that it is recorded
945+
const TEST_EVENT3 = {
946+
data: {},
947+
timestamp: BASE_TIMESTAMP + 7 * DEFAULT_FLUSH_MIN_DELAY,
948+
type: 3,
949+
};
950+
mockRecord._emitter(TEST_EVENT3);
951+
952+
// T = base + 40
953+
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
954+
expect(replay.sendReplay).toHaveBeenCalledTimes(3);
955+
expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT3]) });
956+
957+
// nothing should happen afterwards
958+
// T = base + 60
959+
await advanceTimers(20_000);
960+
expect(replay.sendReplay).toHaveBeenCalledTimes(3);
961+
expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT3]) });
962+
963+
// events array should be empty
964+
expect(replay.eventBuffer?.length).toBe(0);
965+
});
966+
});
877967
});
878968

879969
describe('eventProcessors', () => {

0 commit comments

Comments
 (0)