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', () => {