Skip to content

Commit ba73d92

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 ea61856 commit ba73d92

File tree

6 files changed

+131
-53
lines changed

6 files changed

+131
-53
lines changed

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/integration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
170170
return;
171171
}
172172

173-
this._replay.start();
173+
void this._replay.start();
174174
}
175175

176176
/**

packages/replay/src/replay.ts

Lines changed: 55 additions & 22 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,6 +28,7 @@ import type {
2828
ReplayContainer as ReplayContainerInterface,
2929
ReplayPluginOptions,
3030
Session,
31+
FlushOptions,
3132
} from './types';
3233
import { addEvent } from './util/addEvent';
3334
import { addMemoryEntry } from './util/addMemoryEntry';
@@ -151,7 +152,7 @@ export class ReplayContainer implements ReplayContainerInterface {
151152
* Creates or loads a session, attaches listeners to varying events (DOM,
152153
* _performanceObserver, Recording, Sentry SDK, etc)
153154
*/
154-
public start(): void {
155+
public async start(): Promise<void> {
155156
this._setInitialState();
156157

157158
this._loadSession({ expiry: SESSION_IDLE_DURATION });
@@ -324,7 +325,6 @@ export class ReplayContainer implements ReplayContainerInterface {
324325
}
325326

326327
/**
327-
*
328328
* Always flush via `_debouncedFlush` so that we do not have flushes triggered
329329
* from calling both `flush` and `_debouncedFlush`. Otherwise, there could be
330330
* cases of mulitple flushes happening closely together.
@@ -335,7 +335,7 @@ export class ReplayContainer implements ReplayContainerInterface {
335335
return this._debouncedFlush.flush() as Promise<void>;
336336
}
337337

338-
/** Get the current sesion (=replay) ID */
338+
/** Get the current session (=replay) ID */
339339
public getSessionId(): string | undefined {
340340
return this.session && this.session.id;
341341
}
@@ -625,7 +625,7 @@ export class ReplayContainer implements ReplayContainerInterface {
625625
// Send replay when the page/tab becomes hidden. There is no reason to send
626626
// replay if it becomes visible, since no actions we care about were done
627627
// while it was hidden
628-
this._conditionalFlush();
628+
this._conditionalFlush({finishImmediate: true});
629629
}
630630

631631
/**
@@ -747,11 +747,20 @@ export class ReplayContainer implements ReplayContainerInterface {
747747
/**
748748
* Only flush if `this.recordingMode === 'session'`
749749
*/
750-
private _conditionalFlush(): void {
750+
private _conditionalFlush(options: FlushOptions = {}): void {
751751
if (this.recordingMode === 'error') {
752752
return;
753753
}
754754

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+
755764
void this.flushImmediate();
756765
}
757766

@@ -795,40 +804,60 @@ export class ReplayContainer implements ReplayContainerInterface {
795804
*
796805
* Should never be called directly, only by `flush`
797806
*/
798-
private async _runFlush(): Promise<void> {
807+
private async _runFlush(options: FlushOptions = {}): Promise<void> {
799808
if (!this.session || !this.eventBuffer) {
800809
__DEBUG_BUILD__ && logger.error('[Replay] No session or eventBuffer found to flush.');
801810
return;
802811
}
803812

804-
await this._addPerformanceEntries();
813+
try {
814+
this._debouncedFlush.cancel();
805815

806-
// Check eventBuffer again, as it could have been stopped in the meanwhile
807-
if (!this.eventBuffer || !this.eventBuffer.pendingLength) {
808-
return;
809-
}
816+
const promises: Promise<any>[] = [];
810817

811-
// Only attach memory event if eventBuffer is not empty
812-
await addMemoryEntry(this);
818+
promises.push(this._addPerformanceEntries());
813819

814-
// Check eventBuffer again, as it could have been stopped in the meanwhile
815-
if (!this.eventBuffer) {
816-
return;
817-
}
820+
// Do not continue if there are no pending events in buffer
821+
if (!this.eventBuffer?.pendingLength) {
822+
return;
823+
}
818824

819-
try {
820-
// Note this empties the event buffer regardless of outcome of sending replay
821-
const recordingData = await this.eventBuffer.finish();
825+
// Only attach memory entry if eventBuffer is not empty
826+
promises.push(addMemoryEntry(this));
822827

823828
// NOTE: Copy values from instance members, as it's possible they could
824829
// change before the flush finishes.
825830
const replayId = this.session.id;
826831
const eventContext = this._popEventContext();
827832
// Always increment segmentId regardless of outcome of sending replay
828833
const segmentId = this.session.segmentId++;
834+
835+
// Save session (new segment id) after we save flush data assuming either
836+
// 1) request succeeds or 2) it fails or never happens, in which case we
837+
// need to retry this segment.
829838
this._maybeSaveSession();
830839

831-
await sendReplay({
840+
let recordingData: ReplayRecordingData;
841+
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+
}
859+
860+
const sendReplayPromise = sendReplay({
832861
replayId,
833862
recordingData,
834863
segmentId,
@@ -838,6 +867,10 @@ export class ReplayContainer implements ReplayContainerInterface {
838867
options: this.getOptions(),
839868
timestamp: new Date().getTime(),
840869
});
870+
871+
await sendReplayPromise;
872+
873+
return;
841874
} catch (err) {
842875
this._handleException(err);
843876

packages/replay/src/types.ts

Lines changed: 18 additions & 0 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,6 +27,10 @@ export interface SendReplayData {
1827
options: ReplayPluginOptions;
1928
}
2029

30+
export type PendingReplayData = Omit<SendReplayData, 'recordingData'|'session'|'options'> & {
31+
recordingData: RecordingEvent[];
32+
};
33+
2134
export type InstrumentationTypeBreadcrumb = 'dom' | 'scope';
2235

2336
/**
@@ -237,6 +250,11 @@ export interface EventBuffer {
237250
* Clears and returns the contents of the buffer.
238251
*/
239252
finish(): Promise<ReplayRecordingData>;
253+
254+
/**
255+
* Clears and synchronously returns the pending contents of the buffer. This means no compression.
256+
*/
257+
finishImmediate(): string;
240258
}
241259

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

packages/replay/test/integration/flush.test.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -107,30 +107,24 @@ describe('Integration | flush', () => {
107107
replay && replay.stop();
108108
});
109109

110-
it('flushes twice after multiple flush() calls)', async () => {
111-
// blur events cause an immediate flush (as well as a flush due to adding a
112-
// breadcrumb) -- this means that the first blur event will be flushed and
113-
// the following blur events will all call a debounced flush function, which
114-
// should end up queueing a second flush
115-
110+
it('flushes after each blur event', async () => {
111+
// blur events cause an immediate flush that bypass the debounced flush
112+
// function and skip any async workers
113+
expect(mockRunFlush).toHaveBeenCalledTimes(0);
116114
WINDOW.dispatchEvent(new Event('blur'));
115+
expect(mockRunFlush).toHaveBeenCalledTimes(1);
117116
WINDOW.dispatchEvent(new Event('blur'));
117+
expect(mockRunFlush).toHaveBeenCalledTimes(2);
118118
WINDOW.dispatchEvent(new Event('blur'));
119+
expect(mockRunFlush).toHaveBeenCalledTimes(3);
119120
WINDOW.dispatchEvent(new Event('blur'));
121+
expect(mockRunFlush).toHaveBeenCalledTimes(4);
120122

121-
expect(mockFlush).toHaveBeenCalledTimes(4);
123+
expect(mockFlush).toHaveBeenCalledTimes(0);
122124

123125
jest.runAllTimers();
124126
await new Promise(process.nextTick);
125-
expect(mockRunFlush).toHaveBeenCalledTimes(1);
126-
127-
jest.runAllTimers();
128-
await new Promise(process.nextTick);
129-
expect(mockRunFlush).toHaveBeenCalledTimes(2);
130-
131-
jest.runAllTimers();
132-
await new Promise(process.nextTick);
133-
expect(mockRunFlush).toHaveBeenCalledTimes(2);
127+
expect(mockRunFlush).toHaveBeenCalledTimes(4);
134128
});
135129

136130
it('long first flush enqueues following events', async () => {
@@ -141,8 +135,11 @@ describe('Integration | flush', () => {
141135

142136
expect(mockAddPerformanceEntries).not.toHaveBeenCalled();
143137

144-
// flush #1 @ t=0s - due to blur
145-
WINDOW.dispatchEvent(new Event('blur'));
138+
// flush #1 @ t=0s - (blur bypasses debounce, so manually call `flushImmedate`)
139+
domHandler({
140+
name: 'click',
141+
});
142+
replay.flushImmediate();
146143
expect(mockFlush).toHaveBeenCalledTimes(1);
147144
expect(mockRunFlush).toHaveBeenCalledTimes(1);
148145

@@ -155,17 +152,23 @@ describe('Integration | flush', () => {
155152
expect(mockFlush).toHaveBeenCalledTimes(2);
156153

157154
await advanceTimers(1000);
158-
// flush #3 @ t=6s - due to blur
159-
WINDOW.dispatchEvent(new Event('blur'));
155+
domHandler({
156+
name: 'click',
157+
});
158+
// flush #3 @ t=6s
159+
replay.flushImmediate();
160160
expect(mockFlush).toHaveBeenCalledTimes(3);
161161

162162
// NOTE: Blur also adds a breadcrumb which calls `addUpdate`, meaning it will
163163
// flush after `flushMinDelay`, but this gets cancelled by the blur
164164
await advanceTimers(8000);
165165
expect(mockFlush).toHaveBeenCalledTimes(3);
166166

167-
// flush #4 @ t=14s - due to blur
168-
WINDOW.dispatchEvent(new Event('blur'));
167+
// flush #4 @ t=14s
168+
domHandler({
169+
name: 'click',
170+
});
171+
replay.flushImmediate();
169172
expect(mockFlush).toHaveBeenCalledTimes(4);
170173

171174
expect(mockRunFlush).toHaveBeenCalledTimes(1);

packages/replay/test/integration/stop.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ describe('Integration | stop', () => {
106106
};
107107

108108
addEvent(replay, TEST_EVENT);
109+
// This is an interesting test case because `start()` causes a checkout and a `flushImmediate`, and
110+
// blur causes a direct `runFlush` call to ensure if window unloads, it is able to send out an uncompressed segment.
111+
// This means that the non-async blur call can empty out the buffer before `flushImmediate` finishes.
109112
WINDOW.dispatchEvent(new Event('blur'));
110113
jest.runAllTimers();
111114
await new Promise(process.nextTick);
@@ -127,15 +130,15 @@ describe('Integration | stop', () => {
127130
});
128131

129132
it('does not buffer events when stopped', async function () {
130-
WINDOW.dispatchEvent(new Event('blur'));
133+
WINDOW.dispatchEvent(new Event('focus'));
131134
expect(replay.eventBuffer?.pendingLength).toBe(1);
132135

133136
// stop replays
134137
integration.stop();
135138

136139
expect(replay.eventBuffer?.pendingLength).toBe(undefined);
137140

138-
WINDOW.dispatchEvent(new Event('blur'));
141+
WINDOW.dispatchEvent(new Event('focus'));
139142
await new Promise(process.nextTick);
140143

141144
expect(replay.eventBuffer?.pendingLength).toBe(undefined);

0 commit comments

Comments
 (0)