Skip to content

Commit b6d9258

Browse files
committed
fix(replay): Fix re-sampled sessions after a click
This fixes a bug where an expired session-based replay will always force a new session-based replay after a click, regardless of the actual recording mode. What is happening is: - a session-based replay is left idle - user comes back and clicks on the page and triggers the click listener - `addBreadcrumbEvent()` is called which then calls `triggerUserActivity()` because it is a click - next, `_checkSession()` and `_refreshSession()` are called and this is where the problem starts Inside of `_refreshSession` we stop the current replay (because the session is expired), however `stop()` is async and is `await`-ed before we re-sample. So the current replay state while `stop()` is finishing has: - `recordingMode` = `session` (initial value) - `isEnabled` = false Another however, `addBreadcrumbEvent` (and everything called until `_refreshSession`) are not async and does wait for resampling (`initializeSampling()`) to occur. This means that the click breadcrumb ends up causing a flush and always starting a new replay recording.
1 parent 2426c9a commit b6d9258

File tree

2 files changed

+67
-2
lines changed
  • dev-packages/browser-integration-tests/suites/replay/sessionExpiry
  • packages/replay-internal/src

2 files changed

+67
-2
lines changed

dev-packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
// Session should expire after 2s - keep in sync with init.js
1414
const SESSION_TIMEOUT = 2000;
1515

16-
sentryTest('handles an expired session', async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => {
16+
sentryTest('handles an expired session that re-samples to session', async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => {
1717
if (shouldSkipReplayTest() || browserName !== 'chromium') {
1818
sentryTest.skip();
1919
}
@@ -65,3 +65,64 @@ sentryTest('handles an expired session', async ({ browserName, forceFlushReplay,
6565
const stringifiedSnapshot2 = normalize(fullSnapshots2[0]);
6666
expect(stringifiedSnapshot2).toMatchSnapshot('snapshot-2.json');
6767
});
68+
69+
sentryTest('handles an expired session that re-samples to buffer', async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => {
70+
if (shouldSkipReplayTest() || browserName !== 'chromium') {
71+
sentryTest.skip();
72+
}
73+
74+
const reqPromise0 = waitForReplayRequest(page, 0);
75+
const reqPromise1 = waitForReplayRequest(page, 1);
76+
77+
const url = await getLocalTestUrl({ testDir: __dirname });
78+
79+
await page.goto(url);
80+
const req0 = await reqPromise0;
81+
82+
const replayEvent0 = getReplayEvent(req0);
83+
expect(replayEvent0).toEqual(getExpectedReplayEvent({}));
84+
85+
const fullSnapshots0 = getFullRecordingSnapshots(req0);
86+
expect(fullSnapshots0.length).toEqual(1);
87+
const stringifiedSnapshot = normalize(fullSnapshots0[0]);
88+
expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json');
89+
90+
await page.locator('#button1').click();
91+
await forceFlushReplay();
92+
const req1 = await reqPromise1;
93+
94+
const replayEvent1 = getReplayEvent(req1);
95+
expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] }));
96+
97+
const replay = await getReplaySnapshot(page);
98+
const oldSessionId = replay.session?.id;
99+
100+
await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT));
101+
await page.evaluate(() => {
102+
// @ts-expect-error - Replay is not typed
103+
window.Replay._replay._options.errorSampleRate = 1;
104+
// @ts-expect-error - Replay is not typed
105+
window.Replay._replay._options.sessionSampleRate = 0;
106+
});
107+
108+
let wasReplayFlushed = false;
109+
page.on('request', request => {
110+
if (request.url().includes("/api/1337/envelope/")) {
111+
wasReplayFlushed = true
112+
}
113+
})
114+
await page.locator('#button2').click();
115+
await forceFlushReplay();
116+
117+
// This timeout is not ideal, but not sure of a better way to ensure replay is not flushed
118+
await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT));
119+
120+
expect(wasReplayFlushed).toBe(false);
121+
122+
const currentSessionId = await page.evaluate(() => {
123+
// @ts-expect-error - Replay is not typed
124+
return window.Replay._replay.session?.id;
125+
});
126+
127+
expect(currentSessionId).not.toEqual(oldSessionId);
128+
});

packages/replay-internal/src/replay.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,10 @@ export class ReplayContainer implements ReplayContainerInterface {
503503
// enter into an infinite loop when `stop()` is called while flushing.
504504
this._isEnabled = false;
505505

506+
// Make sure to reset `recordingMode` to `buffer` to avoid any additional
507+
// breadcrumbs to trigger a flush (e.g. in `addUpdate()`)
508+
this.recordingMode = 'buffer';
509+
506510
try {
507511
DEBUG_BUILD && debug.log(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`);
508512

@@ -623,7 +627,7 @@ export class ReplayContainer implements ReplayContainerInterface {
623627

624628
// If this option is turned on then we will only want to call `flush`
625629
// explicitly
626-
if (this.recordingMode === 'buffer') {
630+
if (this.recordingMode === 'buffer' || !this._isEnabled) {
627631
return;
628632
}
629633

0 commit comments

Comments
 (0)