Skip to content

Commit befae60

Browse files
committed
fix(replay): Fully stop & restart session when it expires
1 parent 434507d commit befae60

File tree

11 files changed

+938
-1274
lines changed

11 files changed

+938
-1274
lines changed

packages/replay/src/replay.ts

Lines changed: 48 additions & 53 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,18 @@ 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(
378-
`[Replay] Stopping Replay${reason ? ` triggered by ${reason}` : ''}`,
380+
`[Replay] Stopping Replay${reason ? ` triggered by ${reason}` : ''} ${new Date().toISOString()} ${
381+
this._isEnabled
382+
}`,
379383
this._options._experiments.traceInternals,
380384
);
381385

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;
385386
this._removeListeners();
386387
this.stopRecording();
387388

@@ -475,16 +476,6 @@ export class ReplayContainer implements ReplayContainerInterface {
475476

476477
// Once this session ends, we do not want to refresh it
477478
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.
488479
this._updateUserActivity(activityTime);
489480
this._updateSessionActivity(activityTime);
490481
this._maybeSaveSession();
@@ -740,6 +731,7 @@ export class ReplayContainer implements ReplayContainerInterface {
740731

741732
// Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
742733
this._isEnabled = true;
734+
this._isPaused = false;
743735

744736
this.startRecording();
745737
}
@@ -756,17 +748,17 @@ export class ReplayContainer implements ReplayContainerInterface {
756748
/**
757749
* Loads (or refreshes) the current session.
758750
*/
759-
private _initializeSessionForSampling(): void {
751+
private _initializeSessionForSampling(previousSessionId?: string): void {
760752
// Whenever there is _any_ error sample rate, we always allow buffering
761753
// Because we decide on sampling when an error occurs, we need to buffer at all times if sampling for errors
762754
const allowBuffering = this._options.errorSampleRate > 0;
763755

764756
const session = loadOrCreateSession(
765-
this.session,
766757
{
767758
sessionIdleExpire: this.timeouts.sessionIdleExpire,
768759
maxReplayDuration: this._options.maxReplayDuration,
769760
traceInternals: this._options._experiments.traceInternals,
761+
previousSessionId,
770762
},
771763
{
772764
stickySession: this._options.stickySession,
@@ -791,37 +783,32 @@ export class ReplayContainer implements ReplayContainerInterface {
791783

792784
const currentSession = this.session;
793785

794-
const newSession = maybeRefreshSession(
795-
currentSession,
796-
{
786+
if (
787+
shouldRefreshSession(currentSession, {
797788
sessionIdleExpire: this.timeouts.sessionIdleExpire,
798-
traceInternals: this._options._experiments.traceInternals,
799789
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' });
790+
})
791+
) {
792+
void this._refreshSession(currentSession);
819793
return false;
820794
}
821795

822796
return true;
823797
}
824798

799+
/**
800+
* Refresh a session with a new one.
801+
* This stops the current session (without forcing a flush, as that would never work since we are expired),
802+
* and then does a new sampling based on the refreshed session.
803+
*/
804+
private async _refreshSession(session: Session): Promise<void> {
805+
if (!this._isEnabled) {
806+
return;
807+
}
808+
await this.stop({ reason: 'refresh session' });
809+
this.initializeSampling(session.id);
810+
}
811+
825812
/**
826813
* Adds listeners to record events for the replay
827814
*/
@@ -933,10 +920,14 @@ export class ReplayContainer implements ReplayContainerInterface {
933920

934921
const expired = isSessionExpired(this.session, {
935922
maxReplayDuration: this._options.maxReplayDuration,
936-
...this.timeouts,
923+
sessionIdleExpire: this.timeouts.sessionIdleExpire,
937924
});
938925

939-
if (breadcrumb && !expired) {
926+
if (expired) {
927+
return;
928+
}
929+
930+
if (breadcrumb) {
940931
this._createCustomBreadcrumb(breadcrumb);
941932
}
942933

@@ -1081,7 +1072,9 @@ export class ReplayContainer implements ReplayContainerInterface {
10811072
* Should never be called directly, only by `flush`
10821073
*/
10831074
private async _runFlush(): Promise<void> {
1084-
if (!this.session || !this.eventBuffer) {
1075+
const replayId = this.getSessionId();
1076+
1077+
if (!this.session || !this.eventBuffer || !replayId) {
10851078
__DEBUG_BUILD__ && logger.error('[Replay] No session or eventBuffer found to flush.');
10861079
return;
10871080
}
@@ -1101,13 +1094,15 @@ export class ReplayContainer implements ReplayContainerInterface {
11011094
return;
11021095
}
11031096

1097+
// if this changed in the meanwhile, e.g. because the session was refreshed or similar, we abort here
1098+
if (replayId !== this.getSessionId()) {
1099+
return;
1100+
}
1101+
11041102
try {
11051103
// This uses the data from the eventBuffer, so we need to call this before `finish()
11061104
this._updateInitialTimestampFromEventBuffer();
11071105

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

11131108
// Check total duration again, to avoid sending outdated stuff
@@ -1117,14 +1112,14 @@ export class ReplayContainer implements ReplayContainerInterface {
11171112
throw new Error('Session is too long, not sending replay');
11181113
}
11191114

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;
11231115
const eventContext = this._popEventContext();
11241116
// Always increment segmentId regardless of outcome of sending replay
11251117
const segmentId = this.session.segmentId++;
11261118
this._maybeSaveSession();
11271119

1120+
// Note this empties the event buffer regardless of outcome of sending replay
1121+
const recordingData = await this.eventBuffer.finish();
1122+
11281123
await sendReplay({
11291124
replayId,
11301125
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.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Session } from '../types';
2+
import { isSessionExpired } from '../util/isSessionExpired';
3+
4+
/** If the session should be refreshed or not. */
5+
export function shouldRefreshSession(
6+
session: Session,
7+
{ sessionIdleExpire, maxReplayDuration }: { sessionIdleExpire: number; maxReplayDuration: number },
8+
): boolean {
9+
// If not expired, all good, just keep the session
10+
if (!isSessionExpired(session, { sessionIdleExpire, maxReplayDuration })) {
11+
return false;
12+
}
13+
14+
// If we are buffering & haven't ever flushed yet, always continue
15+
if (session.sampled === 'buffer' && session.segmentId === 0) {
16+
return false;
17+
}
18+
19+
return true;
20+
}

packages/replay/src/types/replay.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -373,12 +373,6 @@ export interface Session {
373373
* Is the session sampled? `false` if not sampled, otherwise, `session` or `buffer`
374374
*/
375375
sampled: Sampled;
376-
377-
/**
378-
* If this is false, the session should not be refreshed when it was inactive.
379-
* This can be the case if you had a buffered session which is now recording because an error happened.
380-
*/
381-
shouldRefresh: boolean;
382376
}
383377

384378
export type EventBufferType = 'sync' | 'worker';

0 commit comments

Comments
 (0)