Skip to content

Commit 14b6fca

Browse files
authored
ref(replay): Handle checkouts more explicitly (#7321)
1 parent 148dd43 commit 14b6fca

File tree

8 files changed

+239
-125
lines changed

8 files changed

+239
-125
lines changed

packages/integration-tests/suites/replay/multiple-pages/test.ts

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,31 @@ sentryTest(
4545

4646
const reqPromise0 = waitForReplayRequest(page, 0);
4747
const reqPromise1 = waitForReplayRequest(page, 1);
48+
const reqPromise2 = waitForReplayRequest(page, 2);
49+
const reqPromise3 = waitForReplayRequest(page, 3);
50+
const reqPromise4 = waitForReplayRequest(page, 4);
51+
const reqPromise5 = waitForReplayRequest(page, 5);
52+
const reqPromise6 = waitForReplayRequest(page, 6);
53+
const reqPromise7 = waitForReplayRequest(page, 7);
54+
const reqPromise8 = waitForReplayRequest(page, 8);
55+
const reqPromise9 = waitForReplayRequest(page, 9);
4856

4957
const url = await getLocalTestPath({ testDir: __dirname });
5058

5159
await page.goto(url);
52-
const replayEvent0 = getReplayEvent(await reqPromise0);
53-
const recording0 = getReplayRecordingContent(await reqPromise0);
60+
const req0 = await reqPromise0;
61+
const replayEvent0 = getReplayEvent(req0);
62+
const recording0 = getReplayRecordingContent(req0);
5463

5564
expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0 }));
5665
expect(normalize(recording0.fullSnapshots)).toMatchSnapshot('seg-0-snap-full');
5766
expect(recording0.incrementalSnapshots.length).toEqual(0);
5867

5968
await page.click('#go-background');
6069

61-
const replayEvent1 = getReplayEvent(await reqPromise1);
62-
const recording1 = getReplayRecordingContent(await reqPromise1);
70+
const req1 = await reqPromise1;
71+
const replayEvent1 = getReplayEvent(req1);
72+
const recording1 = getReplayRecordingContent(req1);
6373

6474
expect(replayEvent1).toEqual(
6575
getExpectedReplayEvent({ segment_id: 1, urls: [], replay_start_timestamp: undefined }),
@@ -91,20 +101,19 @@ sentryTest(
91101

92102
await page.reload();
93103

94-
const reqPromise2 = waitForReplayRequest(page, 2);
95-
const reqPromise3 = waitForReplayRequest(page, 3);
96-
97-
const replayEvent2 = getReplayEvent(await reqPromise2);
98-
const recording2 = getReplayRecordingContent(await reqPromise2);
104+
const req2 = await reqPromise2;
105+
const replayEvent2 = getReplayEvent(req2);
106+
const recording2 = getReplayRecordingContent(req2);
99107

100108
expect(replayEvent2).toEqual(getExpectedReplayEvent({ segment_id: 2, replay_start_timestamp: undefined }));
101109
expect(normalize(recording2.fullSnapshots)).toMatchSnapshot('seg-2-snap-full');
102110
expect(recording2.incrementalSnapshots.length).toEqual(0);
103111

104112
await page.click('#go-background');
105113

106-
const replayEvent3 = getReplayEvent(await reqPromise3);
107-
const recording3 = getReplayRecordingContent(await reqPromise3);
114+
const req3 = await reqPromise3;
115+
const replayEvent3 = getReplayEvent(req3);
116+
const recording3 = getReplayRecordingContent(req3);
108117

109118
expect(replayEvent3).toEqual(
110119
getExpectedReplayEvent({ segment_id: 3, urls: [], replay_start_timestamp: undefined }),
@@ -134,11 +143,9 @@ sentryTest(
134143

135144
await page.click('a');
136145

137-
const reqPromise4 = waitForReplayRequest(page, 4);
138-
const reqPromise5 = waitForReplayRequest(page, 5);
139-
140-
const replayEvent4 = getReplayEvent(await reqPromise4);
141-
const recording4 = getReplayRecordingContent(await reqPromise4);
146+
const req4 = await reqPromise4;
147+
const replayEvent4 = getReplayEvent(req4);
148+
const recording4 = getReplayRecordingContent(req4);
142149

143150
expect(replayEvent4).toEqual(
144151
getExpectedReplayEvent({
@@ -161,8 +168,9 @@ sentryTest(
161168

162169
await page.click('#go-background');
163170

164-
const replayEvent5 = getReplayEvent(await reqPromise5);
165-
const recording5 = getReplayRecordingContent(await reqPromise5);
171+
const req5 = await reqPromise5;
172+
const replayEvent5 = getReplayEvent(req5);
173+
const recording5 = getReplayRecordingContent(req5);
166174

167175
expect(replayEvent5).toEqual(
168176
getExpectedReplayEvent({
@@ -207,9 +215,9 @@ sentryTest(
207215

208216
await page.click('#spa-navigation');
209217

210-
const reqPromise6 = waitForReplayRequest(page, 6);
211-
const replayEvent6 = getReplayEvent(await reqPromise6);
212-
const recording6 = getReplayRecordingContent(await reqPromise6);
218+
const req6 = await reqPromise6;
219+
const replayEvent6 = getReplayEvent(req6);
220+
const recording6 = getReplayRecordingContent(req6);
213221

214222
expect(replayEvent6).toEqual(
215223
getExpectedReplayEvent({
@@ -231,9 +239,9 @@ sentryTest(
231239

232240
await page.click('#go-background');
233241

234-
const reqPromise7 = waitForReplayRequest(page, 7);
235-
const replayEvent7 = getReplayEvent(await reqPromise7);
236-
const recording7 = getReplayRecordingContent(await reqPromise7);
242+
const req7 = await reqPromise7;
243+
const replayEvent7 = getReplayEvent(req7);
244+
const recording7 = getReplayRecordingContent(req7);
237245

238246
expect(replayEvent7).toEqual(
239247
getExpectedReplayEvent({
@@ -279,11 +287,9 @@ sentryTest(
279287

280288
await page.click('a');
281289

282-
const reqPromise8 = waitForReplayRequest(page, 8);
283-
const reqPromise9 = waitForReplayRequest(page, 9);
284-
285-
const replayEvent8 = getReplayEvent(await reqPromise8);
286-
const recording8 = getReplayRecordingContent(await reqPromise8);
290+
const req8 = await reqPromise8;
291+
const replayEvent8 = getReplayEvent(req8);
292+
const recording8 = getReplayRecordingContent(req8);
287293

288294
expect(replayEvent8).toEqual(
289295
getExpectedReplayEvent({
@@ -296,8 +302,9 @@ sentryTest(
296302

297303
await page.click('#go-background');
298304

299-
const replayEvent9 = getReplayEvent(await reqPromise9);
300-
const recording9 = getReplayRecordingContent(await reqPromise9);
305+
const req9 = await reqPromise9;
306+
const replayEvent9 = getReplayEvent(req9);
307+
const recording9 = getReplayRecordingContent(req9);
301308

302309
expect(replayEvent9).toEqual(
303310
getExpectedReplayEvent({

packages/replay/src/replay.ts

Lines changed: 23 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import type {
1616
EventBuffer,
1717
InternalEventContext,
1818
PopEventContext,
19-
RecordingEvent,
2019
RecordingOptions,
2120
ReplayContainer as ReplayContainerInterface,
2221
ReplayPluginOptions,
@@ -30,6 +29,7 @@ import { createBreadcrumb } from './util/createBreadcrumb';
3029
import { createPerformanceEntries } from './util/createPerformanceEntries';
3130
import { createPerformanceSpans } from './util/createPerformanceSpans';
3231
import { debounce } from './util/debounce';
32+
import { getHandleRecordingEmit } from './util/handleRecordingEmit';
3333
import { isExpired } from './util/isExpired';
3434
import { isSessionExpired } from './util/isSessionExpired';
3535
import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent';
@@ -155,7 +155,7 @@ export class ReplayContainer implements ReplayContainerInterface {
155155
* _performanceObserver, Recording, Sentry SDK, etc)
156156
*/
157157
public start(): void {
158-
this._setInitialState();
158+
this.setInitialState();
159159

160160
if (!this._loadAndCheckSession()) {
161161
return;
@@ -207,7 +207,7 @@ export class ReplayContainer implements ReplayContainerInterface {
207207
// Without this, it would record forever, until an error happens, which we don't want
208208
// instead, we'll always keep the last 60 seconds of replay before an error happened
209209
...(this.recordingMode === 'error' && { checkoutEveryNms: ERROR_CHECKOUT_TIME }),
210-
emit: this._handleRecordingEmit,
210+
emit: getHandleRecordingEmit(this),
211211
onMutation: (mutations: unknown[]) => {
212212
if (this._options._experiments.captureMutationSize) {
213213
const count = mutations.length;
@@ -420,6 +420,25 @@ export class ReplayContainer implements ReplayContainerInterface {
420420
return false;
421421
}
422422

423+
/**
424+
* Capture some initial state that can change throughout the lifespan of the
425+
* replay. This is required because otherwise they would be captured at the
426+
* first flush.
427+
*/
428+
public setInitialState(): void {
429+
const urlPath = `${WINDOW.location.pathname}${WINDOW.location.hash}${WINDOW.location.search}`;
430+
const url = `${WINDOW.location.origin}${urlPath}`;
431+
432+
this.performanceEvents = [];
433+
434+
// Reset _context as well
435+
this._clearContext();
436+
437+
this._context.initialUrl = url;
438+
this._context.initialTimestamp = new Date().getTime();
439+
this._context.urls.push(url);
440+
}
441+
423442
/** A wrapper to conditionally capture exceptions. */
424443
private _handleException(error: unknown): void {
425444
__DEBUG_BUILD__ && logger.error('[Replay]', error);
@@ -445,7 +464,7 @@ export class ReplayContainer implements ReplayContainerInterface {
445464
// If session was newly created (i.e. was not loaded from storage), then
446465
// enable flag to create the root replay
447466
if (type === 'new') {
448-
this._setInitialState();
467+
this.setInitialState();
449468
}
450469

451470
const currentSessionId = this.getSessionId();
@@ -463,25 +482,6 @@ export class ReplayContainer implements ReplayContainerInterface {
463482
return true;
464483
}
465484

466-
/**
467-
* Capture some initial state that can change throughout the lifespan of the
468-
* replay. This is required because otherwise they would be captured at the
469-
* first flush.
470-
*/
471-
private _setInitialState(): void {
472-
const urlPath = `${WINDOW.location.pathname}${WINDOW.location.hash}${WINDOW.location.search}`;
473-
const url = `${WINDOW.location.origin}${urlPath}`;
474-
475-
this.performanceEvents = [];
476-
477-
// Reset _context as well
478-
this._clearContext();
479-
480-
this._context.initialUrl = url;
481-
this._context.initialTimestamp = new Date().getTime();
482-
this._context.urls.push(url);
483-
}
484-
485485
/**
486486
* Adds listeners to record events for the replay
487487
*/
@@ -533,72 +533,6 @@ export class ReplayContainer implements ReplayContainerInterface {
533533
}
534534
}
535535

536-
/**
537-
* Handler for recording events.
538-
*
539-
* Adds to event buffer, and has varying flushing behaviors if the event was a checkout.
540-
*/
541-
private _handleRecordingEmit: (event: RecordingEvent, isCheckout?: boolean) => void = (
542-
event: RecordingEvent,
543-
isCheckout?: boolean,
544-
) => {
545-
// If this is false, it means session is expired, create and a new session and wait for checkout
546-
if (!this.checkAndHandleExpiredSession()) {
547-
__DEBUG_BUILD__ && logger.warn('[Replay] Received replay event after session expired.');
548-
549-
return;
550-
}
551-
552-
this.addUpdate(() => {
553-
// The session is always started immediately on pageload/init, but for
554-
// error-only replays, it should reflect the most recent checkout
555-
// when an error occurs. Clear any state that happens before this current
556-
// checkout. This needs to happen before `addEvent()` which updates state
557-
// dependent on this reset.
558-
if (this.recordingMode === 'error' && event.type === 2) {
559-
this._setInitialState();
560-
}
561-
562-
// We need to clear existing events on a checkout, otherwise they are
563-
// incremental event updates and should be appended
564-
void addEvent(this, event, isCheckout);
565-
566-
// Different behavior for full snapshots (type=2), ignore other event types
567-
// See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16
568-
if (event.type !== 2) {
569-
return false;
570-
}
571-
572-
// If there is a previousSessionId after a full snapshot occurs, then
573-
// the replay session was started due to session expiration. The new session
574-
// is started before triggering a new checkout and contains the id
575-
// of the previous session. Do not immediately flush in this case
576-
// to avoid capturing only the checkout and instead the replay will
577-
// be captured if they perform any follow-up actions.
578-
if (this.session && this.session.previousSessionId) {
579-
return true;
580-
}
581-
582-
// See note above re: session start needs to reflect the most recent
583-
// checkout.
584-
if (this.recordingMode === 'error' && this.session && this._context.earliestEvent) {
585-
this.session.started = this._context.earliestEvent;
586-
this._maybeSaveSession();
587-
}
588-
589-
// Flush immediately so that we do not miss the first segment, otherwise
590-
// it can prevent loading on the UI. This will cause an increase in short
591-
// replays (e.g. opening and closing a tab quickly), but these can be
592-
// filtered on the UI.
593-
if (this.recordingMode === 'session') {
594-
// We want to ensure the worker is ready, as otherwise we'd always send the first event uncompressed
595-
void this.flushImmediate();
596-
}
597-
598-
return true;
599-
});
600-
};
601-
602536
/**
603537
* Handle when visibility of the page content changes. Opening a new tab will
604538
* cause the state to change to hidden because of content of current page will

packages/replay/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export interface EventBuffer {
277277

278278
/**
279279
* Add an event to the event buffer.
280+
* `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`.
280281
*
281282
* Returns a promise that resolves if the event was successfully added, else rejects.
282283
*/
@@ -314,6 +315,7 @@ export interface ReplayContainer {
314315
getOptions(): ReplayPluginOptions;
315316
getSessionId(): string | undefined;
316317
checkAndHandleExpiredSession(): boolean | void;
318+
setInitialState(): void;
317319
}
318320

319321
export interface ReplayPerformanceEntry {

packages/replay/src/util/addEvent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { logger } from '@sentry/utils';
44
import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types';
55

66
/**
7-
* Add an event to the event buffer
7+
* Add an event to the event buffer.
8+
* `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`.
89
*/
910
export async function addEvent(
1011
replay: ReplayContainer,

0 commit comments

Comments
 (0)