Skip to content

Commit 21f8c94

Browse files
committed
feat(browser): Add persisted session lifecycle to browserSessionIntegration
Adds `lifecycle: 'session'`, under which one session spans the user's whole visit: it is persisted in `sessionStorage`, resumed on the next page load, and rotated once it idles out (30 min) or hits its max duration (8 h). Both bounds are configurable via `idleTimeout` and `maxDuration`. `'page'` and `'route'` keep their existing behaviour and `'page'` stays the default, so nothing changes unless the new lifecycle is opted into.
1 parent ec6b24f commit 21f8c94

3 files changed

Lines changed: 331 additions & 12 deletions

File tree

‎packages/browser/src/integrations/browsersession.ts‎

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,22 @@ import {
66
SEMANTIC_ATTRIBUTE_SESSION_ID,
77
startSession,
88
} from '@sentry/core/browser';
9-
import { addHistoryInstrumentationHandler, whenIdleOrHidden } from '@sentry/browser-utils';
9+
import {
10+
addClickKeypressInstrumentationHandler,
11+
addHistoryInstrumentationHandler,
12+
whenIdleOrHidden,
13+
} from '@sentry/browser-utils';
1014
import { DEBUG_BUILD } from '../debug-build';
1115
import { WINDOW } from '../helpers';
16+
import type { PersistedSession, SessionExpiryOptions } from '../session/persistence';
17+
import { getPersistedSession, isSessionExpired, persistSession } from '../session/persistence';
18+
19+
const DEFAULT_IDLE_TIMEOUT = 30 * 60_000;
20+
const DEFAULT_MAX_DURATION = 8 * 60 * 60_000;
21+
22+
// Writing to sessionStorage on every interaction is wasteful, and a few seconds of
23+
// staleness is immaterial against timeouts measured in minutes.
24+
const PERSIST_THROTTLE = 5_000;
1225

1326
interface BrowserSessionOptions {
1427
/**
@@ -17,10 +30,59 @@ interface BrowserSessionOptions {
1730
* - `'page'`: A session is created once when the page is loaded. Session is not
1831
* updated on navigation. This is the default behavior.
1932
* - `'route'`: A session is created on page load and on every navigation.
33+
* - `'session'`: One session spans the user's whole visit to the site. It is persisted in
34+
* `sessionStorage` and resumed across reloads and navigations until it expires
35+
* (see `idleTimeout` and `maxDuration`).
2036
*
2137
* @default 'page'
2238
*/
23-
lifecycle?: 'route' | 'page';
39+
lifecycle?: 'route' | 'page' | 'session';
40+
41+
/**
42+
* How long the user can be inactive, in milliseconds, before the next interaction starts
43+
* a new session. Only applies to the `'session'` lifecycle.
44+
*
45+
* Activity means the user did something (a click, a key press, a scroll, a navigation).
46+
* Telemetry the app emits on its own does not keep a session alive, otherwise a tab left
47+
* open in the background would hold one open indefinitely.
48+
*
49+
* @default 1_800_000 (30 minutes)
50+
*/
51+
idleTimeout?: number;
52+
53+
/**
54+
* The maximum lifetime of a session in milliseconds, regardless of activity. Only applies
55+
* to the `'session'` lifecycle.
56+
*
57+
* @default 28_800_000 (8 hours)
58+
*/
59+
maxDuration?: number;
60+
}
61+
62+
/**
63+
* Starts a session, resuming @param resume if one was handed over from a previous page load,
64+
* and writes it back to `sessionStorage`.
65+
*/
66+
function startAndPersistSession(resume?: PersistedSession): PersistedSession {
67+
const now = Date.now();
68+
69+
// `ignoreDuration` stays on: a session now has a meaningful duration, but reporting it would
70+
// change what release health's session duration distribution measures, which is out of scope here.
71+
const session = startSession(
72+
resume
73+
? // `init: false` marks this as an update to an already-counted session rather than a new
74+
// one, so resuming across a page load does not inflate release health session counts.
75+
{ sid: resume.sid, started: resume.started / 1000, init: false, ignoreDuration: true }
76+
: { ignoreDuration: true },
77+
);
78+
79+
// The persisted record is kept on the `Date` clock throughout. Session timestamps come from
80+
// `performance.timeOrigin`, which is reset for every document, so they are not comparable
81+
// across the page loads this record has to survive.
82+
const persisted = { sid: session.sid, started: resume ? resume.started : now, lastActivity: now };
83+
persistSession(persisted);
84+
85+
return persisted;
2486
}
2587

2688
/**
@@ -31,6 +93,10 @@ interface BrowserSessionOptions {
3193
*/
3294
export const browserSessionIntegration = defineIntegration((options: BrowserSessionOptions = {}) => {
3395
const lifecycle = options.lifecycle ?? 'page';
96+
const expiry: SessionExpiryOptions = {
97+
idleTimeout: options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT,
98+
maxDuration: options.maxDuration ?? DEFAULT_MAX_DURATION,
99+
};
34100

35101
return {
36102
name: 'BrowserSession' as const,
@@ -41,18 +107,55 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess
41107
return;
42108
}
43109

44-
// The session duration for browser sessions does not track a meaningful
45-
// concept that can be used as a metric.
46-
// Automatically captured sessions are akin to page views, and thus we
47-
// discard their duration.
48-
startSession({ ignoreDuration: true });
49-
50110
// Sending the session envelope synchronously in `init()` runs the full send
51111
// pipeline during page load, competing with critical resources for the network and
52112
// adding overhead that measurably hurts LCP. We defer the initial send until the
53113
// browser is idle; `whenIdleOrHidden` flushes it on page-hide so we don't lose short
54114
// (page-view-like) sessions.
55115
let initialSessionSent = false;
116+
117+
if (lifecycle === 'session') {
118+
const persisted = getPersistedSession();
119+
const now = Date.now();
120+
let current = startAndPersistSession(
121+
persisted && !isSessionExpired(persisted, expiry, now) ? persisted : undefined,
122+
);
123+
let lastPersistedAt = current.lastActivity;
124+
125+
const onActivity = (): void => {
126+
const activityAt = Date.now();
127+
128+
if (isSessionExpired(current, expiry, activityAt)) {
129+
current = startAndPersistSession();
130+
lastPersistedAt = current.lastActivity;
131+
captureSession();
132+
// A session has now been sent, so the deferred initial capture (if still pending)
133+
// must not re-send this session.
134+
initialSessionSent = true;
135+
return;
136+
}
137+
138+
current.lastActivity = activityAt;
139+
if (activityAt - lastPersistedAt > PERSIST_THROTTLE) {
140+
persistSession(current);
141+
lastPersistedAt = activityAt;
142+
}
143+
};
144+
145+
// Clicks and key presses are already instrumented for breadcrumbs, so subscribing here
146+
// adds no additional listeners in the common case.
147+
addClickKeypressInstrumentationHandler(onActivity);
148+
addHistoryInstrumentationHandler(onActivity);
149+
// Scroll does not bubble, so it has to be caught on the way down.
150+
WINDOW.document.addEventListener('scroll', onActivity, { capture: true, passive: true });
151+
} else {
152+
// The session duration for browser sessions does not track a meaningful
153+
// concept that can be used as a metric.
154+
// Automatically captured sessions are akin to page views, and thus we
155+
// discard their duration.
156+
startSession({ ignoreDuration: true });
157+
}
158+
56159
whenIdleOrHidden(() => {
57160
// A navigation (in `'route'` lifecycle) may start and send a new session before this
58161
// deferred callback fires. In that case the current session was already sent, so
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { debug } from '@sentry/core/browser';
2+
import { DEBUG_BUILD } from '../debug-build';
3+
import { WINDOW } from '../helpers';
4+
5+
export const SESSION_STORAGE_KEY = 'sentry_session';
6+
7+
/**
8+
* The subset of a session we need to resume it on a later page load. Timestamps are
9+
* in milliseconds, unlike the seconds-based timestamps on the session itself.
10+
*/
11+
export interface PersistedSession {
12+
sid: string;
13+
started: number;
14+
lastActivity: number;
15+
}
16+
17+
export interface SessionExpiryOptions {
18+
idleTimeout: number;
19+
maxDuration: number;
20+
}
21+
22+
/**
23+
* Reads the persisted session of the current browsing context, if there is one.
24+
*/
25+
export function getPersistedSession(): PersistedSession | undefined {
26+
try {
27+
const persisted = WINDOW.sessionStorage?.getItem(SESSION_STORAGE_KEY);
28+
// @ts-expect-error - intentionally risking JSON.parse throwing when persisted is null to save bundle size
29+
const session = JSON.parse(persisted) as PersistedSession;
30+
return session.sid ? session : undefined;
31+
} catch {
32+
return undefined;
33+
}
34+
}
35+
36+
/**
37+
* Persists @param session so that the next page load in this browsing context can resume it.
38+
*/
39+
export function persistSession(session: PersistedSession): void {
40+
try {
41+
WINDOW.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
42+
} catch (e) {
43+
// Ignore potential errors (e.g. if sessionStorage is not available)
44+
DEBUG_BUILD && debug.warn('Could not persist session in sessionStorage', e);
45+
}
46+
}
47+
48+
/**
49+
* Whether @param session has run past either of its lifetime bounds and a new session
50+
* should be started in its place.
51+
*/
52+
export function isSessionExpired(
53+
session: PersistedSession,
54+
{ idleTimeout, maxDuration }: SessionExpiryOptions,
55+
now: number,
56+
): boolean {
57+
return now - session.lastActivity > idleTimeout || now - session.started > maxDuration;
58+
}

0 commit comments

Comments
 (0)