diff --git a/packages/replay/src/eventBuffer.ts b/packages/replay/src/eventBuffer.ts index d80cf22879ad..d36094958c31 100644 --- a/packages/replay/src/eventBuffer.ts +++ b/packages/replay/src/eventBuffer.ts @@ -71,15 +71,17 @@ class EventBufferArray implements EventBuffer { return; } - public finish(): Promise { - return new Promise(resolve => { - // Make a copy of the events array reference and immediately clear the - // events member so that we do not lose new events while uploading - // attachment. - const eventsRet = this._events; - this._events = []; - resolve(JSON.stringify(eventsRet)); - }); + public async finish(): Promise { + return this.finishSync(); + } + + public finishSync(): string { + // Make a copy of the events array reference and immediately clear the + // events member so that we do not lose new events while uploading + // attachment. + const events = this._events; + this._events = []; + return JSON.stringify(events); } } @@ -158,6 +160,18 @@ export class EventBufferCompressionWorker implements EventBuffer { return this._finishRequest(this._getAndIncrementId()); } + /** + * Finish the event buffer and return the pending events. + */ + public finishSync(): string { + const events = this._pendingEvents; + + // Ensure worker is still in a good state and disregard the result + void this._finishRequest(this._getAndIncrementId()); + + return JSON.stringify(events); + } + /** * Post message to worker and wait for response before resolving promise. */ diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index ffba33c5631f..daee6d6c1615 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -20,6 +20,7 @@ import type { AddUpdateCallback, AllPerformanceEntry, EventBuffer, + FlushOptions, InstrumentationTypeBreadcrumb, InternalEventContext, PopEventContext, @@ -324,7 +325,6 @@ export class ReplayContainer implements ReplayContainerInterface { } /** - * * Always flush via `_debouncedFlush` so that we do not have flushes triggered * from calling both `flush` and `_debouncedFlush`. Otherwise, there could be * cases of mulitple flushes happening closely together. @@ -335,7 +335,7 @@ export class ReplayContainer implements ReplayContainerInterface { return this._debouncedFlush.flush() as Promise; } - /** Get the current sesion (=replay) ID */ + /** Get the current session (=replay) ID */ public getSessionId(): string | undefined { return this.session && this.session.id; } @@ -625,7 +625,7 @@ export class ReplayContainer implements ReplayContainerInterface { // Send replay when the page/tab becomes hidden. There is no reason to send // replay if it becomes visible, since no actions we care about were done // while it was hidden - this._conditionalFlush(); + this._conditionalFlush({ sync: true }); } /** @@ -747,11 +747,20 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Only flush if `this.recordingMode === 'session'` */ - private _conditionalFlush(): void { + private _conditionalFlush(options: FlushOptions = {}): void { if (this.recordingMode === 'error') { return; } + /** + * Page is likely to unload so need to bypass debounce completely and + * synchronously retrieve pending events from buffer and send request asap. + */ + if (options.sync) { + this._flushSync(); + return; + } + void this.flushImmediate(); } @@ -796,37 +805,29 @@ export class ReplayContainer implements ReplayContainerInterface { * Should never be called directly, only by `flush` */ private async _runFlush(): Promise { - if (!this.session || !this.eventBuffer) { - __DEBUG_BUILD__ && logger.error('[Replay] No session or eventBuffer found to flush.'); - return; - } + try { + const flushData = this._prepareFlush(); - await this._addPerformanceEntries(); + if (!flushData) { + return; + } - // Check eventBuffer again, as it could have been stopped in the meanwhile - if (!this.eventBuffer || !this.eventBuffer.pendingLength) { - return; - } + const { promises, replayId, segmentId, eventContext, eventBuffer, session } = flushData; - // Only attach memory event if eventBuffer is not empty - await addMemoryEntry(this); + // NOTE: Be mindful that nothing after this point (the first `await`) + // will run after when the page is unloaded. + await Promise.all(promises); - // Check eventBuffer again, as it could have been stopped in the meanwhile - if (!this.eventBuffer) { - return; - } + // This can be empty due to blur events calling `runFlush` directly. In + // the case where we have a snapshot checkout and a blur event + // happening near the same time, the blur event can end up emptying the + // buffer even if snapshot happens first. + if (!eventBuffer.pendingLength) { + return; + } - try { - // Note this empties the event buffer regardless of outcome of sending replay - const recordingData = await this.eventBuffer.finish(); - - // NOTE: Copy values from instance members, as it's possible they could - // change before the flush finishes. - const replayId = this.session.id; - const eventContext = this._popEventContext(); - // Always increment segmentId regardless of outcome of sending replay - const segmentId = this.session.segmentId++; - this._maybeSaveSession(); + // This empties the event buffer regardless of outcome of sending replay + const recordingData = await eventBuffer.finish(); await sendReplay({ replayId, @@ -834,22 +835,112 @@ export class ReplayContainer implements ReplayContainerInterface { segmentId, includeReplayStartTimestamp: segmentId === 0, eventContext, - session: this.session, + session, options: this.getOptions(), timestamp: new Date().getTime(), }); } catch (err) { - this._handleException(err); + this._handleSendError(err); + } + } - if (err instanceof RateLimitError) { - this._handleRateLimit(err.rateLimits); + /** + * Flush event buffer synchonously. + * This is necessary e.g. when running flush on page unload or similar. + */ + private _flushSync(): void { + try { + const flushData = this._prepareFlush(); + + if (!flushData) { return; } - // This means we retried 3 times, and all of them failed - // In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments - this.stop(); + const { replayId, segmentId, eventContext, eventBuffer, session } = flushData; + + const recordingData = eventBuffer.finishSync(); + + sendReplay({ + replayId, + recordingData, + segmentId, + includeReplayStartTimestamp: segmentId === 0, + eventContext, + session, + options: this.getOptions(), + timestamp: new Date().getTime(), + }).catch(err => { + this._handleSendError(err); + }); + } catch (err) { + this._handleSendError(err); + } + } + + /** Prepare flush data */ + private _prepareFlush(): + | { + replayId: string; + eventContext: PopEventContext; + segmentId: number; + promises: Promise[]; + eventBuffer: EventBuffer; + session: Session; + } + | undefined { + if (!this.session || !this.eventBuffer) { + __DEBUG_BUILD__ && logger.error('[Replay] No session or eventBuffer found to flush.'); + return; + } + + this._debouncedFlush.cancel(); + + const promises: Promise[] = []; + + promises.push(this._addPerformanceEntries()); + + // Do not continue if there are no pending events in buffer + if (!this.eventBuffer || !this.eventBuffer.pendingLength) { + return; } + + // Only attach memory entry if eventBuffer is not empty + promises.push(addMemoryEntry(this)); + + // NOTE: Copy values from instance members, as it's possible they could + // change before the flush finishes. + const replayId = this.session.id; + const eventContext = this._popEventContext(); + // Always increment segmentId regardless of outcome of sending replay + const segmentId = this.session.segmentId++; + + // Save session (new segment id) after we save flush data assuming either + // 1) request succeeds or 2) it fails or never happens, in which case we + // need to retry this segment. + this._maybeSaveSession(); + + return { + replayId, + eventContext, + segmentId, + promises, + eventBuffer: this.eventBuffer, + session: this.session, + }; + } + + /** Handle an error when sending a replay. */ + private _handleSendError(error: unknown): void { + this._handleException(error); + + if (error instanceof RateLimitError) { + this._handleRateLimit(error.rateLimits); + return; + } + + // This means we retried 3 times, and all of them failed + // In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments + this.stop(); } /** diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 356a3c1179fc..dd23c4e91cd0 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -7,6 +7,15 @@ export type RecordingOptions = recordOptions; export type AllPerformanceEntry = PerformancePaintTiming | PerformanceResourceTiming | PerformanceNavigationTiming; +export interface FlushOptions { + /** + * Attempt to finish the flush immediately without any asynchronous operations + * (e.g. worker calls). This is not directly related to `flushImmediate` which + * skips the debounced flush. + */ + sync?: boolean; +} + export interface SendReplayData { recordingData: ReplayRecordingData; replayId: string; @@ -18,6 +27,10 @@ export interface SendReplayData { options: ReplayPluginOptions; } +export type PendingReplayData = Omit & { + recordingData: RecordingEvent[]; +}; + export type InstrumentationTypeBreadcrumb = 'dom' | 'scope'; /** @@ -237,6 +250,11 @@ export interface EventBuffer { * Clears and returns the contents of the buffer. */ finish(): Promise; + + /** + * Clears and synchronously returns the pending contents of the buffer. This means no compression. + */ + finishSync(): string; } export type AddUpdateCallback = () => boolean | void; diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index c0c143b255e4..7ba49eccb19c 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -107,30 +107,29 @@ describe('Integration | flush', () => { replay && replay.stop(); }); - it('flushes twice after multiple flush() calls)', async () => { - // blur events cause an immediate flush (as well as a flush due to adding a - // breadcrumb) -- this means that the first blur event will be flushed and - // the following blur events will all call a debounced flush function, which - // should end up queueing a second flush + it('flushes after each blur event', async () => { + // @ts-ignore privaye API + const mockFlushSync = jest.spyOn(replay, '_flushSync'); + // blur events cause an immediate flush that bypass the debounced flush + // function and skip any async workers + expect(mockFlushSync).toHaveBeenCalledTimes(0); WINDOW.dispatchEvent(new Event('blur')); + expect(mockFlushSync).toHaveBeenCalledTimes(1); WINDOW.dispatchEvent(new Event('blur')); + expect(mockFlushSync).toHaveBeenCalledTimes(2); WINDOW.dispatchEvent(new Event('blur')); + expect(mockFlushSync).toHaveBeenCalledTimes(3); WINDOW.dispatchEvent(new Event('blur')); + expect(mockFlushSync).toHaveBeenCalledTimes(4); - expect(mockFlush).toHaveBeenCalledTimes(4); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(mockRunFlush).toHaveBeenCalledTimes(1); + expect(mockRunFlush).toHaveBeenCalledTimes(0); + expect(mockFlush).toHaveBeenCalledTimes(0); jest.runAllTimers(); await new Promise(process.nextTick); - expect(mockRunFlush).toHaveBeenCalledTimes(2); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(mockRunFlush).toHaveBeenCalledTimes(2); + expect(mockFlushSync).toHaveBeenCalledTimes(4); + expect(mockRunFlush).toHaveBeenCalledTimes(0); }); it('long first flush enqueues following events', async () => { @@ -141,8 +140,11 @@ describe('Integration | flush', () => { expect(mockAddPerformanceEntries).not.toHaveBeenCalled(); - // flush #1 @ t=0s - due to blur - WINDOW.dispatchEvent(new Event('blur')); + // flush #1 @ t=0s - (blur bypasses debounce, so manually call `flushImmedate`) + domHandler({ + name: 'click', + }); + replay.flushImmediate(); expect(mockFlush).toHaveBeenCalledTimes(1); expect(mockRunFlush).toHaveBeenCalledTimes(1); @@ -155,8 +157,11 @@ describe('Integration | flush', () => { expect(mockFlush).toHaveBeenCalledTimes(2); await advanceTimers(1000); - // flush #3 @ t=6s - due to blur - WINDOW.dispatchEvent(new Event('blur')); + domHandler({ + name: 'click', + }); + // flush #3 @ t=6s + replay.flushImmediate(); expect(mockFlush).toHaveBeenCalledTimes(3); // NOTE: Blur also adds a breadcrumb which calls `addUpdate`, meaning it will @@ -164,8 +169,11 @@ describe('Integration | flush', () => { await advanceTimers(8000); expect(mockFlush).toHaveBeenCalledTimes(3); - // flush #4 @ t=14s - due to blur - WINDOW.dispatchEvent(new Event('blur')); + // flush #4 @ t=14s + domHandler({ + name: 'click', + }); + replay.flushImmediate(); expect(mockFlush).toHaveBeenCalledTimes(4); expect(mockRunFlush).toHaveBeenCalledTimes(1); diff --git a/packages/replay/test/integration/stop.test.ts b/packages/replay/test/integration/stop.test.ts index 55f8dafd9289..0752089db984 100644 --- a/packages/replay/test/integration/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -106,6 +106,9 @@ describe('Integration | stop', () => { }; addEvent(replay, TEST_EVENT); + // This is an interesting test case because `start()` causes a checkout and a `flushImmediate`, and + // blur causes a direct `runFlush` call to ensure if window unloads, it is able to send out an uncompressed segment. + // This means that the non-async blur call can empty out the buffer before `flushImmediate` finishes. WINDOW.dispatchEvent(new Event('blur')); jest.runAllTimers(); await new Promise(process.nextTick); @@ -127,7 +130,7 @@ describe('Integration | stop', () => { }); it('does not buffer events when stopped', async function () { - WINDOW.dispatchEvent(new Event('blur')); + WINDOW.dispatchEvent(new Event('focus')); expect(replay.eventBuffer?.pendingLength).toBe(1); // stop replays @@ -135,7 +138,7 @@ describe('Integration | stop', () => { expect(replay.eventBuffer?.pendingLength).toBe(undefined); - WINDOW.dispatchEvent(new Event('blur')); + WINDOW.dispatchEvent(new Event('focus')); await new Promise(process.nextTick); expect(replay.eventBuffer?.pendingLength).toBe(undefined);