diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 5716c98db98c..a5b3dda389dc 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -65,7 +65,6 @@ export { parameterize, startSession, captureSession, - endSession, spanToStaticSpanJSON, spanToJSON, spanToTraceHeader, @@ -84,6 +83,8 @@ export { } from '@sentry/core/browser'; export { WINDOW } from './helpers'; +// Shadows core's `endSession`: in the browser, ending a session also starts the next one. +export { endSession } from './session/lifecycle'; export { BrowserClient } from './client'; export { makeFetchTransport } from './transports/fetch'; export { uiProfiler } from './profiling'; diff --git a/packages/browser/src/integrations/browsersession.ts b/packages/browser/src/integrations/browsersession.ts index c7227f7af70c..938c93b65bdd 100644 --- a/packages/browser/src/integrations/browsersession.ts +++ b/packages/browser/src/integrations/browsersession.ts @@ -6,9 +6,23 @@ import { SEMANTIC_ATTRIBUTE_SESSION_ID, startSession, } from '@sentry/core/browser'; -import { addHistoryInstrumentationHandler, whenIdleOrHidden } from '@sentry/browser-utils'; +import { + addClickKeypressInstrumentationHandler, + addHistoryInstrumentationHandler, + whenIdleOrHidden, +} from '@sentry/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; +import { setSessionRotator } from '../session/lifecycle'; +import type { PersistedSession, SessionExpiryOptions } from '../session/persistence'; +import { getPersistedSession, isSessionExpired, persistSession } from '../session/persistence'; + +const DEFAULT_IDLE_TIMEOUT = 30 * 60_000; +const DEFAULT_MAX_DURATION = 8 * 60 * 60_000; + +// Writing to sessionStorage on every interaction is wasteful, and a few seconds of +// staleness is immaterial against timeouts measured in minutes. +const PERSIST_THROTTLE = 5_000; interface BrowserSessionOptions { /** @@ -17,10 +31,59 @@ interface BrowserSessionOptions { * - `'page'`: A session is created once when the page is loaded. Session is not * updated on navigation. This is the default behavior. * - `'route'`: A session is created on page load and on every navigation. + * - `'session'`: One session spans the user's whole visit to the site. It is persisted in + * `sessionStorage` and resumed across reloads and navigations until it expires + * (see `idleTimeout` and `maxDuration`). * * @default 'page' */ - lifecycle?: 'route' | 'page'; + lifecycle?: 'route' | 'page' | 'session'; + + /** + * How long the user can be inactive, in milliseconds, before the next interaction starts + * a new session. Only applies to the `'session'` lifecycle. + * + * Activity means the user did something (a click, a key press, a scroll, a navigation). + * Telemetry the app emits on its own does not keep a session alive, otherwise a tab left + * open in the background would hold one open indefinitely. + * + * @default 1_800_000 (30 minutes) + */ + idleTimeout?: number; + + /** + * The maximum lifetime of a session in milliseconds, regardless of activity. Only applies + * to the `'session'` lifecycle. + * + * @default 28_800_000 (8 hours) + */ + maxDuration?: number; +} + +/** + * Starts a session, resuming @param resume if one was handed over from a previous page load, + * and writes it back to `sessionStorage`. + */ +function startAndPersistSession(resume?: PersistedSession): PersistedSession { + const now = Date.now(); + + // `ignoreDuration` stays on: a session now has a meaningful duration, but reporting it would + // change what release health's session duration distribution measures, which is out of scope here. + const session = startSession( + resume + ? // `init: false` marks this as an update to an already-counted session rather than a new + // one, so resuming across a page load does not inflate release health session counts. + { sid: resume.sid, started: resume.started / 1000, init: false, ignoreDuration: true } + : { ignoreDuration: true }, + ); + + // The persisted record is kept on the `Date` clock throughout. Session timestamps come from + // `performance.timeOrigin`, which is reset for every document, so they are not comparable + // across the page loads this record has to survive. + const persisted = { sid: session.sid, started: resume ? resume.started : now, lastActivity: now }; + persistSession(persisted); + + return persisted; } /** @@ -31,6 +94,10 @@ interface BrowserSessionOptions { */ export const browserSessionIntegration = defineIntegration((options: BrowserSessionOptions = {}) => { const lifecycle = options.lifecycle ?? 'page'; + const expiry: SessionExpiryOptions = { + idleTimeout: options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT, + maxDuration: options.maxDuration ?? DEFAULT_MAX_DURATION, + }; return { name: 'BrowserSession' as const, @@ -41,18 +108,71 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess return; } - // The session duration for browser sessions does not track a meaningful - // concept that can be used as a metric. - // Automatically captured sessions are akin to page views, and thus we - // discard their duration. - startSession({ ignoreDuration: true }); - // Sending the session envelope synchronously in `init()` runs the full send // pipeline during page load, competing with critical resources for the network and // adding overhead that measurably hurts LCP. We defer the initial send until the // browser is idle; `whenIdleOrHidden` flushes it on page-hide so we don't lose short // (page-view-like) sessions. let initialSessionSent = false; + + // Ends the current session and starts a new one in its place, sending it right away. Expiry, + // navigation (in the `'route'` lifecycle) and the public `endSession()` all funnel through here. + let rotate: () => void; + + if (lifecycle === 'session') { + const persisted = getPersistedSession(); + const now = Date.now(); + let current = startAndPersistSession( + persisted && !isSessionExpired(persisted, expiry, now) ? persisted : undefined, + ); + let lastPersistedAt = current.lastActivity; + + rotate = () => { + current = startAndPersistSession(); + lastPersistedAt = current.lastActivity; + captureSession(); + // A session has now been sent, so the deferred initial capture (if still pending) + // must not re-send this session. + initialSessionSent = true; + }; + + const onActivity = (): void => { + const activityAt = Date.now(); + + if (isSessionExpired(current, expiry, activityAt)) { + rotate(); + return; + } + + current.lastActivity = activityAt; + if (activityAt - lastPersistedAt > PERSIST_THROTTLE) { + persistSession(current); + lastPersistedAt = activityAt; + } + }; + + // Clicks and key presses are already instrumented for breadcrumbs, so subscribing here + // adds no additional listeners in the common case. + addClickKeypressInstrumentationHandler(onActivity); + addHistoryInstrumentationHandler(onActivity); + // Scroll does not bubble, so it has to be caught on the way down. + WINDOW.document.addEventListener('scroll', onActivity, { capture: true, passive: true }); + } else { + // The session duration for browser sessions does not track a meaningful + // concept that can be used as a metric. + // Automatically captured sessions are akin to page views, and thus we + // discard their duration. + startSession({ ignoreDuration: true }); + + rotate = () => { + startSession({ ignoreDuration: true }); + captureSession(); + initialSessionSent = true; + }; + } + + setSessionRotator(rotate); + whenIdleOrHidden(() => { // A navigation (in `'route'` lifecycle) may start and send a new session before this // deferred callback fires. In that case the current session was already sent, so @@ -95,11 +215,7 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess addHistoryInstrumentationHandler(({ from, to }) => { // Don't create an additional session for the initial route or if the location did not change if (from !== to) { - startSession({ ignoreDuration: true }); - captureSession(); - // A session has now been sent, so the deferred initial capture (if still pending) - // must not re-send this navigation session. - initialSessionSent = true; + rotate(); } }); } diff --git a/packages/browser/src/session/lifecycle.ts b/packages/browser/src/session/lifecycle.ts new file mode 100644 index 000000000000..fcf6270c29ec --- /dev/null +++ b/packages/browser/src/session/lifecycle.ts @@ -0,0 +1,35 @@ +import { endSession as endSessionOnScope } from '@sentry/core/browser'; + +/** + * How the session lifecycle currently in effect replaces its session, registered by + * `browserSessionIntegration`. Rotating is lifecycle-specific (the persisted `'session'` lifecycle + * has a `sessionStorage` record to keep in sync, the others do not), so the integration owns the + * implementation and the public API below only triggers it. + */ +let rotateSession: (() => void) | undefined; + +/** + * Registers @param rotate as the way to end the current session and start a new one in its place. + */ +export function setSessionRotator(rotate: () => void): void { + rotateSession = rotate; +} + +/** + * Ends the current session and immediately starts a new one. Everything the page reports from here + * on belongs to the new session. + * + * There is no gap between the two: a browser tab never stops producing telemetry, so a session that + * ended without a successor would leave whatever comes next unattributed. + * + * Requires `browserSessionIntegration` (enabled by default). Without it there is no session the SDK + * manages, so this only closes a session that was started manually. + */ +export function endSession(): void { + if (rotateSession) { + rotateSession(); + return; + } + + endSessionOnScope(); +} diff --git a/packages/browser/src/session/persistence.ts b/packages/browser/src/session/persistence.ts new file mode 100644 index 000000000000..b96eace651f7 --- /dev/null +++ b/packages/browser/src/session/persistence.ts @@ -0,0 +1,58 @@ +import { debug } from '@sentry/core/browser'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; + +export const SESSION_STORAGE_KEY = 'sentry_session'; + +/** + * The subset of a session we need to resume it on a later page load. Timestamps are + * in milliseconds, unlike the seconds-based timestamps on the session itself. + */ +export interface PersistedSession { + sid: string; + started: number; + lastActivity: number; +} + +export interface SessionExpiryOptions { + idleTimeout: number; + maxDuration: number; +} + +/** + * Reads the persisted session of the current browsing context, if there is one. + */ +export function getPersistedSession(): PersistedSession | undefined { + try { + const persisted = WINDOW.sessionStorage?.getItem(SESSION_STORAGE_KEY); + // @ts-expect-error - intentionally risking JSON.parse throwing when persisted is null to save bundle size + const session = JSON.parse(persisted) as PersistedSession; + return session.sid ? session : undefined; + } catch { + return undefined; + } +} + +/** + * Persists @param session so that the next page load in this browsing context can resume it. + */ +export function persistSession(session: PersistedSession): void { + try { + WINDOW.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); + } catch (e) { + // Ignore potential errors (e.g. if sessionStorage is not available) + DEBUG_BUILD && debug.warn('Could not persist session in sessionStorage', e); + } +} + +/** + * Whether @param session has run past either of its lifetime bounds and a new session + * should be started in its place. + */ +export function isSessionExpired( + session: PersistedSession, + { idleTimeout, maxDuration }: SessionExpiryOptions, + now: number, +): boolean { + return now - session.lastActivity > idleTimeout || now - session.started > maxDuration; +} diff --git a/packages/browser/test/integrations/browsersession.test.ts b/packages/browser/test/integrations/browsersession.test.ts index 4c7e487ad785..7710738c3cce 100644 --- a/packages/browser/test/integrations/browsersession.test.ts +++ b/packages/browser/test/integrations/browsersession.test.ts @@ -3,27 +3,37 @@ */ import type * as BrowserUtils from '@sentry/browser-utils'; -import type { Scope, User } from '@sentry/core/browser'; +import type { Scope, SessionContext, User } from '@sentry/core/browser'; import * as SentryCore from '@sentry/core/browser'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { browserSessionIntegration } from '../../src/integrations/browsersession'; +import { endSession } from '../../src/session/lifecycle'; +import type { PersistedSession } from '../../src/session/persistence'; +import { SESSION_STORAGE_KEY } from '../../src/session/persistence'; const scopeHolder = vi.hoisted(() => ({ current: undefined as unknown as FakeIsolationScope })); const historyHandlers = vi.hoisted(() => ({ current: [] as Array<(data: { from?: string; to?: string }) => void> })); +const domHandlers = vi.hoisted(() => ({ current: [] as Array<() => void> })); + vi.mock('@sentry/core/browser', async importActual => { const actual = (await importActual()) as typeof SentryCore; return { ...actual, - startSession: vi.fn(), + // The integration reads the session it gets back, so the mock has to produce a real one. + // `makeSession` narrows away `started`, which the integration legitimately passes when resuming. + startSession: vi.fn((context?: SessionContext) => + actual.makeSession(context as Parameters[0]), + ), captureSession: vi.fn(), getIsolationScope: () => scopeHolder.current, }; }); -// Capture the registered history handler so navigation can be driven deterministically, -// while keeping the real `whenIdleOrHidden` (the tests drive its timers/events directly). +// Capture the registered history and dom handlers so navigation and user activity can be driven +// deterministically, while keeping the real `whenIdleOrHidden` (the tests drive its timers/events +// directly). vi.mock('@sentry/browser-utils', async importActual => { const actual = (await importActual()) as typeof BrowserUtils; return { @@ -31,6 +41,9 @@ vi.mock('@sentry/browser-utils', async importActual => { addHistoryInstrumentationHandler: (handler: (data: { from?: string; to?: string }) => void) => { historyHandlers.current.push(handler); }, + addClickKeypressInstrumentationHandler: (handler: () => void) => { + domHandlers.current.push(handler); + }, }; }); @@ -38,6 +51,15 @@ function navigate(from: string, to: string): void { historyHandlers.current.forEach(handler => handler({ from, to })); } +function interact(): void { + domHandlers.current.forEach(handler => handler()); +} + +function getStoredSession(): PersistedSession | undefined { + const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY); + return stored ? (JSON.parse(stored) as PersistedSession) : undefined; +} + interface FakeIsolationScope { getUser: () => User | undefined; addScopeListener: (cb: (scope: Scope) => void) => void; @@ -82,6 +104,8 @@ describe('browserSessionIntegration', () => { setVisibilityState('visible'); scopeHolder.current = createFakeIsolationScope(); historyHandlers.current = []; + domHandlers.current = []; + window.sessionStorage.clear(); }); afterEach(() => { @@ -187,4 +211,178 @@ describe('browserSessionIntegration', () => { scopeHolder.current.setUser({ id: '1337', email: 'b@example.com' }); expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); }); + + it('starts and sends a new session when the current one is ended manually', () => { + setupBrowserSession({ lifecycle: 'page' }); + + endSession(); + + expect(SentryCore.startSession).toHaveBeenCalledTimes(2); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + + // The new session was already sent, so the deferred initial capture must not send it again. + vi.runAllTimers(); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + }); + + describe('`session` lifecycle', () => { + const IDLE_TIMEOUT = 30 * 60_000; + + /** Moves the wall clock without running any pending timers. */ + function elapse(ms: number): void { + vi.setSystemTime(Date.now() + ms); + } + + function setupSessionLifecycle(options?: Omit[0], 'lifecycle'>): void { + setupBrowserSession({ ...options, lifecycle: 'session' }); + } + + /** Simulates the next page load in the same tab: the SDK re-initializes, sessionStorage does not. */ + function reload(options?: Omit[0], 'lifecycle'>): void { + vi.mocked(SentryCore.startSession).mockClear(); + vi.mocked(SentryCore.captureSession).mockClear(); + historyHandlers.current = []; + domHandlers.current = []; + setupSessionLifecycle(options); + } + + it('persists the session so the next page load can resume it', () => { + setupSessionLifecycle(); + + expect(getStoredSession()).toEqual({ + sid: expect.any(String), + started: Date.now(), + lastActivity: Date.now(), + }); + }); + + it('resumes the persisted session on the next page load', () => { + setupSessionLifecycle(); + const stored = getStoredSession(); + + elapse(60_000); + reload(); + + expect(SentryCore.startSession).toHaveBeenCalledWith({ + sid: stored?.sid, + started: (stored as PersistedSession).started / 1000, + init: false, + ignoreDuration: true, + }); + expect(getStoredSession()?.sid).toBe(stored?.sid); + }); + + it('starts a new session when the persisted one idled out', () => { + setupSessionLifecycle(); + const stored = getStoredSession(); + + elapse(IDLE_TIMEOUT + 1); + reload(); + + expect(SentryCore.startSession).toHaveBeenCalledWith({ ignoreDuration: true }); + expect(getStoredSession()?.sid).not.toBe(stored?.sid); + }); + + it('starts a new session when the persisted one ran past its max duration', () => { + // Shortened so the test does not have to simulate eight hours of activity. + const shortLived = { idleTimeout: 10_000, maxDuration: 30_000 }; + setupSessionLifecycle(shortLived); + const stored = getStoredSession(); + + // Interacting throughout keeps it from idling out, so only the max duration can expire it. + elapse(9_000); + interact(); + elapse(9_000); + interact(); + elapse(9_000); + interact(); + elapse(9_000); + + reload(shortLived); + + expect(SentryCore.startSession).toHaveBeenCalledWith({ ignoreDuration: true }); + expect(getStoredSession()?.sid).not.toBe(stored?.sid); + }); + + it('keeps the session alive while the user is active', () => { + setupSessionLifecycle(); + const stored = getStoredSession(); + + elapse(IDLE_TIMEOUT - 1); + interact(); + elapse(IDLE_TIMEOUT - 1); + interact(); + + expect(SentryCore.startSession).toHaveBeenCalledTimes(1); + expect(getStoredSession()?.sid).toBe(stored?.sid); + }); + + it('rotates the session when the user returns after idling out', () => { + setupSessionLifecycle(); + const stored = getStoredSession(); + vi.runAllTimers(); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + + elapse(IDLE_TIMEOUT + 1); + interact(); + + expect(SentryCore.startSession).toHaveBeenCalledTimes(2); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); + expect(getStoredSession()?.sid).not.toBe(stored?.sid); + }); + + it('treats navigation as activity', () => { + setupSessionLifecycle(); + const stored = getStoredSession(); + + elapse(IDLE_TIMEOUT - 1); + navigate('/initial', '/next'); + elapse(IDLE_TIMEOUT - 1); + navigate('/next', '/last'); + + expect(SentryCore.startSession).toHaveBeenCalledTimes(1); + expect(getStoredSession()?.sid).toBe(stored?.sid); + }); + + it('replaces the persisted session when the current one is ended manually', () => { + setupSessionLifecycle(); + const ended = getStoredSession(); + + elapse(60_000); + endSession(); + + const started = getStoredSession(); + expect(SentryCore.startSession).toHaveBeenLastCalledWith({ ignoreDuration: true }); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + expect(started).toEqual({ + sid: expect.not.stringMatching(ended?.sid as string), + started: Date.now(), + lastActivity: Date.now(), + }); + + // The ended session must not come back when the page reloads. + reload(); + expect(SentryCore.startSession).toHaveBeenCalledWith({ + sid: started?.sid, + started: (started as PersistedSession).started / 1000, + init: false, + ignoreDuration: true, + }); + }); + + it.each([{ lifecycle: 'page' } as const, { lifecycle: 'route' } as const, undefined])( + 'does not persist or resume anything with options %o', + options => { + setupBrowserSession(options); + + expect(SentryCore.startSession).toHaveBeenCalledWith({ ignoreDuration: true }); + expect(getStoredSession()).toBeUndefined(); + + navigate('/initial', '/next'); + interact(); + + expect(getStoredSession()).toBeUndefined(); + }, + ); + }); }); diff --git a/packages/browser/test/session/lifecycle.test.ts b/packages/browser/test/session/lifecycle.test.ts new file mode 100644 index 000000000000..c2c97bac4cdd --- /dev/null +++ b/packages/browser/test/session/lifecycle.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment jsdom + */ + +import type * as SentryCore from '@sentry/core/browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as SessionLifecycle from '../../src/session/lifecycle'; + +const coreEndSession = vi.hoisted(() => vi.fn()); + +vi.mock('@sentry/core/browser', async importActual => ({ + ...((await importActual()) as typeof SentryCore), + endSession: coreEndSession, +})); + +describe('endSession', () => { + let lifecycle: typeof SessionLifecycle; + + beforeEach(async () => { + vi.clearAllMocks(); + // The registered rotator is module state, so every test needs its own copy of the module. + vi.resetModules(); + lifecycle = await import('../../src/session/lifecycle'); + }); + + it('hands off to the rotator registered by the session lifecycle', () => { + const rotate = vi.fn(); + lifecycle.setSessionRotator(rotate); + + lifecycle.endSession(); + + expect(rotate).toHaveBeenCalledTimes(1); + expect(coreEndSession).not.toHaveBeenCalled(); + }); + + it('closes the session on the scope when no lifecycle is managing sessions', () => { + lifecycle.endSession(); + + expect(coreEndSession).toHaveBeenCalledTimes(1); + }); +});