@@ -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' ;
1014import { DEBUG_BUILD } from '../debug-build' ;
1115import { 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
1326interface 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 */
3294export 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
0 commit comments