Skip to content

Commit 30c4540

Browse files
authored
fix(replay): Fully stop & restart session when it expires (#8834)
This PR changes the behavior when a session is expired to fully stop & restart the replay. This means we just re-sample based on sample rates and start a completely new session in that case.
1 parent 434507d commit 30c4540

File tree

11 files changed

+938
-1291
lines changed

11 files changed

+938
-1291
lines changed

packages/replay/src/replay.ts

Lines changed: 48 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
1818
import { createEventBuffer } from './eventBuffer';
1919
import { clearSession } from './session/clearSession';
2020
import { loadOrCreateSession } from './session/loadOrCreateSession';
21-
import { maybeRefreshSession } from './session/maybeRefreshSession';
2221
import { saveSession } from './session/saveSession';
22+
import { shouldRefreshSession } from './session/shouldRefreshSession';
2323
import type {
2424
AddEventResult,
2525
AddUpdateCallback,
@@ -217,7 +217,7 @@ export class ReplayContainer implements ReplayContainerInterface {
217217
* Initializes the plugin based on sampling configuration. Should not be
218218
* called outside of constructor.
219219
*/
220-
public initializeSampling(): void {
220+
public initializeSampling(previousSessionId?: string): void {
221221
const { errorSampleRate, sessionSampleRate } = this._options;
222222

223223
// If neither sample rate is > 0, then do nothing - user will need to call one of
@@ -228,7 +228,7 @@ export class ReplayContainer implements ReplayContainerInterface {
228228

229229
// Otherwise if there is _any_ sample rate set, try to load an existing
230230
// session, or create a new one.
231-
this._initializeSessionForSampling();
231+
this._initializeSessionForSampling(previousSessionId);
232232

233233
if (!this.session) {
234234
// This should not happen, something wrong has occurred
@@ -273,7 +273,6 @@ export class ReplayContainer implements ReplayContainerInterface {
273273
logInfoNextTick('[Replay] Starting replay in session mode', this._options._experiments.traceInternals);
274274

275275
const session = loadOrCreateSession(
276-
this.session,
277276
{
278277
maxReplayDuration: this._options.maxReplayDuration,
279278
sessionIdleExpire: this.timeouts.sessionIdleExpire,
@@ -304,7 +303,6 @@ export class ReplayContainer implements ReplayContainerInterface {
304303
logInfoNextTick('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals);
305304

306305
const session = loadOrCreateSession(
307-
this.session,
308306
{
309307
sessionIdleExpire: this.timeouts.sessionIdleExpire,
310308
maxReplayDuration: this._options.maxReplayDuration,
@@ -373,15 +371,16 @@ export class ReplayContainer implements ReplayContainerInterface {
373371
return;
374372
}
375373

374+
// We can't move `_isEnabled` after awaiting a flush, otherwise we can
375+
// enter into an infinite loop when `stop()` is called while flushing.
376+
this._isEnabled = false;
377+
376378
try {
377379
logInfo(
378380
`[Replay] Stopping Replay${reason ? ` triggered by ${reason}` : ''}`,
379381
this._options._experiments.traceInternals,
380382
);
381383

382-
// We can't move `_isEnabled` after awaiting a flush, otherwise we can
383-
// enter into an infinite loop when `stop()` is called while flushing.
384-
this._isEnabled = false;
385384
this._removeListeners();
386385
this.stopRecording();
387386

@@ -475,16 +474,6 @@ export class ReplayContainer implements ReplayContainerInterface {
475474

476475
// Once this session ends, we do not want to refresh it
477476
if (this.session) {
478-
this.session.shouldRefresh = false;
479-
480-
// It's possible that the session lifespan is > max session lifespan
481-
// because we have been buffering beyond max session lifespan (we ignore
482-
// expiration given that `shouldRefresh` is true). Since we flip
483-
// `shouldRefresh`, the session could be considered expired due to
484-
// lifespan, which is not what we want. Update session start date to be
485-
// the current timestamp, so that session is not considered to be
486-
// expired. This means that max replay duration can be MAX_REPLAY_DURATION +
487-
// (length of buffer), which we are ok with.
488477
this._updateUserActivity(activityTime);
489478
this._updateSessionActivity(activityTime);
490479
this._maybeSaveSession();
@@ -612,8 +601,6 @@ export class ReplayContainer implements ReplayContainerInterface {
612601
* @hidden
613602
*/
614603
public checkAndHandleExpiredSession(): boolean | void {
615-
const oldSessionId = this.getSessionId();
616-
617604
// Prevent starting a new session if the last user activity is older than
618605
// SESSION_IDLE_PAUSE_DURATION. Otherwise non-user activity can trigger a new
619606
// session+recording. This creates noisy replays that do not have much
@@ -635,24 +622,11 @@ export class ReplayContainer implements ReplayContainerInterface {
635622
// --- There is recent user activity --- //
636623
// This will create a new session if expired, based on expiry length
637624
if (!this._checkSession()) {
638-
return;
639-
}
640-
641-
// Session was expired if session ids do not match
642-
const expired = oldSessionId !== this.getSessionId();
643-
644-
if (!expired) {
645-
return true;
646-
}
647-
648-
// Session is expired, trigger a full snapshot (which will create a new session)
649-
if (this.isPaused()) {
650-
this.resume();
651-
} else {
652-
this._triggerFullSnapshot();
625+
// Check session handles the refreshing itself
626+
return false;
653627
}
654628

655-
return false;
629+
return true;
656630
}
657631

658632
/**
@@ -740,6 +714,7 @@ export class ReplayContainer implements ReplayContainerInterface {
740714

741715
// Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
742716
this._isEnabled = true;
717+
this._isPaused = false;
743718

744719
this.startRecording();
745720
}
@@ -756,17 +731,17 @@ export class ReplayContainer implements ReplayContainerInterface {
756731
/**
757732
* Loads (or refreshes) the current session.
758733
*/
759-
private _initializeSessionForSampling(): void {
734+
private _initializeSessionForSampling(previousSessionId?: string): void {
760735
// Whenever there is _any_ error sample rate, we always allow buffering
761736
// Because we decide on sampling when an error occurs, we need to buffer at all times if sampling for errors
762737
const allowBuffering = this._options.errorSampleRate > 0;
763738

764739
const session = loadOrCreateSession(
765-
this.session,
766740
{
767741
sessionIdleExpire: this.timeouts.sessionIdleExpire,
768742
maxReplayDuration: this._options.maxReplayDuration,
769743
traceInternals: this._options._experiments.traceInternals,
744+
previousSessionId,
770745
},
771746
{
772747
stickySession: this._options.stickySession,
@@ -791,37 +766,32 @@ export class ReplayContainer implements ReplayContainerInterface {
791766

792767
const currentSession = this.session;
793768

794-
const newSession = maybeRefreshSession(
795-
currentSession,
796-
{
769+
if (
770+
shouldRefreshSession(currentSession, {
797771
sessionIdleExpire: this.timeouts.sessionIdleExpire,
798-
traceInternals: this._options._experiments.traceInternals,
799772
maxReplayDuration: this._options.maxReplayDuration,
800-
},
801-
{
802-
stickySession: Boolean(this._options.stickySession),
803-
sessionSampleRate: this._options.sessionSampleRate,
804-
allowBuffering: this._options.errorSampleRate > 0,
805-
},
806-
);
807-
808-
const isNew = newSession.id !== currentSession.id;
809-
810-
// If session was newly created (i.e. was not loaded from storage), then
811-
// enable flag to create the root replay
812-
if (isNew) {
813-
this.setInitialState();
814-
this.session = newSession;
815-
}
816-
817-
if (!this.session.sampled) {
818-
void this.stop({ reason: 'session not refreshed' });
773+
})
774+
) {
775+
void this._refreshSession(currentSession);
819776
return false;
820777
}
821778

822779
return true;
823780
}
824781

782+
/**
783+
* Refresh a session with a new one.
784+
* This stops the current session (without forcing a flush, as that would never work since we are expired),
785+
* and then does a new sampling based on the refreshed session.
786+
*/
787+
private async _refreshSession(session: Session): Promise<void> {
788+
if (!this._isEnabled) {
789+
return;
790+
}
791+
await this.stop({ reason: 'refresh session' });
792+
this.initializeSampling(session.id);
793+
}
794+
825795
/**
826796
* Adds listeners to record events for the replay
827797
*/
@@ -933,10 +903,14 @@ export class ReplayContainer implements ReplayContainerInterface {
933903

934904
const expired = isSessionExpired(this.session, {
935905
maxReplayDuration: this._options.maxReplayDuration,
936-
...this.timeouts,
906+
sessionIdleExpire: this.timeouts.sessionIdleExpire,
937907
});
938908

939-
if (breadcrumb && !expired) {
909+
if (expired) {
910+
return;
911+
}
912+
913+
if (breadcrumb) {
940914
this._createCustomBreadcrumb(breadcrumb);
941915
}
942916

@@ -1081,7 +1055,9 @@ export class ReplayContainer implements ReplayContainerInterface {
10811055
* Should never be called directly, only by `flush`
10821056
*/
10831057
private async _runFlush(): Promise<void> {
1084-
if (!this.session || !this.eventBuffer) {
1058+
const replayId = this.getSessionId();
1059+
1060+
if (!this.session || !this.eventBuffer || !replayId) {
10851061
__DEBUG_BUILD__ && logger.error('[Replay] No session or eventBuffer found to flush.');
10861062
return;
10871063
}
@@ -1101,13 +1077,15 @@ export class ReplayContainer implements ReplayContainerInterface {
11011077
return;
11021078
}
11031079

1080+
// if this changed in the meanwhile, e.g. because the session was refreshed or similar, we abort here
1081+
if (replayId !== this.getSessionId()) {
1082+
return;
1083+
}
1084+
11041085
try {
11051086
// This uses the data from the eventBuffer, so we need to call this before `finish()
11061087
this._updateInitialTimestampFromEventBuffer();
11071088

1108-
// Note this empties the event buffer regardless of outcome of sending replay
1109-
const recordingData = await this.eventBuffer.finish();
1110-
11111089
const timestamp = Date.now();
11121090

11131091
// Check total duration again, to avoid sending outdated stuff
@@ -1117,14 +1095,14 @@ export class ReplayContainer implements ReplayContainerInterface {
11171095
throw new Error('Session is too long, not sending replay');
11181096
}
11191097

1120-
// NOTE: Copy values from instance members, as it's possible they could
1121-
// change before the flush finishes.
1122-
const replayId = this.session.id;
11231098
const eventContext = this._popEventContext();
11241099
// Always increment segmentId regardless of outcome of sending replay
11251100
const segmentId = this.session.segmentId++;
11261101
this._maybeSaveSession();
11271102

1103+
// Note this empties the event buffer regardless of outcome of sending replay
1104+
const recordingData = await this.eventBuffer.finish();
1105+
11281106
await sendReplay({
11291107
replayId,
11301108
recordingData,

packages/replay/src/session/Session.ts

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

1918
return {
@@ -22,7 +21,6 @@ export function makeSession(session: Partial<Session> & { sampled: Sampled }): S
2221
lastActivity,
2322
segmentId,
2423
sampled,
25-
shouldRefresh,
2624
previousSessionId,
2725
};
2826
}

packages/replay/src/session/loadOrCreateSession.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,38 @@ import type { Session, SessionOptions } from '../types';
22
import { logInfoNextTick } from '../util/log';
33
import { createSession } from './createSession';
44
import { fetchSession } from './fetchSession';
5-
import { maybeRefreshSession } from './maybeRefreshSession';
5+
import { shouldRefreshSession } from './shouldRefreshSession';
66

77
/**
88
* Get or create a session, when initializing the replay.
99
* Returns a session that may be unsampled.
1010
*/
1111
export function loadOrCreateSession(
12-
currentSession: Session | undefined,
1312
{
1413
traceInternals,
1514
sessionIdleExpire,
1615
maxReplayDuration,
16+
previousSessionId,
1717
}: {
1818
sessionIdleExpire: number;
1919
maxReplayDuration: number;
2020
traceInternals?: boolean;
21+
previousSessionId?: string;
2122
},
2223
sessionOptions: SessionOptions,
2324
): Session {
24-
// If session exists and is passed, use it instead of always hitting session storage
25-
const existingSession = currentSession || (sessionOptions.stickySession && fetchSession(traceInternals));
25+
const existingSession = sessionOptions.stickySession && fetchSession(traceInternals);
2626

2727
// No session exists yet, just create a new one
2828
if (!existingSession) {
29-
logInfoNextTick('[Replay] Created new session', traceInternals);
30-
return createSession(sessionOptions);
29+
logInfoNextTick('[Replay] Creating new session', traceInternals);
30+
return createSession(sessionOptions, { previousSessionId });
3131
}
3232

33-
return maybeRefreshSession(existingSession, { sessionIdleExpire, traceInternals, maxReplayDuration }, sessionOptions);
33+
if (!shouldRefreshSession(existingSession, { sessionIdleExpire, maxReplayDuration })) {
34+
return existingSession;
35+
}
36+
37+
logInfoNextTick('[Replay] Session in sessionStorage is expired, creating new one...');
38+
return createSession(sessionOptions, { previousSessionId: existingSession.id });
3439
}

packages/replay/src/session/maybeRefreshSession.ts

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

0 commit comments

Comments
 (0)