Skip to content

Commit 69d7dec

Browse files
committed
feat(replay): Add non-async flush for page unloads
Add a method of using a non-async flush so that we are able to send a segment when the user unloads the page.
1 parent 7f3fa0b commit 69d7dec

File tree

11 files changed

+112
-165
lines changed

11 files changed

+112
-165
lines changed

packages/replay/src/constants.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { GLOBAL_OBJ } from '@sentry/utils';
77
export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window;
88

99
export const REPLAY_SESSION_KEY = 'sentryReplaySession';
10-
export const PENDING_REPLAY_STATUS_KEY = 'sentryReplayFlushStatus';
11-
export const PENDING_REPLAY_DATA_KEY = 'sentryReplayFlushData';
1210
export const REPLAY_EVENT_NAME = 'replay_event';
1311
export const RECORDING_EVENT_NAME = 'replay_recording';
1412
export const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay';

packages/replay/src/coreHandlers/handleGlobalEvent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even
1313
return (event: Event) => {
1414
// Do not apply replayId to the root event
1515
if (event.type === REPLAY_EVENT_NAME) {
16+
// Replays have separate set of breadcrumbs, do not include breadcrumbs
17+
// from core SDK
18+
delete event.breadcrumbs;
1619
return event;
1720
}
1821

packages/replay/src/eventBuffer.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,23 @@ class EventBufferArray implements EventBuffer {
7373

7474
public finish(): Promise<string> {
7575
return new Promise<string>(resolve => {
76-
// Make a copy of the events array reference and immediately clear the
77-
// events member so that we do not lose new events while uploading
78-
// attachment.
79-
const eventsRet = this._events;
80-
this._events = [];
81-
resolve(JSON.stringify(eventsRet));
76+
resolve(
77+
this._finish());
8278
});
8379
}
80+
81+
public finishImmediate(): string {
82+
return this._finish();
83+
}
84+
85+
private _finish(): string {
86+
// Make a copy of the events array reference and immediately clear the
87+
// events member so that we do not lose new events while uploading
88+
// attachment.
89+
const events = this._events;
90+
this._events = [];
91+
return JSON.stringify(events);
92+
}
8493
}
8594

8695
/**
@@ -158,6 +167,18 @@ export class EventBufferCompressionWorker implements EventBuffer {
158167
return this._finishRequest(this._getAndIncrementId());
159168
}
160169

170+
/**
171+
* Finish the event buffer and return the pending events.
172+
*/
173+
public finishImmediate(): string {
174+
const events = this._pendingEvents;
175+
176+
// Ensure worker is still in a good state and disregard the result
177+
void this._finishRequest(this._getAndIncrementId());
178+
179+
return JSON.stringify(events);
180+
}
181+
161182
/**
162183
* Post message to worker and wait for response before resolving promise.
163184
*/

packages/replay/src/replay.ts

Lines changed: 38 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable max-lines */ // TODO: We might want to split this file up
22
import { addGlobalEventProcessor, captureException, getCurrentHub } from '@sentry/core';
3-
import type { Breadcrumb, ReplayRecordingMode } from '@sentry/types';
3+
import type { Breadcrumb, ReplayRecordingMode, ReplayRecordingData } from '@sentry/types';
44
import type { RateLimits } from '@sentry/utils';
55
import { addInstrumentationHandler, disabledUntil, logger } from '@sentry/utils';
66
import { EventType, record } from 'rrweb';
@@ -28,22 +28,19 @@ import type {
2828
ReplayContainer as ReplayContainerInterface,
2929
ReplayPluginOptions,
3030
Session,
31+
FlushOptions,
3132
} from './types';
32-
import { FlushState } from './types';
3333
import { addEvent } from './util/addEvent';
3434
import { addMemoryEntry } from './util/addMemoryEntry';
35-
import { clearPendingReplay } from './util/clearPendingReplay';
3635
import { createBreadcrumb } from './util/createBreadcrumb';
3736
import { createPerformanceEntries } from './util/createPerformanceEntries';
3837
import { createPerformanceSpans } from './util/createPerformanceSpans';
3938
import { debounce } from './util/debounce';
40-
import { getPendingReplay } from './util/getPendingReplay';
4139
import { isExpired } from './util/isExpired';
4240
import { isSessionExpired } from './util/isSessionExpired';
4341
import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent';
4442
import { sendReplay } from './util/sendReplay';
45-
import { RateLimitError,sendReplayRequest } from './util/sendReplayRequest';
46-
import { setFlushState } from './util/setFlushState';
43+
import { RateLimitError } from './util/sendReplayRequest';
4744

4845
/**
4946
* The main replay container class, which holds all the state and methods for recording and sending replays.
@@ -166,23 +163,6 @@ export class ReplayContainer implements ReplayContainerInterface {
166163
return;
167164
}
168165

169-
const useCompression = Boolean(this._options.useCompression);
170-
171-
// Flush any pending events that were previously unable to be sent
172-
try {
173-
const pendingEvent = await getPendingReplay({ useCompression });
174-
if (pendingEvent) {
175-
await sendReplayRequest({
176-
...pendingEvent,
177-
session: this.session,
178-
options: this._options,
179-
});
180-
clearPendingReplay();
181-
}
182-
} catch {
183-
// ignore
184-
}
185-
186166
if (!this.session.sampled) {
187167
// If session was not sampled, then we do not initialize the integration at all.
188168
return;
@@ -199,7 +179,7 @@ export class ReplayContainer implements ReplayContainerInterface {
199179
this._updateSessionActivity();
200180

201181
this.eventBuffer = createEventBuffer({
202-
useCompression,
182+
useCompression: Boolean(this._options.useCompression),
203183
});
204184

205185
this._addListeners();
@@ -345,7 +325,6 @@ export class ReplayContainer implements ReplayContainerInterface {
345325
}
346326

347327
/**
348-
*
349328
* Always flush via `_debouncedFlush` so that we do not have flushes triggered
350329
* from calling both `flush` and `_debouncedFlush`. Otherwise, there could be
351330
* cases of mulitple flushes happening closely together.
@@ -356,7 +335,7 @@ export class ReplayContainer implements ReplayContainerInterface {
356335
return this._debouncedFlush.flush() as Promise<void>;
357336
}
358337

359-
/** Get the current sesion (=replay) ID */
338+
/** Get the current session (=replay) ID */
360339
public getSessionId(): string | undefined {
361340
return this.session && this.session.id;
362341
}
@@ -387,7 +366,6 @@ export class ReplayContainer implements ReplayContainerInterface {
387366
// enable flag to create the root replay
388367
if (type === 'new') {
389368
this._setInitialState();
390-
clearPendingReplay();
391369
}
392370

393371
const currentSessionId = this.getSessionId();
@@ -647,7 +625,7 @@ export class ReplayContainer implements ReplayContainerInterface {
647625
// Send replay when the page/tab becomes hidden. There is no reason to send
648626
// replay if it becomes visible, since no actions we care about were done
649627
// while it was hidden
650-
this._conditionalFlush();
628+
this._conditionalFlush({finishImmediate: true});
651629
}
652630

653631
/**
@@ -769,11 +747,20 @@ export class ReplayContainer implements ReplayContainerInterface {
769747
/**
770748
* Only flush if `this.recordingMode === 'session'`
771749
*/
772-
private _conditionalFlush(): void {
750+
private _conditionalFlush(options: FlushOptions = {}): void {
773751
if (this.recordingMode === 'error') {
774752
return;
775753
}
776754

755+
/**
756+
* Page is likely to unload so need to bypass debounce completely and
757+
* synchronously retrieve pending events from buffer and send request asap.
758+
*/
759+
if (options.finishImmediate) {
760+
void this._runFlush(options);
761+
return;
762+
}
763+
777764
void this.flushImmediate();
778765
}
779766

@@ -817,13 +804,15 @@ export class ReplayContainer implements ReplayContainerInterface {
817804
*
818805
* Should never be called directly, only by `flush`
819806
*/
820-
private async _runFlush(): Promise<void> {
807+
private async _runFlush(options: FlushOptions = {}): Promise<void> {
821808
if (!this.session || !this.eventBuffer) {
822809
__DEBUG_BUILD__ && logger.error('[Replay] No session or eventBuffer found to flush.');
823810
return;
824811
}
825812

826813
try {
814+
this._debouncedFlush.cancel();
815+
827816
const promises: Promise<any>[] = [];
828817

829818
promises.push(this._addPerformanceEntries());
@@ -843,29 +832,30 @@ export class ReplayContainer implements ReplayContainerInterface {
843832
// Always increment segmentId regardless of outcome of sending replay
844833
const segmentId = this.session.segmentId++;
845834

846-
// Write to local storage before flushing, in case flush request never starts.
847-
// Ensure that this happens before *any* `await` happens, otherwise we
848-
// will lose data.
849-
setFlushState(FlushState.START, {
850-
recordingData: this.eventBuffer.pendingEvents,
851-
replayId,
852-
eventContext,
853-
segmentId,
854-
includeReplayStartTimestamp: segmentId === 0,
855-
timestamp: new Date().getTime(),
856-
});
857-
858835
// Save session (new segment id) after we save flush data assuming either
859836
// 1) request succeeds or 2) it fails or never happens, in which case we
860837
// need to retry this segment.
861838
this._maybeSaveSession();
862839

863-
// NOTE: Be mindful that nothing after this point (the first `await`)
864-
// will run after when the page is unloaded.
865-
await Promise.all(promises);
840+
let recordingData: ReplayRecordingData;
866841

867-
// This empties the event buffer regardless of outcome of sending replay
868-
const recordingData = await this.eventBuffer.finish();
842+
if (options.finishImmediate && this.eventBuffer.pendingLength) {
843+
recordingData = this.eventBuffer.finishImmediate();
844+
} else {
845+
// NOTE: Be mindful that nothing after this point (the first `await`)
846+
// will run after when the page is unloaded.
847+
await Promise.all(promises);
848+
849+
// This can be empty due to blur events calling `runFlush` directly. In
850+
// the case where we have a snapshot checkout and a blur event
851+
// happening near the same time, the blur event can end up emptying the
852+
// buffer even if snapshot happens first.
853+
if (!this.eventBuffer.pendingLength) {
854+
return;
855+
}
856+
// This empties the event buffer regardless of outcome of sending replay
857+
recordingData = await this.eventBuffer.finish();
858+
}
869859

870860
const sendReplayPromise = sendReplay({
871861
replayId,
@@ -878,15 +868,11 @@ export class ReplayContainer implements ReplayContainerInterface {
878868
timestamp: new Date().getTime(),
879869
});
880870

881-
// If replay request starts, optimistically update some states
882-
setFlushState(FlushState.SENT_REQUEST);
883-
884871
await sendReplayPromise;
885872

886-
setFlushState(FlushState.SENT_REQUEST);
873+
return;
887874
} catch (err) {
888875
this._handleException(err);
889-
setFlushState(FlushState.ERROR);
890876

891877
if (err instanceof RateLimitError) {
892878
this._handleRateLimit(err.rateLimits);

packages/replay/src/types.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ export type RecordingOptions = recordOptions;
77

88
export type AllPerformanceEntry = PerformancePaintTiming | PerformanceResourceTiming | PerformanceNavigationTiming;
99

10+
export interface FlushOptions {
11+
/**
12+
* Attempt to finish the flush immediately without any asynchronous operations
13+
* (e.g. worker calls). This is not directly related to `flushImmediate` which
14+
* skips the debounced flush.
15+
*/
16+
finishImmediate?: boolean;
17+
}
18+
1019
export interface SendReplayData {
1120
recordingData: ReplayRecordingData;
1221
replayId: string;
@@ -18,13 +27,6 @@ export interface SendReplayData {
1827
options: ReplayPluginOptions;
1928
}
2029

21-
export enum FlushState {
22-
START = 'start',
23-
SENT_REQUEST = 'sent-request',
24-
COMPLETE = 'complete',
25-
ERROR = 'error',
26-
}
27-
2830
export type PendingReplayData = Omit<SendReplayData, 'recordingData'|'session'|'options'> & {
2931
recordingData: RecordingEvent[];
3032
};
@@ -248,6 +250,11 @@ export interface EventBuffer {
248250
* Clears and returns the contents of the buffer.
249251
*/
250252
finish(): Promise<ReplayRecordingData>;
253+
254+
/**
255+
* Clears and synchronously returns the pending contents of the buffer. This means no compression.
256+
*/
257+
finishImmediate(): string;
251258
}
252259

253260
export type AddUpdateCallback = () => boolean | void;

packages/replay/src/util/clearPendingReplay.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

packages/replay/src/util/getPendingReplay.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

packages/replay/src/util/sendReplayRequest.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,6 @@ export async function sendReplayRequest({
6969
errorSampleRate: options.errorSampleRate,
7070
};
7171

72-
// Replays have separate set of breadcrumbs, do not include breadcrumbs
73-
// from core SDK
74-
delete replayEvent.breadcrumbs;
75-
7672
/*
7773
For reference, the fully built event looks something like this:
7874
{

packages/replay/src/util/setFlushState.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)