diff --git a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts index f3a531f3ffdc..dc94022e5b4a 100644 --- a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts @@ -53,19 +53,9 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal event.exception && event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing ) { - setTimeout(async () => { - // Allow flush to complete before resuming as a session recording, otherwise - // the checkout from `startRecording` may be included in the payload. - // Prefer to keep the error replay as a separate (and smaller) segment - // than the session replay. - await replay.flushImmediate(); - - if (replay.stopRecording()) { - // Reset all "capture on error" configuration before - // starting a new recording - replay.recordingMode = 'session'; - replay.startRecording(); - } + setTimeout(() => { + // Capture current event buffer as new replay + void replay.sendBufferedReplayOrFlush(); }); } }; diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index c08eb0431cac..bb948ce3e217 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -4,7 +4,7 @@ import { dropUndefinedKeys } from '@sentry/utils'; import { DEFAULT_FLUSH_MAX_DELAY, DEFAULT_FLUSH_MIN_DELAY } from './constants'; import { ReplayContainer } from './replay'; -import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions } from './types'; +import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions, SendBufferedReplayOptions } from './types'; import { getPrivacyOptions } from './util/getPrivacyOptions'; import { isBrowser } from './util/isBrowser'; @@ -216,14 +216,18 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, } /** - * Immediately send all pending events. + * Immediately send all pending events. In buffer-mode, this should be used + * to capture the initial replay. + * + * Unless `continueRecording` is false, the replay will continue to record and + * behave as a "session"-based replay. */ - public flush(): Promise | void { + public flush(options?: SendBufferedReplayOptions): Promise | void { if (!this._replay || !this._replay.isEnabled()) { return; } - return this._replay.flushImmediate(); + return this._replay.sendBufferedReplayOrFlush(options); } /** Setup the integration. */ diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 8bb426874442..0fc93e4078ee 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -19,6 +19,7 @@ import type { RecordingOptions, ReplayContainer as ReplayContainerInterface, ReplayPluginOptions, + SendBufferedReplayOptions, Session, Timeouts, } from './types'; @@ -216,17 +217,18 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Stops the recording, if it was running. - * Returns true if it was stopped, else false. + * + * Returns true if it was previously stopped, or is now stopped, + * otherwise false. */ public stopRecording(): boolean { try { if (this._stopRecording) { this._stopRecording(); this._stopRecording = undefined; - return true; } - return false; + return true; } catch (err) { this._handleException(err); return false; @@ -289,6 +291,38 @@ export class ReplayContainer implements ReplayContainerInterface { this.startRecording(); } + /** + * If not in "session" recording mode, flush event buffer which will create a new replay. + * Unless `continueRecording` is false, the replay will continue to record and + * behave as a "session"-based replay. + * + * Otherwise, queue up a flush. + */ + public async sendBufferedReplayOrFlush({ continueRecording = true }: SendBufferedReplayOptions = {}): Promise { + if (this.recordingMode === 'session') { + return this.flushImmediate(); + } + + // Allow flush to complete before resuming as a session recording, otherwise + // the checkout from `startRecording` may be included in the payload. + // Prefer to keep the error replay as a separate (and smaller) segment + // than the session replay. + await this.flushImmediate(); + + const hasStoppedRecording = this.stopRecording(); + + if (!continueRecording || !hasStoppedRecording) { + return; + } + + // Re-start recording, but in "session" recording mode + + // Reset all "capture on error" configuration before + // starting a new recording + this.recordingMode = 'session'; + this.startRecording(); + } + /** * We want to batch uploads of replay events. Save events only if * `` milliseconds have elapsed since the last event diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index f6d4566d2d7c..f1d728b7c8c9 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -424,6 +424,10 @@ export interface EventBuffer { export type AddUpdateCallback = () => boolean | void; +export interface SendBufferedReplayOptions { + continueRecording?: boolean; +} + export interface ReplayContainer { eventBuffer: EventBuffer | null; performanceEvents: AllPerformanceEntry[]; @@ -442,7 +446,8 @@ export interface ReplayContainer { resume(): void; startRecording(): void; stopRecording(): boolean; - flushImmediate(): void; + sendBufferedReplayOrFlush(options?: SendBufferedReplayOptions): Promise; + flushImmediate(): Promise; triggerUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 85f154e523f4..0b5baa8e2512 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -158,6 +158,100 @@ describe('Integration | errorSampleRate', () => { }); }); + it('manually flushes replay and does not continue to record', async () => { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + mockRecord._emitter(TEST_EVENT); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).not.toHaveLastSentReplay(); + + // Does not capture on mouse click + domHandler({ + name: 'click', + }); + jest.runAllTimers(); + await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + + replay.sendBufferedReplayOrFlush({ continueRecording: false }); + + await new Promise(process.nextTick); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + expect(replay).toHaveSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'error', + contexts: { + replay: { + error_sample_rate: 1, + session_sample_rate: 0, + }, + }, + }), + recordingData: JSON.stringify([ + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + TEST_EVENT, + { + type: 5, + timestamp: BASE_TIMESTAMP, + data: { + tag: 'breadcrumb', + payload: { + timestamp: BASE_TIMESTAMP / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + ]), + }); + + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + // Check that click will not get captured + domHandler({ + name: 'click', + }); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + // This is still the last replay sent since we passed `continueRecording: + // false`. + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'error', + contexts: { + replay: { + error_sample_rate: 1, + session_sample_rate: 0, + }, + }, + }), + recordingData: JSON.stringify([ + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + TEST_EVENT, + { + type: 5, + timestamp: BASE_TIMESTAMP, + data: { + tag: 'breadcrumb', + payload: { + timestamp: BASE_TIMESTAMP / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + ]), + }); + }); + it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_DURATION]ms', async () => { Object.defineProperty(document, 'visibilityState', { configurable: true,