Skip to content

Commit c34e11a

Browse files
committed
feat(replay): Keep min. 30s of data for error sessions
1 parent 15ec85b commit c34e11a

23 files changed

+410
-341
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/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"@sentry-internal/rrweb": "1.100.1",
5050
"@types/pako": "^2.0.0",
5151
"jsdom-worker": "^0.2.1",
52-
"pako": "^2.0.4",
52+
"pako": "2.1.0",
5353
"tslib": "^1.9.3"
5454
},
5555
"dependencies": {

packages/replay/src/constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +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;
30+
/* How often to capture a full checkout when in error mode */
31+
export const ERROR_CHECKOUT_TIME = 30_000;
3232

3333
export const RETRY_BASE_INTERVAL = 5000;
3434
export const RETRY_MAX_COUNT = 3;
Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,75 @@
1-
import type { AddEventResult, EventBuffer, RecordingEvent } from '../types';
1+
import type { ReplayRecordingData } from '@sentry/types';
2+
3+
import type { EventBuffer, RecordingEvent } from '../types';
4+
5+
interface EventsGroup {
6+
checkoutTimestamp: number;
7+
events: RecordingEvent[];
8+
}
29

310
/**
411
* A basic event buffer that does not do any compression.
512
* Used as fallback if the compression worker cannot be loaded or is disabled.
613
*/
714
export class EventBufferArray implements EventBuffer {
8-
private _events: RecordingEvent[];
15+
private _events: EventsGroup[];
916

1017
public constructor() {
1118
this._events = [];
1219
}
1320

1421
/** @inheritdoc */
1522
public get pendingLength(): number {
16-
return this._events.length;
23+
return this.pendingEvents.length;
1724
}
1825

19-
/**
20-
* Returns the raw events that are buffered. In `EventBufferArray`, this is the
21-
* same as `this._events`.
22-
*/
26+
/** @inheritdoc */
2327
public get pendingEvents(): RecordingEvent[] {
24-
return this._events;
28+
return this._events.reduce((acc, { events }) => [...events, ...acc], [] as RecordingEvent[]);
29+
}
30+
31+
/** @inheritdoc */
32+
public getFirstCheckoutTimestamp(): number | null {
33+
return (this._events[0] && this._events[0].checkoutTimestamp) || null;
2534
}
2635

2736
/** @inheritdoc */
2837
public destroy(): void {
29-
this._events = [];
38+
this.clear();
3039
}
3140

3241
/** @inheritdoc */
33-
public async addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
34-
if (isCheckout) {
35-
this._events = [event];
36-
return;
42+
public addEvent(event: RecordingEvent, isCheckout?: boolean): void {
43+
if (isCheckout || this._events.length === 0) {
44+
const group: EventsGroup = {
45+
checkoutTimestamp: event.timestamp,
46+
events: [event],
47+
};
48+
this._events.unshift(group);
49+
} else {
50+
this._events[0].events.push(event);
3751
}
38-
39-
this._events.push(event);
40-
return;
4152
}
4253

4354
/** @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;
55+
public clear(keepLastCheckout?: boolean): void {
56+
if (keepLastCheckout) {
57+
this._events.splice(1);
58+
} else {
5059
this._events = [];
51-
resolve(JSON.stringify(eventsRet));
52-
});
60+
}
61+
}
62+
63+
/** @inheritdoc */
64+
public finish(): Promise<ReplayRecordingData> {
65+
const pendingEvents = this.pendingEvents.slice();
66+
this.clear();
67+
68+
return Promise.resolve(this._finishRecording(pendingEvents));
69+
}
70+
71+
/** Finish in a sync manner. */
72+
protected _finishRecording(events: RecordingEvent[]): ReplayRecordingData {
73+
return JSON.stringify(events);
5374
}
5475
}

packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts

Lines changed: 17 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,23 @@
11
import type { ReplayRecordingData } from '@sentry/types';
22
import { logger } from '@sentry/utils';
33

4-
import type { AddEventResult, EventBuffer, RecordingEvent, WorkerRequest, WorkerResponse } from '../types';
4+
import type { RecordingEvent, WorkerRequest, WorkerResponse } from '../types';
5+
import { EventBufferArray } from './EventBufferArray';
56

67
/**
78
* Event buffer that uses a web worker to compress events.
89
* Exported only for testing.
910
*/
10-
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[] = [];
17-
11+
export class EventBufferCompressionWorker extends EventBufferArray {
1812
private _worker: Worker;
19-
private _eventBufferItemLength: number = 0;
13+
2014
private _id: number = 0;
2115
private _ensureReadyPromise?: Promise<void>;
2216

2317
public constructor(worker: Worker) {
18+
super();
2419
this._worker = worker;
2520
}
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;
41-
}
42-
4321
/**
4422
* Ensure the worker is ready (or not).
4523
* This will either resolve when the worker is ready, or reject if an error occured.
@@ -75,56 +53,34 @@ export class EventBufferCompressionWorker implements EventBuffer {
7553
return this._ensureReadyPromise;
7654
}
7755

78-
/**
79-
* Destroy the event buffer.
80-
*/
56+
/** @inheritdoc */
8157
public destroy(): void {
8258
__DEBUG_BUILD__ && logger.log('[Replay] Destroying compression worker');
8359
this._worker.terminate();
84-
}
85-
86-
/**
87-
* Add an event to the event buffer.
88-
*
89-
* Returns true if event was successfuly received and processed by worker.
90-
*/
91-
public async addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
92-
if (isCheckout) {
93-
// This event is a checkout, make sure worker buffer is cleared before
94-
// proceeding.
95-
await this._postMessage({
96-
id: this._getAndIncrementId(),
97-
method: 'init',
98-
args: [],
99-
});
100-
}
101-
102-
// Don't store checkout events in `_pendingEvents` because they are too large
103-
if (!isCheckout) {
104-
this._pendingEvents.push(event);
105-
}
106-
107-
return this._sendEventToWorker(event);
60+
super.destroy();
10861
}
10962

11063
/**
11164
* Finish the event buffer and return the compressed data.
11265
*/
11366
public async finish(): Promise<ReplayRecordingData> {
67+
const pendingEvents = this.pendingEvents.slice();
68+
69+
this.clear();
70+
11471
try {
115-
return await this._finishRequest(this._getAndIncrementId());
72+
return await this._compressEvents(this._getAndIncrementId(), pendingEvents);
11673
} catch (error) {
11774
__DEBUG_BUILD__ && logger.error('[Replay] Error when trying to compress events', error);
11875
// fall back to uncompressed
119-
const events = this.pendingEvents;
120-
return JSON.stringify(events);
76+
return this._finishRecording(pendingEvents);
12177
}
12278
}
12379

12480
/**
12581
* Post message to worker and wait for response before resolving promise.
12682
*/
127-
private _postMessage<T>({ id, method, args }: WorkerRequest): Promise<T> {
83+
private _postMessage<T>({ id, method, arg }: WorkerRequest): Promise<T> {
12884
return new Promise((resolve, reject) => {
12985
const listener = ({ data }: MessageEvent): void => {
13086
const response = data as WorkerResponse;
@@ -152,51 +108,18 @@ export class EventBufferCompressionWorker implements EventBuffer {
152108
resolve(response.response as T);
153109
};
154110

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-
163111
// Note: we can't use `once` option because it's possible it needs to
164112
// listen to multiple messages
165113
this._worker.addEventListener('message', listener);
166-
this._worker.postMessage({ id, method, args: stringifiedArgs });
167-
});
168-
}
169-
170-
/**
171-
* Send the event to the worker.
172-
*/
173-
private async _sendEventToWorker(event: RecordingEvent): Promise<AddEventResult> {
174-
const promise = this._postMessage<void>({
175-
id: this._getAndIncrementId(),
176-
method: 'addEvent',
177-
args: [event],
114+
this._worker.postMessage({ id, method, arg });
178115
});
179-
180-
// XXX: See note in `get length()`
181-
this._eventBufferItemLength++;
182-
183-
return promise;
184116
}
185117

186118
/**
187119
* Finish the request and return the compressed data from the worker.
188120
*/
189-
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;
196-
197-
this._pendingEvents = [];
198-
199-
return promise;
121+
private async _compressEvents(id: number, events: RecordingEvent[]): Promise<Uint8Array> {
122+
return this._postMessage<Uint8Array>({ id, method: 'compress', arg: JSON.stringify(events) });
200123
}
201124

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

packages/replay/src/eventBuffer/EventBufferProxy.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ReplayRecordingData } from '@sentry/types';
22
import { logger } from '@sentry/utils';
33

4-
import type { AddEventResult, EventBuffer, RecordingEvent } from '../types';
4+
import type { EventBuffer, RecordingEvent } from '../types';
55
import { EventBufferArray } from './EventBufferArray';
66
import { EventBufferCompressionWorker } from './EventBufferCompressionWorker';
77

@@ -21,9 +21,7 @@ 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 */
@@ -42,15 +40,16 @@ export class EventBufferProxy implements EventBuffer {
4240
this._compression.destroy();
4341
}
4442

45-
/**
46-
* Add an event to the event buffer.
47-
*
48-
* Returns true if event was successfully added.
49-
*/
50-
public addEvent(event: RecordingEvent, isCheckout?: boolean): Promise<AddEventResult> {
43+
/** @inheritdoc */
44+
public addEvent(event: RecordingEvent, isCheckout?: boolean): void {
5145
return this._used.addEvent(event, isCheckout);
5246
}
5347

48+
/** @inheritdoc */
49+
public clear(keepLastCheckout?: boolean): void {
50+
return this._used.clear(keepLastCheckout);
51+
}
52+
5453
/** @inheritDoc */
5554
public async finish(): Promise<ReplayRecordingData> {
5655
// Ensure the worker is loaded, so the sent event is compressed
@@ -59,6 +58,11 @@ export class EventBufferProxy implements EventBuffer {
5958
return this._used.finish();
6059
}
6160

61+
/** @inheritdoc */
62+
public getFirstCheckoutTimestamp(): number | null {
63+
return this._used.getFirstCheckoutTimestamp();
64+
}
65+
6266
/** Ensure the worker has loaded. */
6367
public ensureWorkerIsLoaded(): Promise<void> {
6468
return this._ensureWorkerIsLoadedPromise;
@@ -77,16 +81,14 @@ export class EventBufferProxy implements EventBuffer {
7781

7882
// Compression worker is ready, we can use it
7983
// Now we need to switch over the array buffer to the compression worker
80-
const addEventPromises: Promise<void>[] = [];
8184
for (const event of this._fallback.pendingEvents) {
82-
addEventPromises.push(this._compression.addEvent(event));
85+
this._compression.addEvent(event);
8386
}
8487

8588
// We switch over to the compression buffer immediately - any further events will be added
8689
// after the previously buffered ones
8790
this._used = this._compression;
8891

89-
// Wait for original events to be re-added before resolving
90-
await Promise.all(addEventPromises);
92+
this._fallback.clear();
9193
}
9294
}

packages/replay/src/replay.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { createEventBuffer } from './eventBuffer';
1717
import { getSession } from './session/getSession';
1818
import { saveSession } from './session/saveSession';
1919
import type {
20-
AddEventResult,
2120
AddUpdateCallback,
2221
AllPerformanceEntry,
2322
EventBuffer,
@@ -704,12 +703,12 @@ export class ReplayContainer implements ReplayContainerInterface {
704703
* Observed performance events are added to `this.performanceEvents`. These
705704
* are included in the replay event before it is finished and sent to Sentry.
706705
*/
707-
private _addPerformanceEntries(): Promise<Array<AddEventResult | null>> {
706+
private _addPerformanceEntries(): void {
708707
// Copy and reset entries before processing
709708
const entries = [...this.performanceEvents];
710709
this.performanceEvents = [];
711710

712-
return Promise.all(createPerformanceSpans(this, createPerformanceEntries(entries)));
711+
createPerformanceSpans(this, createPerformanceEntries(entries));
713712
}
714713

715714
/**

0 commit comments

Comments
 (0)