Skip to content

Commit 4a4df0d

Browse files
authored
fix(replay): Streamline session creation/refresh (#8813)
We've been using `_loadAndCheckSession` both in initial session setup as well as when checking for expiration of session. This leads to some not-so-optimized stuff, as we kind of have to do double duty in there (e.g. we constantly re-assign the session etc). This streamlines this by splitting this into: * `_initializeSessionForSampling()`: Only called in `initializeSampling()` * `_checkSession()`: Called everywhere else, assumes we have a session setup yet Only the former actually looks into sessionStorage, the latter can assume we always have a session already. This also extends the behavior so that if we fetch a `buffer` session from storage and segment_id > 0, we start the session in `session` mode. Without this, we could theoretically run into endless sessions if the user keeps refreshing and keeps having errors, leading to continuous switchovers from buffer>session mode.
1 parent a985738 commit 4a4df0d

21 files changed

+950
-435
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 1,
13+
replaysSessionSampleRate: 0.0,
14+
replaysOnErrorSampleRate: 1.0,
15+
16+
integrations: [window.Replay],
17+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button onclick="console.log('Test log 1')" id="button1">Click me</button>
8+
<button onclick="Sentry.captureException('test error')" id="buttonError">Click me</button>
9+
</body>
10+
</html>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import {
5+
getReplaySnapshot,
6+
shouldSkipReplayTest,
7+
waitForReplayRequest,
8+
waitForReplayRunning,
9+
} from '../../../utils/replayHelpers';
10+
11+
sentryTest('continues buffer session in session mode after error & reload', async ({ getLocalTestPath, page }) => {
12+
if (shouldSkipReplayTest()) {
13+
sentryTest.skip();
14+
}
15+
16+
const reqPromise1 = waitForReplayRequest(page, 0);
17+
18+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
19+
return route.fulfill({
20+
status: 200,
21+
contentType: 'application/json',
22+
body: JSON.stringify({ id: 'test-id' }),
23+
});
24+
});
25+
26+
const url = await getLocalTestPath({ testDir: __dirname });
27+
28+
await page.goto(url);
29+
30+
// buffer session captures an error & switches to session mode
31+
await page.click('#buttonError');
32+
await new Promise(resolve => setTimeout(resolve, 300));
33+
await reqPromise1;
34+
35+
await waitForReplayRunning(page);
36+
const replay1 = await getReplaySnapshot(page);
37+
38+
expect(replay1.recordingMode).toEqual('session');
39+
expect(replay1.session?.sampled).toEqual('buffer');
40+
expect(replay1.session?.segmentId).toBeGreaterThan(0);
41+
42+
// Reload to ensure the session is correctly recovered from sessionStorage
43+
await page.reload();
44+
45+
await waitForReplayRunning(page);
46+
const replay2 = await getReplaySnapshot(page);
47+
48+
expect(replay2.recordingMode).toEqual('session');
49+
expect(replay2.session?.sampled).toEqual('buffer');
50+
expect(replay2.session?.segmentId).toBeGreaterThan(0);
51+
});

packages/replay/src/replay.ts

Lines changed: 88 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent';
1818
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
1919
import { createEventBuffer } from './eventBuffer';
2020
import { clearSession } from './session/clearSession';
21-
import { getSession } from './session/getSession';
21+
import { loadOrCreateSession } from './session/loadOrCreateSession';
22+
import { maybeRefreshSession } from './session/maybeRefreshSession';
2223
import { saveSession } from './session/saveSession';
2324
import type {
2425
AddEventResult,
@@ -228,28 +229,24 @@ export class ReplayContainer implements ReplayContainerInterface {
228229

229230
// Otherwise if there is _any_ sample rate set, try to load an existing
230231
// session, or create a new one.
231-
const isSessionSampled = this._loadAndCheckSession();
232-
233-
if (!isSessionSampled) {
234-
// This should only occur if `errorSampleRate` is 0 and was unsampled for
235-
// session-based replay. In this case there is nothing to do.
236-
return;
237-
}
232+
this._initializeSessionForSampling();
238233

239234
if (!this.session) {
240235
// This should not happen, something wrong has occurred
241236
this._handleException(new Error('Unable to initialize and create session'));
242237
return;
243238
}
244239

245-
if (this.session.sampled && this.session.sampled !== 'session') {
246-
// If not sampled as session-based, then recording mode will be `buffer`
247-
// Note that we don't explicitly check if `sampled === 'buffer'` because we
248-
// could have sessions from Session storage that are still `error` from
249-
// prior SDK version.
250-
this.recordingMode = 'buffer';
240+
if (this.session.sampled === false) {
241+
// This should only occur if `errorSampleRate` is 0 and was unsampled for
242+
// session-based replay. In this case there is nothing to do.
243+
return;
251244
}
252245

246+
// If segmentId > 0, it means we've previously already captured this session
247+
// In this case, we still want to continue in `session` recording mode
248+
this.recordingMode = this.session.sampled === 'buffer' && this.session.segmentId === 0 ? 'buffer' : 'session';
249+
253250
logInfoNextTick(
254251
`[Replay] Starting replay in ${this.recordingMode} mode`,
255252
this._options._experiments.traceInternals,
@@ -276,19 +273,20 @@ export class ReplayContainer implements ReplayContainerInterface {
276273

277274
logInfoNextTick('[Replay] Starting replay in session mode', this._options._experiments.traceInternals);
278275

279-
const previousSessionId = this.session && this.session.id;
280-
281-
const { session } = getSession({
282-
timeouts: this.timeouts,
283-
stickySession: Boolean(this._options.stickySession),
284-
currentSession: this.session,
285-
// This is intentional: create a new session-based replay when calling `start()`
286-
sessionSampleRate: 1,
287-
allowBuffering: false,
288-
traceInternals: this._options._experiments.traceInternals,
289-
});
276+
const session = loadOrCreateSession(
277+
this.session,
278+
{
279+
timeouts: this.timeouts,
280+
traceInternals: this._options._experiments.traceInternals,
281+
},
282+
{
283+
stickySession: this._options.stickySession,
284+
// This is intentional: create a new session-based replay when calling `start()`
285+
sessionSampleRate: 1,
286+
allowBuffering: false,
287+
},
288+
);
290289

291-
session.previousSessionId = previousSessionId;
292290
this.session = session;
293291

294292
this._initializeRecording();
@@ -305,18 +303,19 @@ export class ReplayContainer implements ReplayContainerInterface {
305303

306304
logInfoNextTick('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals);
307305

308-
const previousSessionId = this.session && this.session.id;
309-
310-
const { session } = getSession({
311-
timeouts: this.timeouts,
312-
stickySession: Boolean(this._options.stickySession),
313-
currentSession: this.session,
314-
sessionSampleRate: 0,
315-
allowBuffering: true,
316-
traceInternals: this._options._experiments.traceInternals,
317-
});
306+
const session = loadOrCreateSession(
307+
this.session,
308+
{
309+
timeouts: this.timeouts,
310+
traceInternals: this._options._experiments.traceInternals,
311+
},
312+
{
313+
stickySession: this._options.stickySession,
314+
sessionSampleRate: 0,
315+
allowBuffering: true,
316+
},
317+
);
318318

319-
session.previousSessionId = previousSessionId;
320319
this.session = session;
321320

322321
this.recordingMode = 'buffer';
@@ -427,7 +426,7 @@ export class ReplayContainer implements ReplayContainerInterface {
427426
* new DOM checkout.`
428427
*/
429428
public resume(): void {
430-
if (!this._isPaused || !this._loadAndCheckSession()) {
429+
if (!this._isPaused || !this._checkSession()) {
431430
return;
432431
}
433432

@@ -535,7 +534,7 @@ export class ReplayContainer implements ReplayContainerInterface {
535534
if (!this._stopRecording) {
536535
// Create a new session, otherwise when the user action is flushed, it
537536
// will get rejected due to an expired session.
538-
if (!this._loadAndCheckSession()) {
537+
if (!this._checkSession()) {
539538
return;
540539
}
541540

@@ -634,7 +633,7 @@ export class ReplayContainer implements ReplayContainerInterface {
634633

635634
// --- There is recent user activity --- //
636635
// This will create a new session if expired, based on expiry length
637-
if (!this._loadAndCheckSession()) {
636+
if (!this._checkSession()) {
638637
return;
639638
}
640639

@@ -751,31 +750,63 @@ export class ReplayContainer implements ReplayContainerInterface {
751750

752751
/**
753752
* Loads (or refreshes) the current session.
753+
*/
754+
private _initializeSessionForSampling(): void {
755+
// Whenever there is _any_ error sample rate, we always allow buffering
756+
// Because we decide on sampling when an error occurs, we need to buffer at all times if sampling for errors
757+
const allowBuffering = this._options.errorSampleRate > 0;
758+
759+
const session = loadOrCreateSession(
760+
this.session,
761+
{
762+
timeouts: this.timeouts,
763+
traceInternals: this._options._experiments.traceInternals,
764+
},
765+
{
766+
stickySession: this._options.stickySession,
767+
sessionSampleRate: this._options.sessionSampleRate,
768+
allowBuffering,
769+
},
770+
);
771+
772+
this.session = session;
773+
}
774+
775+
/**
776+
* Checks and potentially refreshes the current session.
754777
* Returns false if session is not recorded.
755778
*/
756-
private _loadAndCheckSession(): boolean {
757-
const { type, session } = getSession({
758-
timeouts: this.timeouts,
759-
stickySession: Boolean(this._options.stickySession),
760-
currentSession: this.session,
761-
sessionSampleRate: this._options.sessionSampleRate,
762-
allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer',
763-
traceInternals: this._options._experiments.traceInternals,
764-
});
779+
private _checkSession(): boolean {
780+
// If there is no session yet, we do not want to refresh anything
781+
// This should generally not happen, but to be safe....
782+
if (!this.session) {
783+
return false;
784+
}
785+
786+
const currentSession = this.session;
787+
788+
const newSession = maybeRefreshSession(
789+
currentSession,
790+
{
791+
timeouts: this.timeouts,
792+
traceInternals: this._options._experiments.traceInternals,
793+
},
794+
{
795+
stickySession: Boolean(this._options.stickySession),
796+
sessionSampleRate: this._options.sessionSampleRate,
797+
allowBuffering: this._options.errorSampleRate > 0,
798+
},
799+
);
800+
801+
const isNew = newSession.id !== currentSession.id;
765802

766803
// If session was newly created (i.e. was not loaded from storage), then
767804
// enable flag to create the root replay
768-
if (type === 'new') {
805+
if (isNew) {
769806
this.setInitialState();
807+
this.session = newSession;
770808
}
771809

772-
const currentSessionId = this.getSessionId();
773-
if (session.id !== currentSessionId) {
774-
session.previousSessionId = currentSessionId;
775-
}
776-
777-
this.session = session;
778-
779810
if (!this.session.sampled) {
780811
void this.stop({ reason: 'session not refreshed' });
781812
return false;

packages/replay/src/session/Session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function makeSession(session: Partial<Session> & { sampled: Sampled }): S
1414
const segmentId = session.segmentId || 0;
1515
const sampled = session.sampled;
1616
const shouldRefresh = typeof session.shouldRefresh === 'boolean' ? session.shouldRefresh : true;
17+
const previousSessionId = session.previousSessionId;
1718

1819
return {
1920
id,
@@ -22,5 +23,6 @@ export function makeSession(session: Partial<Session> & { sampled: Sampled }): S
2223
segmentId,
2324
sampled,
2425
shouldRefresh,
26+
previousSessionId,
2527
};
2628
}

packages/replay/src/session/createSession.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ export function getSessionSampleType(sessionSampleRate: number, allowBuffering:
1515
* that all replays will be saved to as attachments. Currently, we only expect
1616
* one of these Sentry events per "replay session".
1717
*/
18-
export function createSession({ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions): Session {
18+
export function createSession(
19+
{ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions,
20+
{ previousSessionId }: { previousSessionId?: string } = {},
21+
): Session {
1922
const sampled = getSessionSampleType(sessionSampleRate, allowBuffering);
2023
const session = makeSession({
2124
sampled,
25+
previousSessionId,
2226
});
2327

2428
if (stickySession) {

packages/replay/src/session/getSession.ts

Lines changed: 0 additions & 63 deletions
This file was deleted.

0 commit comments

Comments
 (0)