Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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://[email protected]/1337',
release: '0.1',
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,
integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="disable-session-tracking">Disable session tracking</button>
<button id="click-me">Click me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '0.1',
});

// Disable right at the start
Sentry.getClient().setSessionTrackingEnabled(false);
Original file line number Diff line number Diff line change
@@ -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}`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="throw-error">Throw error</button>
<button id="enable">Enable session tracking</button>
<a id="navigate" href="#foo">Navigate</a>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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<SerializedSession>(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,
}),
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '0.1',
});
Original file line number Diff line number Diff line change
@@ -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' });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="disable">Disable session tracking</button>
<button id="enable">Enable session tracking</button>
<a id="navigate" href="#foo">Navigate</a>
<button id="set-user">Set user</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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<SerializedSession>(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<SerializedSession>(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);
});
15 changes: 12 additions & 3 deletions packages/browser/src/integrations/browsersession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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;
}
Expand All @@ -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();
}
}
Expand All @@ -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)
Expand Down
Loading
Loading