Skip to content

Commit b86ac10

Browse files
authored
fix(replay): Handle compression worker errors more gracefully (#6936)
1 parent 6098879 commit b86ac10

File tree

8 files changed

+263
-188
lines changed

8 files changed

+263
-188
lines changed

packages/replay/src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,8 @@ export const MASK_ALL_TEXT_SELECTOR = 'body *:not(style), body *:not(script)';
2727
export const DEFAULT_FLUSH_MIN_DELAY = 5_000;
2828
export const DEFAULT_FLUSH_MAX_DELAY = 5_000;
2929

30+
/* How long to wait for error checkouts */
31+
export const ERROR_CHECKOUT_TIME = 60_000;
32+
3033
export const RETRY_BASE_INTERVAL = 5000;
3134
export const RETRY_MAX_COUNT = 3;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { AddEventResult, EventBuffer, RecordingEvent } from '../types';
2+
3+
/**
4+
* A basic event buffer that does not do any compression.
5+
* Used as fallback if the compression worker cannot be loaded or is disabled.
6+
*/
7+
export class EventBufferArray implements EventBuffer {
8+
private _events: RecordingEvent[];
9+
10+
public constructor() {
11+
this._events = [];
12+
}
13+
14+
/** @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;
25+
}
26+
27+
/** @inheritdoc */
28+
public destroy(): void {
29+
this._events = [];
30+
}
31+
32+
/** @inheritdoc */
33+
public async addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
34+
if (isCheckout) {
35+
this._events = [event];
36+
return;
37+
}
38+
39+
this._events.push(event);
40+
return;
41+
}
42+
43+
/** @inheritdoc */
44+
public finish(): Promise<string> {
45+
return new Promise<string>(resolve => {
46+
// Make a copy of the events array reference and immediately clear the
47+
// events member so that we do not lose new events while uploading
48+
// attachment.
49+
const eventsRet = this._events;
50+
this._events = [];
51+
resolve(JSON.stringify(eventsRet));
52+
});
53+
}
54+
}

packages/replay/src/eventBuffer.ts renamed to packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts

Lines changed: 27 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,153 +1,7 @@
1-
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
2-
// TODO: figure out member access types and remove the line above
3-
41
import type { ReplayRecordingData } from '@sentry/types';
52
import { logger } from '@sentry/utils';
63

7-
import type { AddEventResult, EventBuffer, RecordingEvent, WorkerRequest } from './types';
8-
import workerString from './worker/worker.js';
9-
10-
interface CreateEventBufferParams {
11-
useCompression: boolean;
12-
}
13-
14-
/**
15-
* Create an event buffer for replays.
16-
*/
17-
export function createEventBuffer({ useCompression }: CreateEventBufferParams): EventBuffer {
18-
// eslint-disable-next-line no-restricted-globals
19-
if (useCompression && window.Worker) {
20-
const workerBlob = new Blob([workerString]);
21-
const workerUrl = URL.createObjectURL(workerBlob);
22-
23-
__DEBUG_BUILD__ && logger.log('[Replay] Using compression worker');
24-
const worker = new Worker(workerUrl);
25-
return new EventBufferProxy(worker);
26-
}
27-
28-
__DEBUG_BUILD__ && logger.log('[Replay] Using simple buffer');
29-
return new EventBufferArray();
30-
}
31-
32-
/**
33-
* This proxy will try to use the compression worker, and fall back to use the simple buffer if an error occurs there.
34-
* This can happen e.g. if the worker cannot be loaded.
35-
* Exported only for testing.
36-
*/
37-
export class EventBufferProxy implements EventBuffer {
38-
private _fallback: EventBufferArray;
39-
private _compression: EventBufferCompressionWorker;
40-
private _used: EventBuffer;
41-
42-
public constructor(worker: Worker) {
43-
this._fallback = new EventBufferArray();
44-
this._compression = new EventBufferCompressionWorker(worker);
45-
this._used = this._fallback;
46-
47-
void this._ensureWorkerIsLoaded();
48-
}
49-
50-
/** @inheritDoc */
51-
public get pendingLength(): number {
52-
return this._used.pendingLength;
53-
}
54-
55-
/** @inheritDoc */
56-
public get pendingEvents(): RecordingEvent[] {
57-
return this._used.pendingEvents;
58-
}
59-
60-
/** @inheritDoc */
61-
public destroy(): void {
62-
this._fallback.destroy();
63-
this._compression.destroy();
64-
}
65-
66-
/**
67-
* Add an event to the event buffer.
68-
*
69-
* Returns true if event was successfully added.
70-
*/
71-
public addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
72-
return this._used.addEvent(event, isCheckout);
73-
}
74-
75-
/** @inheritDoc */
76-
public finish(): Promise<ReplayRecordingData> {
77-
return this._used.finish();
78-
}
79-
80-
/** Ensure the worker has loaded. */
81-
private async _ensureWorkerIsLoaded(): Promise<void> {
82-
try {
83-
await this._compression.ensureReady();
84-
} catch (error) {
85-
// If the worker fails to load, we fall back to the simple buffer.
86-
// Nothing more to do from our side here
87-
__DEBUG_BUILD__ && logger.log('[Replay] Failed to load the compression worker, falling back to simple buffer');
88-
return;
89-
}
90-
91-
// Compression worker is ready, we can use it
92-
// Now we need to switch over the array buffer to the compression worker
93-
const addEventPromises: Promise<void>[] = [];
94-
for (const event of this._fallback.pendingEvents) {
95-
addEventPromises.push(this._compression.addEvent(event));
96-
}
97-
98-
// We switch over to the compression buffer immediately - any further events will be added
99-
// after the previously buffered ones
100-
this._used = this._compression;
101-
102-
// Wait for original events to be re-added before resolving
103-
await Promise.all(addEventPromises);
104-
}
105-
}
106-
107-
class EventBufferArray implements EventBuffer {
108-
private _events: RecordingEvent[];
109-
110-
public constructor() {
111-
this._events = [];
112-
}
113-
114-
public get pendingLength(): number {
115-
return this._events.length;
116-
}
117-
118-
/**
119-
* Returns the raw events that are buffered. In `EventBufferArray`, this is the
120-
* same as `this._events`.
121-
*/
122-
public get pendingEvents(): RecordingEvent[] {
123-
return this._events;
124-
}
125-
126-
public destroy(): void {
127-
this._events = [];
128-
}
129-
130-
public async addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
131-
if (isCheckout) {
132-
this._events = [event];
133-
return;
134-
}
135-
136-
this._events.push(event);
137-
return;
138-
}
139-
140-
public finish(): Promise<string> {
141-
return new Promise<string>(resolve => {
142-
// Make a copy of the events array reference and immediately clear the
143-
// events member so that we do not lose new events while uploading
144-
// attachment.
145-
const eventsRet = this._events;
146-
this._events = [];
147-
resolve(JSON.stringify(eventsRet));
148-
});
149-
}
150-
}
4+
import type { AddEventResult, EventBuffer, RecordingEvent, WorkerRequest, WorkerResponse } from '../types';
1515

1526
/**
1537
* Event buffer that uses a web worker to compress events.
@@ -164,6 +18,7 @@ export class EventBufferCompressionWorker implements EventBuffer {
16418
private _worker: Worker;
16519
private _eventBufferItemLength: number = 0;
16620
private _id: number = 0;
21+
private _ensureReadyPromise?: Promise<void>;
16722

16823
public constructor(worker: Worker) {
16924
this._worker = worker;
@@ -190,11 +45,16 @@ export class EventBufferCompressionWorker implements EventBuffer {
19045
* This will either resolve when the worker is ready, or reject if an error occured.
19146
*/
19247
public ensureReady(): Promise<void> {
193-
return new Promise((resolve, reject) => {
48+
// Ensure we only check once
49+
if (this._ensureReadyPromise) {
50+
return this._ensureReadyPromise;
51+
}
52+
53+
this._ensureReadyPromise = new Promise((resolve, reject) => {
19454
this._worker.addEventListener(
19555
'message',
19656
({ data }: MessageEvent) => {
197-
if (data.success) {
57+
if ((data as WorkerResponse).success) {
19858
resolve();
19959
} else {
20060
reject();
@@ -211,6 +71,8 @@ export class EventBufferCompressionWorker implements EventBuffer {
21171
{ once: true },
21272
);
21373
});
74+
75+
return this._ensureReadyPromise;
21476
}
21577

21678
/**
@@ -248,39 +110,46 @@ export class EventBufferCompressionWorker implements EventBuffer {
248110
/**
249111
* Finish the event buffer and return the compressed data.
250112
*/
251-
public finish(): Promise<Uint8Array> {
252-
return this._finishRequest(this._getAndIncrementId());
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+
}
253122
}
254123

255124
/**
256125
* Post message to worker and wait for response before resolving promise.
257126
*/
258127
private _postMessage<T>({ id, method, args }: WorkerRequest): Promise<T> {
259128
return new Promise((resolve, reject) => {
260-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
261-
const listener = ({ data }: MessageEvent) => {
262-
if (data.method !== method) {
129+
const listener = ({ data }: MessageEvent): void => {
130+
const response = data as WorkerResponse;
131+
if (response.method !== method) {
263132
return;
264133
}
265134

266135
// There can be multiple listeners for a single method, the id ensures
267136
// that the response matches the caller.
268-
if (data.id !== id) {
137+
if (response.id !== id) {
269138
return;
270139
}
271140

272141
// At this point, we'll always want to remove listener regardless of result status
273142
this._worker.removeEventListener('message', listener);
274143

275-
if (!data.success) {
144+
if (!response.success) {
276145
// TODO: Do some error handling, not sure what
277-
__DEBUG_BUILD__ && logger.error('[Replay]', data.response);
146+
__DEBUG_BUILD__ && logger.error('[Replay]', response.response);
278147

279148
reject(new Error('Error in compression worker'));
280149
return;
281150
}
282151

283-
resolve(data.response);
152+
resolve(response.response as T);
284153
};
285154

286155
let stringifiedArgs;

0 commit comments

Comments
 (0)