Skip to content

Commit dd30d21

Browse files
committed
fix(replay): Handle compression failures more robustly
We now stop the recording when either `addEvent` or `finish` fail.
1 parent a8d8dfa commit dd30d21

20 files changed

+426
-379
lines changed

packages/replay/.eslintrc.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ module.exports = {
1313
// TODO: figure out if we need a worker-specific tsconfig
1414
project: ['tsconfig.worker.json'],
1515
},
16+
rules: {
17+
// We cannot use backticks, as that conflicts with the stringified worker
18+
'prefer-template': 'off',
19+
},
1620
},
1721
{
1822
files: ['src/worker/**/*.js'],

packages/replay/src/eventBuffer/EventBufferArray.ts

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,31 @@ import type { AddEventResult, EventBuffer, RecordingEvent } from '../types';
55
* Used as fallback if the compression worker cannot be loaded or is disabled.
66
*/
77
export class EventBufferArray implements EventBuffer {
8-
private _events: RecordingEvent[];
8+
/** All the events that are buffered to be sent. */
9+
public events: RecordingEvent[];
910

1011
public constructor() {
11-
this._events = [];
12+
this.events = [];
1213
}
1314

1415
/** @inheritdoc */
15-
public get pendingLength(): number {
16-
return this._events.length;
17-
}
18-
19-
/**
20-
* Returns the raw events that are buffered. In `EventBufferArray`, this is the
21-
* same as `this._events`.
22-
*/
23-
public get pendingEvents(): RecordingEvent[] {
24-
return this._events;
16+
public get hasEvents(): boolean {
17+
return this.events.length > 0;
2518
}
2619

2720
/** @inheritdoc */
2821
public destroy(): void {
29-
this._events = [];
22+
this.events = [];
3023
}
3124

3225
/** @inheritdoc */
3326
public async addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
3427
if (isCheckout) {
35-
this._events = [event];
28+
this.events = [event];
3629
return;
3730
}
3831

39-
this._events.push(event);
32+
this.events.push(event);
4033
return;
4134
}
4235

@@ -46,8 +39,8 @@ export class EventBufferArray implements EventBuffer {
4639
// Make a copy of the events array reference and immediately clear the
4740
// events member so that we do not lose new events while uploading
4841
// attachment.
49-
const eventsRet = this._events;
50-
this._events = [];
42+
const eventsRet = this.events;
43+
this.events = [];
5144
resolve(JSON.stringify(eventsRet));
5245
});
5346
}

packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts

Lines changed: 18 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,17 @@ import type { AddEventResult, EventBuffer, RecordingEvent, WorkerRequest, Worker
88
* Exported only for testing.
99
*/
1010
export class EventBufferCompressionWorker implements EventBuffer {
11-
/**
12-
* Keeps track of the list of events since the last flush that have not been compressed.
13-
* For example, page is reloaded and a flush attempt is made, but
14-
* `finish()` (and thus the flush), does not complete.
15-
*/
16-
public _pendingEvents: RecordingEvent[] = [];
11+
/** @inheritdoc */
12+
public hasEvents: boolean;
1713

1814
private _worker: Worker;
19-
private _eventBufferItemLength: number = 0;
20-
private _id: number = 0;
15+
private _id: number;
2116
private _ensureReadyPromise?: Promise<void>;
2217

2318
public constructor(worker: Worker) {
2419
this._worker = worker;
25-
}
26-
27-
/**
28-
* The number of raw events that are buffered. This may not be the same as
29-
* the number of events that have been compresed in the worker because
30-
* `addEvent` is async.
31-
*/
32-
public get pendingLength(): number {
33-
return this._eventBufferItemLength;
34-
}
35-
36-
/**
37-
* Returns a list of the raw recording events that are being compressed.
38-
*/
39-
public get pendingEvents(): RecordingEvent[] {
40-
return this._pendingEvents;
20+
this.hasEvents = false;
21+
this._id = 0;
4122
}
4223

4324
/**
@@ -89,42 +70,31 @@ export class EventBufferCompressionWorker implements EventBuffer {
8970
* Returns true if event was successfuly received and processed by worker.
9071
*/
9172
public async addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
73+
this.hasEvents = true;
74+
9275
if (isCheckout) {
9376
// This event is a checkout, make sure worker buffer is cleared before
9477
// proceeding.
9578
await this._postMessage({
9679
id: this._getAndIncrementId(),
97-
method: 'init',
98-
args: [],
80+
method: 'clear',
9981
});
10082
}
10183

102-
// Don't store checkout events in `_pendingEvents` because they are too large
103-
if (!isCheckout) {
104-
this._pendingEvents.push(event);
105-
}
106-
10784
return this._sendEventToWorker(event);
10885
}
10986

11087
/**
11188
* Finish the event buffer and return the compressed data.
11289
*/
113-
public async finish(): Promise<ReplayRecordingData> {
114-
try {
115-
return await this._finishRequest(this._getAndIncrementId());
116-
} catch (error) {
117-
__DEBUG_BUILD__ && logger.error('[Replay] Error when trying to compress events', error);
118-
// fall back to uncompressed
119-
const events = this.pendingEvents;
120-
return JSON.stringify(events);
121-
}
90+
public finish(): Promise<ReplayRecordingData> {
91+
return this._finishRequest(this._getAndIncrementId());
12292
}
12393

12494
/**
12595
* Post message to worker and wait for response before resolving promise.
12696
*/
127-
private _postMessage<T>({ id, method, args }: WorkerRequest): Promise<T> {
97+
private _postMessage<T>({ id, method, arg }: WorkerRequest): Promise<T> {
12898
return new Promise((resolve, reject) => {
12999
const listener = ({ data }: MessageEvent): void => {
130100
const response = data as WorkerResponse;
@@ -152,51 +122,33 @@ export class EventBufferCompressionWorker implements EventBuffer {
152122
resolve(response.response as T);
153123
};
154124

155-
let stringifiedArgs;
156-
try {
157-
stringifiedArgs = JSON.stringify(args);
158-
} catch (err) {
159-
__DEBUG_BUILD__ && logger.error('[Replay] Error when trying to stringify args', err);
160-
stringifiedArgs = '[]';
161-
}
162-
163125
// Note: we can't use `once` option because it's possible it needs to
164126
// listen to multiple messages
165127
this._worker.addEventListener('message', listener);
166-
this._worker.postMessage({ id, method, args: stringifiedArgs });
128+
this._worker.postMessage({ id, method, arg });
167129
});
168130
}
169131

170132
/**
171133
* Send the event to the worker.
172134
*/
173-
private async _sendEventToWorker(event: RecordingEvent): Promise<AddEventResult> {
174-
const promise = this._postMessage<void>({
135+
private _sendEventToWorker(event: RecordingEvent): Promise<AddEventResult> {
136+
return this._postMessage<void>({
175137
id: this._getAndIncrementId(),
176138
method: 'addEvent',
177-
args: [event],
139+
arg: JSON.stringify(event),
178140
});
179-
180-
// XXX: See note in `get length()`
181-
this._eventBufferItemLength++;
182-
183-
return promise;
184141
}
185142

186143
/**
187144
* Finish the request and return the compressed data from the worker.
188145
*/
189146
private async _finishRequest(id: number): Promise<Uint8Array> {
190-
const promise = this._postMessage<Uint8Array>({ id, method: 'finish', args: [] });
191-
192-
// XXX: See note in `get length()`
193-
this._eventBufferItemLength = 0;
194-
195-
await promise;
147+
const response = await this._postMessage<Uint8Array>({ id, method: 'finish' });
196148

197-
this._pendingEvents = [];
149+
this.hasEvents = false;
198150

199-
return promise;
151+
return response;
200152
}
201153

202154
/** Get the current ID and increment it for the next call. */

packages/replay/src/eventBuffer/EventBufferProxy.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,12 @@ export class EventBufferProxy implements EventBuffer {
2121
this._compression = new EventBufferCompressionWorker(worker);
2222
this._used = this._fallback;
2323

24-
this._ensureWorkerIsLoadedPromise = this._ensureWorkerIsLoaded().catch(() => {
25-
// Ignore errors here
26-
});
24+
this._ensureWorkerIsLoadedPromise = this._ensureWorkerIsLoaded();
2725
}
2826

2927
/** @inheritDoc */
30-
public get pendingLength(): number {
31-
return this._used.pendingLength;
32-
}
33-
34-
/** @inheritDoc */
35-
public get pendingEvents(): RecordingEvent[] {
36-
return this._used.pendingEvents;
28+
public get hasEvents(): boolean {
29+
return this._used.hasEvents;
3730
}
3831

3932
/** @inheritDoc */
@@ -75,18 +68,28 @@ export class EventBufferProxy implements EventBuffer {
7568
return;
7669
}
7770

78-
// Compression worker is ready, we can use it
7971
// Now we need to switch over the array buffer to the compression worker
72+
await this._switchToCompressionWorker();
73+
}
74+
75+
/** Switch the used buffer to the compression worker. */
76+
private async _switchToCompressionWorker(): Promise<void> {
77+
const { events } = this._fallback;
78+
8079
const addEventPromises: Promise<void>[] = [];
81-
for (const event of this._fallback.pendingEvents) {
80+
for (const event of events) {
8281
addEventPromises.push(this._compression.addEvent(event));
8382
}
8483

85-
// We switch over to the compression buffer immediately - any further events will be added
84+
// We switch over to the new buffer immediately - any further events will be added
8685
// after the previously buffered ones
8786
this._used = this._compression;
8887

8988
// Wait for original events to be re-added before resolving
90-
await Promise.all(addEventPromises);
89+
try {
90+
await Promise.all(addEventPromises);
91+
} catch (error) {
92+
__DEBUG_BUILD__ && logger.warn('[Replay] Failed to add events when switching buffers.', error);
93+
}
9194
}
9295
}

packages/replay/src/replay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,7 @@ export class ReplayContainer implements ReplayContainerInterface {
772772
await this._addPerformanceEntries();
773773

774774
// Check eventBuffer again, as it could have been stopped in the meanwhile
775-
if (!this.eventBuffer || !this.eventBuffer.pendingLength) {
775+
if (!this.eventBuffer || !this.eventBuffer.hasEvents) {
776776
return;
777777
}
778778

packages/replay/src/types.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export interface SendReplayData {
2323
*/
2424
export interface WorkerRequest {
2525
id: number;
26-
method: string;
27-
args: unknown[];
26+
method: 'clear' | 'addEvent' | 'finish';
27+
arg?: string;
2828
}
2929

3030
// PerformancePaintTiming and PerformanceNavigationTiming are only available with TS 4.4 and newer
@@ -261,14 +261,9 @@ export interface Session {
261261

262262
export interface EventBuffer {
263263
/**
264-
* The number of raw events that are buffered
265-
*/
266-
readonly pendingLength: number;
267-
268-
/**
269-
* The raw events that are buffered.
264+
* If any events have been added to the buffer.
270265
*/
271-
readonly pendingEvents: RecordingEvent[];
266+
readonly hasEvents: boolean;
272267

273268
/**
274269
* Destroy the event buffer.

packages/replay/src/util/addEvent.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { logger } from '@sentry/utils';
2+
13
import { SESSION_IDLE_DURATION } from '../constants';
24
import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types';
35

@@ -39,5 +41,10 @@ export async function addEvent(
3941
replay.getContext().earliestEvent = timestampInMs;
4042
}
4143

42-
return replay.eventBuffer.addEvent(event, isCheckout);
44+
try {
45+
return await replay.eventBuffer.addEvent(event, isCheckout);
46+
} catch (error) {
47+
__DEBUG_BUILD__ && logger.error(error);
48+
replay.stop();
49+
}
4350
}

packages/replay/src/worker/worker.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/replay/test/integration/rateLimiting.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ describe('Integration | rate-limiting behaviour', () => {
168168
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) });
169169

170170
// events array should be empty
171-
expect(replay.eventBuffer?.pendingLength).toBe(0);
171+
expect(replay.eventBuffer?.hasEvents).toBe(false);
172172
},
173173
);
174174

@@ -253,7 +253,7 @@ describe('Integration | rate-limiting behaviour', () => {
253253
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) });
254254

255255
// events array should be empty
256-
expect(replay.eventBuffer?.pendingLength).toBe(0);
256+
expect(replay.eventBuffer?.hasEvents).toBe(false);
257257
});
258258

259259
it("doesn't do anything, if a rate limit is hit and recording is already paused", async () => {

packages/replay/test/integration/sendReplayEvent.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ describe('Integration | sendReplayEvent', () => {
106106
expect(replay.session?.segmentId).toBe(1);
107107

108108
// events array should be empty
109-
expect(replay.eventBuffer?.pendingLength).toBe(0);
109+
expect(replay.eventBuffer?.hasEvents).toBe(false);
110110
});
111111

112112
it('update last activity when user clicks mouse', async () => {
@@ -145,7 +145,7 @@ describe('Integration | sendReplayEvent', () => {
145145
expect(replay.session?.segmentId).toBe(1);
146146

147147
// events array should be empty
148-
expect(replay.eventBuffer?.pendingLength).toBe(0);
148+
expect(replay.eventBuffer?.hasEvents).toBe(false);
149149
});
150150

151151
it('uploads a replay event if maxFlushDelay is set 15 seconds have elapsed since the last replay upload', async () => {
@@ -173,7 +173,7 @@ describe('Integration | sendReplayEvent', () => {
173173
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
174174
expect(replay.session?.segmentId).toBe(1);
175175
// events array should be empty
176-
expect(replay.eventBuffer?.pendingLength).toBe(0);
176+
expect(replay.eventBuffer?.hasEvents).toBe(false);
177177

178178
// Let's make sure it continues to work
179179
mockTransportSend.mockClear();
@@ -218,7 +218,7 @@ describe('Integration | sendReplayEvent', () => {
218218
// Session's last activity should not be updated
219219
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
220220
// events array should be empty
221-
expect(replay.eventBuffer?.pendingLength).toBe(0);
221+
expect(replay.eventBuffer?.hasEvents).toBe(false);
222222
});
223223

224224
it('uploads a replay event when document becomes hidden', async () => {
@@ -246,7 +246,7 @@ describe('Integration | sendReplayEvent', () => {
246246
// visibilitystate as user being active
247247
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
248248
// events array should be empty
249-
expect(replay.eventBuffer?.pendingLength).toBe(0);
249+
expect(replay.eventBuffer?.hasEvents).toBe(false);
250250
});
251251

252252
it('uploads a dom breadcrumb 5 seconds after listener receives an event', async () => {

0 commit comments

Comments
 (0)