Skip to content

Commit 8b154cd

Browse files
committed
feat(browser): Restart the session when endSession is called
`endSession` is now exported from `@sentry/browser` rather than re-exported from core. Calling it ends the current session and immediately starts a new one, so telemetry emitted after the call is attributed to the new session instead of to nothing. Expiry, navigation in the `route` lifecycle, and `endSession` all go through the same rotate step, which under the `session` lifecycle also replaces the `sessionStorage` record so the ended session is not resumed on the next page load.
1 parent 21f8c94 commit 8b154cd

5 files changed

Lines changed: 142 additions & 12 deletions

File tree

‎packages/browser/src/exports.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export {
6565
parameterize,
6666
startSession,
6767
captureSession,
68-
endSession,
6968
spanToStaticSpanJSON,
7069
spanToJSON,
7170
spanToTraceHeader,
@@ -84,6 +83,8 @@ export {
8483
} from '@sentry/core/browser';
8584

8685
export { WINDOW } from './helpers';
86+
// Shadows core's `endSession`: in the browser, ending a session also starts the next one.
87+
export { endSession } from './session/lifecycle';
8788
export { BrowserClient } from './client';
8889
export { makeFetchTransport } from './transports/fetch';
8990
export { uiProfiler } from './profiling';

‎packages/browser/src/integrations/browsersession.ts‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@sentry/browser-utils';
1414
import { DEBUG_BUILD } from '../debug-build';
1515
import { WINDOW } from '../helpers';
16+
import { setSessionRotator } from '../session/lifecycle';
1617
import type { PersistedSession, SessionExpiryOptions } from '../session/persistence';
1718
import { getPersistedSession, isSessionExpired, persistSession } from '../session/persistence';
1819

@@ -114,6 +115,10 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess
114115
// (page-view-like) sessions.
115116
let initialSessionSent = false;
116117

118+
// Ends the current session and starts a new one in its place, sending it right away. Expiry,
119+
// navigation (in the `'route'` lifecycle) and the public `endSession()` all funnel through here.
120+
let rotate: () => void;
121+
117122
if (lifecycle === 'session') {
118123
const persisted = getPersistedSession();
119124
const now = Date.now();
@@ -122,16 +127,20 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess
122127
);
123128
let lastPersistedAt = current.lastActivity;
124129

130+
rotate = () => {
131+
current = startAndPersistSession();
132+
lastPersistedAt = current.lastActivity;
133+
captureSession();
134+
// A session has now been sent, so the deferred initial capture (if still pending)
135+
// must not re-send this session.
136+
initialSessionSent = true;
137+
};
138+
125139
const onActivity = (): void => {
126140
const activityAt = Date.now();
127141

128142
if (isSessionExpired(current, expiry, activityAt)) {
129-
current = startAndPersistSession();
130-
lastPersistedAt = current.lastActivity;
131-
captureSession();
132-
// A session has now been sent, so the deferred initial capture (if still pending)
133-
// must not re-send this session.
134-
initialSessionSent = true;
143+
rotate();
135144
return;
136145
}
137146

@@ -154,8 +163,16 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess
154163
// Automatically captured sessions are akin to page views, and thus we
155164
// discard their duration.
156165
startSession({ ignoreDuration: true });
166+
167+
rotate = () => {
168+
startSession({ ignoreDuration: true });
169+
captureSession();
170+
initialSessionSent = true;
171+
};
157172
}
158173

174+
setSessionRotator(rotate);
175+
159176
whenIdleOrHidden(() => {
160177
// A navigation (in `'route'` lifecycle) may start and send a new session before this
161178
// deferred callback fires. In that case the current session was already sent, so
@@ -198,11 +215,7 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess
198215
addHistoryInstrumentationHandler(({ from, to }) => {
199216
// Don't create an additional session for the initial route or if the location did not change
200217
if (from !== to) {
201-
startSession({ ignoreDuration: true });
202-
captureSession();
203-
// A session has now been sent, so the deferred initial capture (if still pending)
204-
// must not re-send this navigation session.
205-
initialSessionSent = true;
218+
rotate();
206219
}
207220
});
208221
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { endSession as endSessionOnScope } from '@sentry/core/browser';
2+
3+
/**
4+
* How the session lifecycle currently in effect replaces its session, registered by
5+
* `browserSessionIntegration`. Rotating is lifecycle-specific (the persisted `'session'` lifecycle
6+
* has a `sessionStorage` record to keep in sync, the others do not), so the integration owns the
7+
* implementation and the public API below only triggers it.
8+
*/
9+
let rotateSession: (() => void) | undefined;
10+
11+
/**
12+
* Registers @param rotate as the way to end the current session and start a new one in its place.
13+
*/
14+
export function setSessionRotator(rotate: () => void): void {
15+
rotateSession = rotate;
16+
}
17+
18+
/**
19+
* Ends the current session and immediately starts a new one. Everything the page reports from here
20+
* on belongs to the new session.
21+
*
22+
* There is no gap between the two: a browser tab never stops producing telemetry, so a session that
23+
* ended without a successor would leave whatever comes next unattributed.
24+
*
25+
* Requires `browserSessionIntegration` (enabled by default). Without it there is no session the SDK
26+
* manages, so this only closes a session that was started manually.
27+
*/
28+
export function endSession(): void {
29+
if (rotateSession) {
30+
rotateSession();
31+
return;
32+
}
33+
34+
endSessionOnScope();
35+
}

‎packages/browser/test/integrations/browsersession.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Scope, SessionContext, User } from '@sentry/core/browser';
77
import * as SentryCore from '@sentry/core/browser';
88
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
99
import { browserSessionIntegration } from '../../src/integrations/browsersession';
10+
import { endSession } from '../../src/session/lifecycle';
1011
import type { PersistedSession } from '../../src/session/persistence';
1112
import { SESSION_STORAGE_KEY } from '../../src/session/persistence';
1213

@@ -211,6 +212,19 @@ describe('browserSessionIntegration', () => {
211212
expect(SentryCore.captureSession).toHaveBeenCalledTimes(2);
212213
});
213214

215+
it('starts and sends a new session when the current one is ended manually', () => {
216+
setupBrowserSession({ lifecycle: 'page' });
217+
218+
endSession();
219+
220+
expect(SentryCore.startSession).toHaveBeenCalledTimes(2);
221+
expect(SentryCore.captureSession).toHaveBeenCalledTimes(1);
222+
223+
// The new session was already sent, so the deferred initial capture must not send it again.
224+
vi.runAllTimers();
225+
expect(SentryCore.captureSession).toHaveBeenCalledTimes(1);
226+
});
227+
214228
describe('`session` lifecycle', () => {
215229
const IDLE_TIMEOUT = 30 * 60_000;
216230

@@ -330,6 +344,32 @@ describe('browserSessionIntegration', () => {
330344
expect(getStoredSession()?.sid).toBe(stored?.sid);
331345
});
332346

347+
it('replaces the persisted session when the current one is ended manually', () => {
348+
setupSessionLifecycle();
349+
const ended = getStoredSession();
350+
351+
elapse(60_000);
352+
endSession();
353+
354+
const started = getStoredSession();
355+
expect(SentryCore.startSession).toHaveBeenLastCalledWith({ ignoreDuration: true });
356+
expect(SentryCore.captureSession).toHaveBeenCalledTimes(1);
357+
expect(started).toEqual({
358+
sid: expect.not.stringMatching(ended?.sid as string),
359+
started: Date.now(),
360+
lastActivity: Date.now(),
361+
});
362+
363+
// The ended session must not come back when the page reloads.
364+
reload();
365+
expect(SentryCore.startSession).toHaveBeenCalledWith({
366+
sid: started?.sid,
367+
started: (started as PersistedSession).started / 1000,
368+
init: false,
369+
ignoreDuration: true,
370+
});
371+
});
372+
333373
it.each([{ lifecycle: 'page' } as const, { lifecycle: 'route' } as const, undefined])(
334374
'does not persist or resume anything with options %o',
335375
options => {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import type * as SentryCore from '@sentry/core/browser';
6+
import { beforeEach, describe, expect, it, vi } from 'vitest';
7+
import type * as SessionLifecycle from '../../src/session/lifecycle';
8+
9+
const coreEndSession = vi.hoisted(() => vi.fn());
10+
11+
vi.mock('@sentry/core/browser', async importActual => ({
12+
...((await importActual()) as typeof SentryCore),
13+
endSession: coreEndSession,
14+
}));
15+
16+
describe('endSession', () => {
17+
let lifecycle: typeof SessionLifecycle;
18+
19+
beforeEach(async () => {
20+
vi.clearAllMocks();
21+
// The registered rotator is module state, so every test needs its own copy of the module.
22+
vi.resetModules();
23+
lifecycle = await import('../../src/session/lifecycle');
24+
});
25+
26+
it('hands off to the rotator registered by the session lifecycle', () => {
27+
const rotate = vi.fn();
28+
lifecycle.setSessionRotator(rotate);
29+
30+
lifecycle.endSession();
31+
32+
expect(rotate).toHaveBeenCalledTimes(1);
33+
expect(coreEndSession).not.toHaveBeenCalled();
34+
});
35+
36+
it('closes the session on the scope when no lifecycle is managing sessions', () => {
37+
lifecycle.endSession();
38+
39+
expect(coreEndSession).toHaveBeenCalledTimes(1);
40+
});
41+
});

0 commit comments

Comments
 (0)