From e019b4829ea01b67d55065ef016656841f05da13 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:55:53 +0200 Subject: [PATCH] feat(core): Add `client.setSessionTrackingEnabled()` to toggle session tracking at runtime --- CHANGELOG.md | 17 +++ .../replay/session-tracking-disabled/init.js | 16 +++ .../session-tracking-disabled/subject.js | 8 ++ .../session-tracking-disabled/template.html | 10 ++ .../replay/session-tracking-disabled/test.ts | 36 +++++ .../suites/sessions/disabled-on-load/init.js | 11 ++ .../sessions/disabled-on-load/subject.js | 13 ++ .../sessions/disabled-on-load/template.html | 11 ++ .../suites/sessions/disabled-on-load/test.ts | 65 +++++++++ .../sessions/enabled-toggle-disabled/init.js | 8 ++ .../enabled-toggle-disabled/subject.js | 17 +++ .../enabled-toggle-disabled/template.html | 12 ++ .../sessions/enabled-toggle-disabled/test.ts | 63 +++++++++ .../src/integrations/browsersession.ts | 15 ++- .../test/integrations/browsersession.test.ts | 114 ++++++++++++++++ packages/core/src/client.ts | 41 +++++- packages/core/test/lib/client.test.ts | 124 ++++++++++++++++++ 17 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/template.html create mode 100644 dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/init.js create mode 100644 dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/template.html create mode 100644 dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/init.js create mode 100644 dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/template.html create mode 100644 dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d8fcde58b1..c4aa6c87daf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! +- **feat(core): Add `client.setSessionTrackingEnabled()` to toggle session tracking at runtime ([#22369](https://github.com/getsentry/sentry-javascript/pull/22369))** + + You can now turn session tracking on and off at runtime via `client.setSessionTrackingEnabled(enabled)`. + This is useful for consent-gated (e.g. GDPR) setups where you want to stop capturing sessions after a user withdraws consent, without re-initializing the SDK. + + While disabled, no session envelopes are sent. The existing in-memory session is retained, so re-enabling resumes it on its next normal capture rather than flushing immediately. + + ```js + const client = Sentry.getClient(); + + // Stop capturing and sending sessions (e.g. after consent is withdrawn) + client.setSessionTrackingEnabled(false); + + // Resume session tracking later + client.setSessionTrackingEnabled(true); + ``` + - feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) ## 10.66.0 diff --git a/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/init.js b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/init.js new file mode 100644 index 000000000000..b88558522cb9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '0.1', + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/subject.js b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/subject.js new file mode 100644 index 000000000000..08303d0fae43 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/subject.js @@ -0,0 +1,8 @@ +document.getElementById('disable-session-tracking').addEventListener('click', () => { + Sentry.getClient().setSessionTrackingEnabled(false); +}); + +document.getElementById('click-me').addEventListener('click', () => { + // Produces a Replay breadcrumb event, triggering a flush + console.log('clicked'); +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/template.html b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/template.html new file mode 100644 index 000000000000..f42a2d8a502a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/test.ts b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/test.ts new file mode 100644 index 000000000000..63984375c3d7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/session-tracking-disabled/test.ts @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; + +sentryTest( + 'Replay still records and sends a segment when session tracking is disabled', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + await page.locator('#disable-session-tracking').click(); + + // Interact with the page to ensure Replay records something and flushes. + const reqPromise1 = waitForReplayRequest(page, 1); + await page.locator('#click-me').click(); + + const [replayEvent0, replayEvent1] = await Promise.all([ + reqPromise0.then(getReplayEvent), + reqPromise1.then(getReplayEvent), + ]); + + expect(replayEvent0).toBeDefined(); + expect(replayEvent0.replay_type).toBe('session'); + expect(replayEvent0.segment_id).toBe(0); + + expect(replayEvent1).toBeDefined(); + expect(replayEvent1.replay_type).toBe('session'); + expect(replayEvent1.segment_id).toBe(1); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/init.js b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/init.js new file mode 100644 index 000000000000..f0480ae1b233 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '0.1', +}); + +// Disable right at the start +Sentry.getClient().setSessionTrackingEnabled(false); diff --git a/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/subject.js b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/subject.js new file mode 100644 index 000000000000..2ae96837a6b1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/subject.js @@ -0,0 +1,13 @@ +document.getElementById('throw-error').addEventListener('click', () => { + throw new Error('Test error'); +}); + +document.getElementById('enable').addEventListener('click', () => { + Sentry.getClient().setSessionTrackingEnabled(true); +}); + +let clickCount = 0; +document.getElementById('navigate').addEventListener('click', () => { + clickCount++; + history.pushState({}, '', `/page-${clickCount}`); +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/template.html b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/template.html new file mode 100644 index 000000000000..8976bdaafecb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/template.html @@ -0,0 +1,11 @@ + + + + + + + + + Navigate + + diff --git a/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/test.ts b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/test.ts new file mode 100644 index 000000000000..918e783541aa --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/disabled-on-load/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import type { SerializedSession } from '@sentry/core/src'; +import { sentryTest } from '../../../utils/fixtures'; +import { + envelopeRequestParser, + getMultipleSentryEnvelopeRequests, + waitForErrorRequest, + waitForSession, +} from '../../../utils/helpers'; + +sentryTest( + 'no initial session envelope when tracking is disabled before the deferred capture fires', + async ({ getLocalTestUrl, page }) => { + // Collect all session envelopes for 7 seconds then assert none arrived. + const sessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + envelopeType: 'session', + timeout: 7000, + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const sessions = await sessionsPromise; + expect(sessions.filter(s => s.init)).toHaveLength(0); + }, +); + +sentryTest('error telemetry is still captured when session tracking is disabled', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const errorPromise = waitForErrorRequest(page); + await page.locator('#throw-error').click(); + const errorReq = await errorPromise; + + expect(envelopeRequestParser(errorReq)).toEqual( + expect.objectContaining({ + exception: expect.objectContaining({ + values: expect.arrayContaining([expect.objectContaining({ value: 'Test error' })]), + }), + }), + ); +}); + +sentryTest( + 'session capture starts once tracking is enabled after being disabled on load', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + // Re-enable, then trigger a navigation which should now send a session. + const sessionPromise = waitForSession(page, s => s.init && s.status === 'ok'); + await page.locator('#enable').click(); + await page.locator('#navigate').click(); + + const session = await sessionPromise; + expect(session).toEqual( + expect.objectContaining({ + init: true, + status: 'ok', + errors: 0, + }), + ); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/init.js b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/init.js new file mode 100644 index 000000000000..af2df91a7ceb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '0.1', +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/subject.js b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/subject.js new file mode 100644 index 000000000000..31baf799877b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/subject.js @@ -0,0 +1,17 @@ +document.getElementById('disable').addEventListener('click', () => { + Sentry.getClient().setSessionTrackingEnabled(false); +}); + +document.getElementById('enable').addEventListener('click', () => { + Sentry.getClient().setSessionTrackingEnabled(true); +}); + +let clickCount = 0; +document.getElementById('navigate').addEventListener('click', () => { + clickCount++; + history.pushState({}, '', `/page-${clickCount}`); +}); + +document.getElementById('set-user').addEventListener('click', () => { + Sentry.setUser({ id: '1337' }); +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/template.html b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/template.html new file mode 100644 index 000000000000..cb2c3dd9945e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/template.html @@ -0,0 +1,12 @@ + + + + + + + + + Navigate + + + diff --git a/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/test.ts b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/test.ts new file mode 100644 index 000000000000..2ee8d66524d1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/enabled-toggle-disabled/test.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import type { SerializedSession } from '@sentry/core/src'; +import { sentryTest } from '../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, waitForSession } from '../../../utils/helpers'; + +sentryTest('no session envelope on navigation when tracking is disabled', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const initialSessionPromise = waitForSession(page, s => s.init && s.status === 'ok'); + await page.goto(url); + await initialSessionPromise; + + // Collect further sessions for 3 seconds — expect none to arrive. + const furtherSessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + envelopeType: 'session', + timeout: 3000, + }); + + await page.locator('#disable').click(); + await page.locator('#navigate').click(); + + const furtherSessions = await furtherSessionsPromise; + expect(furtherSessions).toHaveLength(0); +}); + +sentryTest('no session envelope on user change when tracking is disabled', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const initialSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + await page.goto(url); + await initialSessionPromise; + + const furtherSessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + envelopeType: 'session', + timeout: 3000, + }); + + await page.locator('#disable').click(); + await page.locator('#set-user').click(); + + const furtherSessions = await furtherSessionsPromise; + expect(furtherSessions).toHaveLength(0); +}); + +sentryTest('session capture resumes after re-enabling session tracking', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const initialSessionPromise = waitForSession(page, s => s.init && s.status === 'ok'); + await page.goto(url); + const initialSession = await initialSessionPromise; + + // Disable and navigate: no new session expected + await page.locator('#disable').click(); + await page.locator('#navigate').click(); + + // Re-enable and navigate again: a new session should now be sent + const resumedSessionPromise = waitForSession(page, s => s.init && s.status === 'ok' && s.sid !== initialSession.sid); + await page.locator('#enable').click(); + await page.locator('#navigate').click(); + + const resumedSession = await resumedSessionPromise; + expect(resumedSession.sid).not.toBe(initialSession.sid); +}); diff --git a/packages/browser/src/integrations/browsersession.ts b/packages/browser/src/integrations/browsersession.ts index c386ef1fcb00..f33af3a86538 100644 --- a/packages/browser/src/integrations/browsersession.ts +++ b/packages/browser/src/integrations/browsersession.ts @@ -27,8 +27,17 @@ interface BrowserSessionOptions { export const browserSessionIntegration = defineIntegration((options: BrowserSessionOptions = {}) => { const lifecycle = options.lifecycle ?? 'route'; + // Tracks whether session lifecycle callbacks are allowed to fire. + // Mirrors the client's _sessionTrackingEnabled flag, updated via the 'sessionTrackingEnabledChange' hook. + let sessionTrackingEnabled = true; + return { name: 'BrowserSession' as const, + setup(client) { + client.on('sessionTrackingEnabledChange', (enabled: boolean) => { + sessionTrackingEnabled = enabled; + }); + }, setupOnce() { if (typeof WINDOW.document === 'undefined') { DEBUG_BUILD && @@ -52,7 +61,7 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess // 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 // re-capturing here would send it a second time - guard against that. - if (!initialSessionSent) { + if (!initialSessionSent && sessionTrackingEnabled) { captureSession(); initialSessionSent = true; } @@ -79,7 +88,7 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess // reflected in that session (the scope writes it onto the session), so capturing // here would send a redundant envelope - and do so during page load, which is // exactly the overhead we're deferring away from. - if (initialSessionSent) { + if (initialSessionSent && sessionTrackingEnabled) { captureSession(); } } @@ -89,7 +98,7 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess // We want to create a session for every navigation as well addHistoryInstrumentationHandler(({ from, to }) => { // Don't create an additional session for the initial route or if the location did not change - if (from !== to) { + if (from !== to && sessionTrackingEnabled) { startSession({ ignoreDuration: true }); captureSession(); // A session has now been sent, so the deferred initial capture (if still pending) diff --git a/packages/browser/test/integrations/browsersession.test.ts b/packages/browser/test/integrations/browsersession.test.ts index 7d42fb7e3715..c45f86257e8f 100644 --- a/packages/browser/test/integrations/browsersession.test.ts +++ b/packages/browser/test/integrations/browsersession.test.ts @@ -67,11 +67,43 @@ function setVisibilityState(state: DocumentVisibilityState): void { Object.defineProperty(document, 'visibilityState', { value: state, configurable: true }); } +type SessionTrackingChangeCallback = (enabled: boolean) => void; + +/** + * Minimal client stand-in that supports the sessionTrackingEnabledChange hook. + * The integration's setup(client) subscribes to this hook. + */ +function createFakeClient(): { + on: (hook: 'sessionTrackingEnabledChange', cb: SessionTrackingChangeCallback) => () => void; + emit: (hook: 'sessionTrackingEnabledChange', enabled: boolean) => void; +} { + const listeners = new Set(); + return { + on(_hook, cb) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + emit(_hook, enabled) { + listeners.forEach(cb => cb(enabled)); + }, + }; +} + function setupBrowserSession(options?: Parameters[0]): void { const integration = browserSessionIntegration(options); integration.setupOnce?.(); } +function setupBrowserSessionWithClient( + options?: Parameters[0], +): ReturnType { + const integration = browserSessionIntegration(options); + const client = createFakeClient(); + integration.setup?.(client as Parameters>[0]); + integration.setupOnce?.(); + return client; +} + describe('browserSessionIntegration', () => { beforeEach(() => { vi.clearAllMocks(); @@ -187,4 +219,86 @@ describe('browserSessionIntegration', () => { scopeHolder.current.setUser({ id: '1337', email: 'b@example.com' }); expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); }); + + describe('setSessionTrackingEnabled / sessionTrackingEnabledChange hook', () => { + it('suppresses the deferred initial capture while disabled', () => { + const client = setupBrowserSessionWithClient({ lifecycle: 'page' }); + + // Disable before the idle timer fires + client.emit('sessionTrackingEnabledChange', false); + + vi.runAllTimers(); + + expect(SentryCore.captureSession).not.toHaveBeenCalled(); + }); + + it('suppresses the page-hide flush while disabled', () => { + const client = setupBrowserSessionWithClient({ lifecycle: 'page' }); + + client.emit('sessionTrackingEnabledChange', false); + window.dispatchEvent(new Event('pagehide')); + + expect(SentryCore.captureSession).not.toHaveBeenCalled(); + }); + + it('suppresses user-change capture while disabled (user changes after initial send)', () => { + const client = setupBrowserSessionWithClient({ lifecycle: 'page' }); + vi.runAllTimers(); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + + client.emit('sessionTrackingEnabledChange', false); + scopeHolder.current.setUser({ id: 'new-user' }); + + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + }); + + it('suppresses navigation session creation and capture while disabled', () => { + const client = setupBrowserSessionWithClient(); + + client.emit('sessionTrackingEnabledChange', false); + navigate('/a', '/b'); + + expect(SentryCore.startSession).toHaveBeenCalledTimes(1); // only the initial one + expect(SentryCore.captureSession).not.toHaveBeenCalled(); + }); + + it('resumes deferred capture on re-enable when idle fires after re-enable', () => { + const client = setupBrowserSessionWithClient({ lifecycle: 'page' }); + + client.emit('sessionTrackingEnabledChange', false); + // Re-enable before the idle timer fires; the deferred callback must now send + client.emit('sessionTrackingEnabledChange', true); + + vi.runAllTimers(); + + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + }); + + it('resumes user-change captures after re-enable', () => { + const client = setupBrowserSessionWithClient({ lifecycle: 'page' }); + vi.runAllTimers(); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + + client.emit('sessionTrackingEnabledChange', false); + scopeHolder.current.setUser({ id: 'user-while-disabled' }); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + + client.emit('sessionTrackingEnabledChange', true); + scopeHolder.current.setUser({ id: 'user-after-reenable' }); + expect(SentryCore.captureSession).toHaveBeenCalledTimes(2); + }); + + it('resumes navigation captures after re-enable', () => { + const client = setupBrowserSessionWithClient(); + + client.emit('sessionTrackingEnabledChange', false); + navigate('/a', '/b'); + expect(SentryCore.captureSession).not.toHaveBeenCalled(); + + client.emit('sessionTrackingEnabledChange', true); + navigate('/b', '/c'); + expect(SentryCore.startSession).toHaveBeenCalledTimes(2); // initial + /b->c navigation + expect(SentryCore.captureSession).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 87b62479d18e..b6ca363a2136 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -220,6 +220,8 @@ export abstract class Client { protected readonly _dataCollection: ResolvedDataCollection; + protected _sessionTrackingEnabled: boolean; + /** * Initializes this client instance. * @@ -232,6 +234,7 @@ export abstract class Client { this._outcomes = {}; this._hooks = {}; this._eventProcessors = []; + this._sessionTrackingEnabled = true; this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE); this._dataCollection = resolveDataCollectionOptions(options); @@ -377,11 +380,29 @@ export abstract class Client { * Captures a session. */ public captureSession(session: Session): void { + if (!this._sessionTrackingEnabled) { + return; + } this.sendSession(session); // After sending, we set init false to indicate it's not the first occurrence updateSession(session, { init: false }); } + /** + * Enable or disable automatic session tracking and session envelope sending. + * + * When set to `false`, no session envelopes will be sent and `browserSessionIntegration` + * will pause all automatic session lifecycle work. Existing in-memory sessions are + * retained but not mutated by error events or sent. + * + * When set back to `true`, the existing session resumes and will be sent on its + * next normal lifecycle trigger. + */ + public setSessionTrackingEnabled(enabled: boolean): void { + this._sessionTrackingEnabled = enabled; + this.emit('sessionTrackingEnabledChange', enabled); + } + /** * Create a cron monitor check in and send it to Sentry. This method is not available on all clients. * @@ -572,6 +593,10 @@ export abstract class Client { * Send a session or session aggregrates to Sentry. */ public sendSession(session: Session | SessionAggregates): void { + if (!this._sessionTrackingEnabled) { + return; + } + // Backfill release and environment on session const { release: clientReleaseOption, environment: clientEnvironmentOption = DEFAULT_ENVIRONMENT } = this._options; if ('aggregates' in session) { @@ -718,6 +743,14 @@ export abstract class Client { */ public on(hook: 'beforeSendSession', callback: (session: Session | SessionAggregates) => void): () => void; + /** + * Register a callback that is called whenever session tracking is enabled or disabled via `client.setSessionTrackingEnabled()`. + * + * @param callback A frunction that receives the new enabled state as argument. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'sessionTrackingEnabledChange', callback: (enabled: boolean) => void): () => void; + /** * Register a callback for preprocessing an event, * before it is passed to (global) event processors. @@ -1024,6 +1057,12 @@ export abstract class Client { */ public emit(hook: 'beforeSendSession', session: Session | SessionAggregates): void; + /** + * Fire a hook event when session tracking enabled state changes. + * Expects to be given the new enabled state as second argument. + */ + public emit(hook: 'sessionTrackingEnabledChange', enabled: boolean): void; + /** * Fire a hook event to process events before they are passed to (global) event processors. * Expects to be given an Event & EventHint as the second/third argument. @@ -1477,7 +1516,7 @@ export abstract class Client { } const session = currentScope.getSession() || isolationScope.getSession(); - if (isError && session) { + if (isError && session && this._sessionTrackingEnabled) { this._updateSessionFromEvent(session, processedEvent); } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index e37c32dea309..0ebf31e2812e 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import type { SeverityLevel } from '../../src'; import { addBreadcrumb, + Client, dsnToString, getCurrentScope, getIsolationScope, @@ -2431,6 +2432,129 @@ describe('Client', () => { }); }); + describe('setSessionTrackingEnabled()', () => { + test('is enabled by default — captureSession sends normally', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession({ release: 'test' }); + + client.captureSession(session); + + expect(client.session).toEqual(session); + }); + + test('disabling prevents captureSession from sending', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession({ release: 'test' }); + + client.setSessionTrackingEnabled(false); + client.captureSession(session); + + expect(client.session).toBeUndefined(); + }); + + test('disabling prevents captureSession from mutating session.init to false', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession({ release: 'test' }); + + expect(session.init).toBe(true); + client.setSessionTrackingEnabled(false); + client.captureSession(session); + + // init must remain true because we never completed the send + expect(session.init).toBe(true); + }); + + test('disabling prevents sendSession from sending envelopes', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession({ release: 'test' }); + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + client.setSessionTrackingEnabled(false); + // Call base-class sendSession directly because TestClient overrides it for session capture tracking + Client.prototype.sendSession.call(client, session); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + + test('re-enabling allows captureSession to send again', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession({ release: 'test' }); + + client.setSessionTrackingEnabled(false); + client.captureSession(session); + expect(client.session).toBeUndefined(); + + client.setSessionTrackingEnabled(true); + client.captureSession(session); + expect(client.session).toEqual(session); + }); + + test('idempotent: calling disable twice still blocks sends', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession({ release: 'test' }); + + client.setSessionTrackingEnabled(false); + client.setSessionTrackingEnabled(false); + client.captureSession(session); + + expect(client.session).toBeUndefined(); + }); + + test('idempotent: calling enable twice still allows sends', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession({ release: 'test' }); + + client.setSessionTrackingEnabled(false); + client.setSessionTrackingEnabled(true); + client.setSessionTrackingEnabled(true); + client.captureSession(session); + + expect(client.session).toEqual(session); + }); + + test('emits sessionTrackingEnabledChange hook with new state', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const hook = vi.fn(); + + client.on('sessionTrackingEnabledChange', hook); + + client.setSessionTrackingEnabled(false); + expect(hook).toHaveBeenCalledTimes(1); + expect(hook).toHaveBeenLastCalledWith(false); + + client.setSessionTrackingEnabled(true); + expect(hook).toHaveBeenCalledTimes(2); + expect(hook).toHaveBeenLastCalledWith(true); + }); + + test('disabling prevents _updateSessionFromEvent from mutating session on error', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession(); + getCurrentScope().setSession(session); + + client.setSessionTrackingEnabled(false); + client.captureEvent({ message: 'test', level: 'fatal' }); + + // Session should be unchanged — no status update, no error count increment + expect(session.status).toBe('ok'); + expect(session.errors).toBe(0); + }); + + test('re-enabling allows _updateSessionFromEvent to run on the next error', () => { + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const session = makeSession(); + getCurrentScope().setSession(session); + + client.setSessionTrackingEnabled(false); + client.captureEvent({ message: 'test', level: 'fatal' }); + expect(session.status).toBe('ok'); + + client.setSessionTrackingEnabled(true); + client.captureEvent({ message: 'test', level: 'fatal' }); + expect(session.status).toBe('crashed'); + }); + }); + describe('_updateSessionFromEvent()', () => { describe('event has no exceptions', () => { it('sets status to crashed if level is fatal', () => {