Skip to content

Commit 0161cdd

Browse files
authored
feat(replay): Change flush() API to record current event buffer (#7743)
This adds a public API: `capture` that will record the current event buffer and by default, convert the replay type to "session" and continue recording. We have extracted the logic that was used for "onError" capturing and made it a public API.
1 parent bbbea86 commit 0161cdd

File tree

5 files changed

+148
-21
lines changed

5 files changed

+148
-21
lines changed

packages/replay/src/coreHandlers/handleAfterSendEvent.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,9 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal
5353
event.exception &&
5454
event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing
5555
) {
56-
setTimeout(async () => {
57-
// Allow flush to complete before resuming as a session recording, otherwise
58-
// the checkout from `startRecording` may be included in the payload.
59-
// Prefer to keep the error replay as a separate (and smaller) segment
60-
// than the session replay.
61-
await replay.flushImmediate();
62-
63-
if (replay.stopRecording()) {
64-
// Reset all "capture on error" configuration before
65-
// starting a new recording
66-
replay.recordingMode = 'session';
67-
replay.startRecording();
68-
}
56+
setTimeout(() => {
57+
// Capture current event buffer as new replay
58+
void replay.sendBufferedReplayOrFlush();
6959
});
7060
}
7161
};

packages/replay/src/integration.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { dropUndefinedKeys } from '@sentry/utils';
44

55
import { DEFAULT_FLUSH_MAX_DELAY, DEFAULT_FLUSH_MIN_DELAY } from './constants';
66
import { ReplayContainer } from './replay';
7-
import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions } from './types';
7+
import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions, SendBufferedReplayOptions } from './types';
88
import { getPrivacyOptions } from './util/getPrivacyOptions';
99
import { isBrowser } from './util/isBrowser';
1010

@@ -216,14 +216,18 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
216216
}
217217

218218
/**
219-
* Immediately send all pending events.
219+
* Immediately send all pending events. In buffer-mode, this should be used
220+
* to capture the initial replay.
221+
*
222+
* Unless `continueRecording` is false, the replay will continue to record and
223+
* behave as a "session"-based replay.
220224
*/
221-
public flush(): Promise<void> | void {
225+
public flush(options?: SendBufferedReplayOptions): Promise<void> | void {
222226
if (!this._replay || !this._replay.isEnabled()) {
223227
return;
224228
}
225229

226-
return this._replay.flushImmediate();
230+
return this._replay.sendBufferedReplayOrFlush(options);
227231
}
228232

229233
/**

packages/replay/src/replay.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
ReplayContainer as ReplayContainerInterface,
2121
ReplayExperimentalPluginOptions,
2222
ReplayPluginOptions,
23+
SendBufferedReplayOptions,
2324
Session,
2425
Timeouts,
2526
} from './types';
@@ -230,17 +231,18 @@ export class ReplayContainer implements ReplayContainerInterface {
230231

231232
/**
232233
* Stops the recording, if it was running.
233-
* Returns true if it was stopped, else false.
234+
*
235+
* Returns true if it was previously stopped, or is now stopped,
236+
* otherwise false.
234237
*/
235238
public stopRecording(): boolean {
236239
try {
237240
if (this._stopRecording) {
238241
this._stopRecording();
239242
this._stopRecording = undefined;
240-
return true;
241243
}
242244

243-
return false;
245+
return true;
244246
} catch (err) {
245247
this._handleException(err);
246248
return false;
@@ -303,6 +305,38 @@ export class ReplayContainer implements ReplayContainerInterface {
303305
this.startRecording();
304306
}
305307

308+
/**
309+
* If not in "session" recording mode, flush event buffer which will create a new replay.
310+
* Unless `continueRecording` is false, the replay will continue to record and
311+
* behave as a "session"-based replay.
312+
*
313+
* Otherwise, queue up a flush.
314+
*/
315+
public async sendBufferedReplayOrFlush({ continueRecording = true }: SendBufferedReplayOptions = {}): Promise<void> {
316+
if (this.recordingMode === 'session') {
317+
return this.flushImmediate();
318+
}
319+
320+
// Allow flush to complete before resuming as a session recording, otherwise
321+
// the checkout from `startRecording` may be included in the payload.
322+
// Prefer to keep the error replay as a separate (and smaller) segment
323+
// than the session replay.
324+
await this.flushImmediate();
325+
326+
const hasStoppedRecording = this.stopRecording();
327+
328+
if (!continueRecording || !hasStoppedRecording) {
329+
return;
330+
}
331+
332+
// Re-start recording, but in "session" recording mode
333+
334+
// Reset all "capture on error" configuration before
335+
// starting a new recording
336+
this.recordingMode = 'session';
337+
this.startRecording();
338+
}
339+
306340
/**
307341
* We want to batch uploads of replay events. Save events only if
308342
* `<flushMinDelay>` milliseconds have elapsed since the last event

packages/replay/src/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,10 @@ export interface EventBuffer {
446446

447447
export type AddUpdateCallback = () => boolean | void;
448448

449+
export interface SendBufferedReplayOptions {
450+
continueRecording?: boolean;
451+
}
452+
449453
export interface ReplayContainer {
450454
eventBuffer: EventBuffer | null;
451455
performanceEvents: AllPerformanceEntry[];
@@ -464,7 +468,8 @@ export interface ReplayContainer {
464468
resume(): void;
465469
startRecording(): void;
466470
stopRecording(): boolean;
467-
flushImmediate(): void;
471+
sendBufferedReplayOrFlush(options?: SendBufferedReplayOptions): Promise<void>;
472+
flushImmediate(): Promise<void>;
468473
triggerUserActivity(): void;
469474
addUpdate(cb: AddUpdateCallback): void;
470475
getOptions(): ReplayPluginOptions;

packages/replay/test/integration/errorSampleRate.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,100 @@ describe('Integration | errorSampleRate', () => {
158158
});
159159
});
160160

161+
it('manually flushes replay and does not continue to record', async () => {
162+
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
163+
mockRecord._emitter(TEST_EVENT);
164+
165+
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
166+
expect(replay).not.toHaveLastSentReplay();
167+
168+
// Does not capture on mouse click
169+
domHandler({
170+
name: 'click',
171+
});
172+
jest.runAllTimers();
173+
await new Promise(process.nextTick);
174+
expect(replay).not.toHaveLastSentReplay();
175+
176+
replay.sendBufferedReplayOrFlush({ continueRecording: false });
177+
178+
await new Promise(process.nextTick);
179+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
180+
await new Promise(process.nextTick);
181+
182+
expect(replay).toHaveSentReplay({
183+
recordingPayloadHeader: { segment_id: 0 },
184+
replayEventPayload: expect.objectContaining({
185+
replay_type: 'error',
186+
contexts: {
187+
replay: {
188+
error_sample_rate: 1,
189+
session_sample_rate: 0,
190+
},
191+
},
192+
}),
193+
recordingData: JSON.stringify([
194+
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 },
195+
TEST_EVENT,
196+
{
197+
type: 5,
198+
timestamp: BASE_TIMESTAMP,
199+
data: {
200+
tag: 'breadcrumb',
201+
payload: {
202+
timestamp: BASE_TIMESTAMP / 1000,
203+
type: 'default',
204+
category: 'ui.click',
205+
message: '<unknown>',
206+
data: {},
207+
},
208+
},
209+
},
210+
]),
211+
});
212+
213+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
214+
// Check that click will not get captured
215+
domHandler({
216+
name: 'click',
217+
});
218+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
219+
await new Promise(process.nextTick);
220+
221+
// This is still the last replay sent since we passed `continueRecording:
222+
// false`.
223+
expect(replay).toHaveLastSentReplay({
224+
recordingPayloadHeader: { segment_id: 0 },
225+
replayEventPayload: expect.objectContaining({
226+
replay_type: 'error',
227+
contexts: {
228+
replay: {
229+
error_sample_rate: 1,
230+
session_sample_rate: 0,
231+
},
232+
},
233+
}),
234+
recordingData: JSON.stringify([
235+
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 },
236+
TEST_EVENT,
237+
{
238+
type: 5,
239+
timestamp: BASE_TIMESTAMP,
240+
data: {
241+
tag: 'breadcrumb',
242+
payload: {
243+
timestamp: BASE_TIMESTAMP / 1000,
244+
type: 'default',
245+
category: 'ui.click',
246+
message: '<unknown>',
247+
data: {},
248+
},
249+
},
250+
},
251+
]),
252+
});
253+
});
254+
161255
it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_DURATION]ms', async () => {
162256
Object.defineProperty(document, 'visibilityState', {
163257
configurable: true,

0 commit comments

Comments
 (0)