From de418ae46f9e47b98c075ab08da8b5913072ccd5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Feb 2026 15:13:48 +0100 Subject: [PATCH 1/8] fix(browser): Update session after setting user --- .../suites/sessions/init.js | 1 + .../suites/sessions/update-user/subject.js | 7 ++++ .../suites/sessions/update-user/template.html | 9 +++++ .../suites/sessions/update-user/test.ts | 39 +++++++++++++++++++ .../utils/helpers.ts | 14 ++++--- .../src/integrations/browsersession.ts | 21 +++++++++- 6 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/sessions/update-user/template.html create mode 100644 dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts diff --git a/dev-packages/browser-integration-tests/suites/sessions/init.js b/dev-packages/browser-integration-tests/suites/sessions/init.js index af2df91a7ceb..c95631ad1d72 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/init.js +++ b/dev-packages/browser-integration-tests/suites/sessions/init.js @@ -5,4 +5,5 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '0.1', + debug: true, }); diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js b/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js new file mode 100644 index 000000000000..d7895ff218bc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js @@ -0,0 +1,7 @@ +console.log('xx setting user'); +Sentry.setUser({ + id: '1337', + email: 'user@name.com', + username: 'user1337', +}) +console.log('xx user set'); diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/template.html b/dev-packages/browser-integration-tests/suites/sessions/update-user/template.html new file mode 100644 index 000000000000..77906444cbce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/update-user/template.html @@ -0,0 +1,9 @@ + + + + + + + Navigate + + diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts b/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts new file mode 100644 index 000000000000..5038d7ceed65 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { waitForSession } from '../../../utils/helpers'; + +sentryTest( + 'starts a new session on pageload with user id', + async ({ getLocalTestUrl, page }) => { + const initialSessionPromise = waitForSession(page, (s) => !!s.init); + const updatedSessionPromise = waitForSession(page, (s) => !s.init); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const initialSession = await initialSessionPromise; + const updatedSession = await updatedSessionPromise; + + expect(initialSession).toEqual({ + attrs: { + environment: 'production', + release: '0.1', + user_agent: + expect.any(String), + }, + errors: 0, + init: true, + sid: expect.any(String), + started: expect.any(String), + status: 'ok', + timestamp: expect.any(String), + }); + + expect(updatedSession).toEqual({ + init: false, + errors: 1, + status: 'crashed', + sid: initialSession.sid, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 6cc5188d3c29..ec5d34af572f 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -8,7 +8,7 @@ import type { Event as SentryEvent, EventEnvelope, EventEnvelopeHeaders, - SessionContext, + SerializedSession, TransactionEvent, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; @@ -283,7 +283,7 @@ export function waitForClientReportRequest(page: Page, callback?: (report: Clien }); } -export async function waitForSession(page: Page): Promise { +export async function waitForSession(page: Page, callback?: (session: SerializedSession) => boolean): Promise { const req = await page.waitForRequest(req => { const postData = req.postData(); if (!postData) { @@ -291,15 +291,19 @@ export async function waitForSession(page: Page): Promise { } try { - const event = envelopeRequestParser(req); + const event = envelopeRequestParser(req); - return typeof event.init === 'boolean' && event.started !== undefined; + if (callback) { + return callback(event); + } + + return true; } catch { return false; } }); - return envelopeRequestParser(req); + return envelopeRequestParser(req); } /** diff --git a/packages/browser/src/integrations/browsersession.ts b/packages/browser/src/integrations/browsersession.ts index a0eb63034b9f..02017e45234f 100644 --- a/packages/browser/src/integrations/browsersession.ts +++ b/packages/browser/src/integrations/browsersession.ts @@ -1,4 +1,4 @@ -import { captureSession, debug, defineIntegration, startSession } from '@sentry/core'; +import { captureSession, debug, defineIntegration, getIsolationScope, startSession } from '@sentry/core'; import { addHistoryInstrumentationHandler } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; @@ -43,6 +43,25 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess startSession({ ignoreDuration: true }); captureSession(); + // User data can be set at any time, for example async after Sentry.init has run and the initial session + // envelope was already sent, but still on the initial page. + // Therefore, we have to update the ongoing session with the new user data if it exists, to send the `did`. + // In theory, sessions, as well as user data is always put onto the isolation scope. So we can listen to the + // isolation scope for changes and update the session with the new user data if it exists. + // This will not catch users set onto other scopes, like the current scope. For now, we'll accept this limitation. + // The alternative is to update and capture the session from within the scope, which might be too costly on the + // server side. Given this happens in the scope, we'd need change scope behaviour in the browser, which is out + // of scope for now. + const isolationScope = getIsolationScope(); + let previousUser = isolationScope.getUser(); + getIsolationScope().addScopeListener(scope => { + const maybeNewUser = scope.getUser(); + if (previousUser?.id !== maybeNewUser?.id || previousUser?.ip_address !== maybeNewUser?.ip_address) { + captureSession(); + previousUser = maybeNewUser; + } + }) + if (lifecycle === 'route') { // We want to create a session for every navigation as well addHistoryInstrumentationHandler(({ from, to }) => { From 5706a8324e89ee3ce9f1b25fc8eb2397f875b588 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Feb 2026 15:29:54 +0100 Subject: [PATCH 2/8] done, cleanup --- .../suites/sessions/init.js | 1 - .../suites/sessions/update-user/subject.js | 4 +- .../suites/sessions/update-user/test.ts | 74 ++++++++++--------- .../utils/generatePlugin.ts | 1 + .../utils/helpers.ts | 5 +- .../src/integrations/browsersession.ts | 11 +-- 6 files changed, 51 insertions(+), 45 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/sessions/init.js b/dev-packages/browser-integration-tests/suites/sessions/init.js index c95631ad1d72..af2df91a7ceb 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/init.js +++ b/dev-packages/browser-integration-tests/suites/sessions/init.js @@ -5,5 +5,4 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '0.1', - debug: true, }); diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js b/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js index d7895ff218bc..16d9653f0b7b 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js +++ b/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js @@ -1,7 +1,5 @@ -console.log('xx setting user'); Sentry.setUser({ id: '1337', email: 'user@name.com', username: 'user1337', -}) -console.log('xx user set'); +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts b/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts index 5038d7ceed65..4b262d360fbd 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts @@ -2,38 +2,42 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; import { waitForSession } from '../../../utils/helpers'; -sentryTest( - 'starts a new session on pageload with user id', - async ({ getLocalTestUrl, page }) => { - const initialSessionPromise = waitForSession(page, (s) => !!s.init); - const updatedSessionPromise = waitForSession(page, (s) => !s.init); - - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const initialSession = await initialSessionPromise; - const updatedSession = await updatedSessionPromise; - - expect(initialSession).toEqual({ - attrs: { - environment: 'production', - release: '0.1', - user_agent: - expect.any(String), - }, - errors: 0, - init: true, - sid: expect.any(String), - started: expect.any(String), - status: 'ok', - timestamp: expect.any(String), - }); - - expect(updatedSession).toEqual({ - init: false, - errors: 1, - status: 'crashed', - sid: initialSession.sid, - }); - }, -); +sentryTest('starts a new session on pageload with user id', async ({ getLocalTestUrl, page }) => { + const initialSessionPromise = waitForSession(page, s => !!s.init); + const updatedSessionPromise = waitForSession(page, s => !s.init); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const initialSession = await initialSessionPromise; + const updatedSession = await updatedSessionPromise; + + expect(initialSession).toEqual({ + attrs: { + environment: 'production', + release: '0.1', + user_agent: expect.any(String), + }, + errors: 0, + init: true, + sid: expect.any(String), + started: expect.any(String), + status: 'ok', + timestamp: expect.any(String), + }); + + expect(updatedSession).toEqual({ + ...initialSession, + init: false, + timestamp: expect.any(String), + did: '1337', + }); + + const secondSessionPromise = waitForSession(page, s => !s.init); + + await page.locator('#navigate').click(); + const secondSession = await secondSessionPromise; + + expect(secondSession.sid).not.toBe(initialSession.sid); + expect(secondSession.did).toBe('1337'); +}); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index ae534463fe80..9da79bf15b0b 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -202,6 +202,7 @@ class SentryScenarioGenerationPlugin { factory.hooks.parser.for('javascript/auto').tap(this._name, parser => { parser.hooks.import.tap( this._name, + // @ts-expect-error - not sure why this is failing suddenly??? (statement: { specifiers: [{ imported: { name: string } }] }, source: string) => { const imported = statement.specifiers?.[0]?.imported?.name; diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index ec5d34af572f..86172d945173 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -283,7 +283,10 @@ export function waitForClientReportRequest(page: Page, callback?: (report: Clien }); } -export async function waitForSession(page: Page, callback?: (session: SerializedSession) => boolean): Promise { +export async function waitForSession( + page: Page, + callback?: (session: SerializedSession) => boolean, +): Promise { const req = await page.waitForRequest(req => { const postData = req.postData(); if (!postData) { diff --git a/packages/browser/src/integrations/browsersession.ts b/packages/browser/src/integrations/browsersession.ts index 02017e45234f..2e3b7e2a7e48 100644 --- a/packages/browser/src/integrations/browsersession.ts +++ b/packages/browser/src/integrations/browsersession.ts @@ -46,21 +46,22 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess // User data can be set at any time, for example async after Sentry.init has run and the initial session // envelope was already sent, but still on the initial page. // Therefore, we have to update the ongoing session with the new user data if it exists, to send the `did`. - // In theory, sessions, as well as user data is always put onto the isolation scope. So we can listen to the + // In theory, sessions, as well as user data is always put onto the isolation scope. So we listen to the // isolation scope for changes and update the session with the new user data if it exists. // This will not catch users set onto other scopes, like the current scope. For now, we'll accept this limitation. - // The alternative is to update and capture the session from within the scope, which might be too costly on the - // server side. Given this happens in the scope, we'd need change scope behaviour in the browser, which is out - // of scope for now. + // The alternative is to update and capture the session from within the scope. This could be too costly or would not + // play well with session aggregates on the server side. Since this happens in the scope class, we'd need change + // scope behaviour in the browser. const isolationScope = getIsolationScope(); let previousUser = isolationScope.getUser(); getIsolationScope().addScopeListener(scope => { const maybeNewUser = scope.getUser(); if (previousUser?.id !== maybeNewUser?.id || previousUser?.ip_address !== maybeNewUser?.ip_address) { + // the scope class already writes the user to its session, so we only need to capture the session here captureSession(); previousUser = maybeNewUser; } - }) + }); if (lifecycle === 'route') { // We want to create a session for every navigation as well From 9c487f7f8d1899db5699495043fab2ba3b8e6f02 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Feb 2026 16:59:47 +0100 Subject: [PATCH 3/8] fix more session bugs, adjust tests --- .../sessions/initial-scope/template.html | 2 +- .../suites/sessions/initial-scope/test.ts | 41 +++++---- .../suites/sessions/page-lifecycle/test.ts | 54 ++++++----- .../suites/sessions/route-lifecycle/test.ts | 8 +- .../sessions/start-session/template.html | 2 +- .../suites/sessions/start-session/test.ts | 18 ++-- .../suites/sessions/update-session/test.ts | 16 ++-- .../suites/sessions/update-user/test.ts | 43 --------- .../sessions/{update-user => user}/subject.js | 0 .../{update-user => user}/template.html | 4 +- .../suites/sessions/user/test.ts | 90 +++++++++++++++++++ .../utils/helpers.ts | 2 +- .../astro-5-cf-workers/wrangler.jsonc | 7 +- .../src/integrations/browsersession.ts | 5 +- packages/core/src/exports.ts | 2 +- 15 files changed, 183 insertions(+), 111 deletions(-) delete mode 100644 dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts rename dev-packages/browser-integration-tests/suites/sessions/{update-user => user}/subject.js (100%) rename dev-packages/browser-integration-tests/suites/sessions/{update-user => user}/template.html (55%) create mode 100644 dev-packages/browser-integration-tests/suites/sessions/user/test.ts diff --git a/dev-packages/browser-integration-tests/suites/sessions/initial-scope/template.html b/dev-packages/browser-integration-tests/suites/sessions/initial-scope/template.html index 291d2f23499b..e7c463998c02 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/initial-scope/template.html +++ b/dev-packages/browser-integration-tests/suites/sessions/initial-scope/template.html @@ -4,6 +4,6 @@ - Navigate + Navigate diff --git a/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts b/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts index 781e22c28f40..c7bb2c571f2d 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts @@ -1,31 +1,42 @@ -import type { Route } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { SessionContext } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers'; +import { waitForSession } from '../../../utils/helpers'; -sentryTest('should start a new session on pageload.', async ({ getLocalTestUrl, page }) => { +sentryTest('starts a new session on pageload.', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - const session = await getFirstSentryEnvelopeRequest(page, url); + const sessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + + await page.goto(url); + const session = await sessionPromise; expect(session).toBeDefined(); - expect(session.init).toBe(true); - expect(session.errors).toBe(0); - expect(session.status).toBe('ok'); - expect(session.did).toBe('1337'); + expect(session).toEqual({ + attrs: { + environment: 'production', + release: '0.1', + user_agent: expect.any(String), + }, + did: '1337', + errors: 0, + init: true, + sid: expect.any(String), + started: expect.any(String), + status: 'ok', + timestamp: expect.any(String), + }); }); -sentryTest('should start a new session with navigation.', async ({ getLocalTestUrl, page }) => { +sentryTest('starts a new session with navigation.', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); + const initSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); - // Route must be set up before any navigation to avoid race conditions - await page.route('**/foo', (route: Route) => route.continue({ url })); - - const initSession = await getFirstSentryEnvelopeRequest(page, url); + await page.goto(url); + const initSession = await initSessionPromise; + const newSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); await page.locator('#navigate').click(); - const newSession = await getFirstSentryEnvelopeRequest(page, url); + const newSession = await newSessionPromise; expect(newSession).toBeDefined(); expect(newSession.init).toBe(true); diff --git a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts index 1837393f86cc..f5b58b503dc9 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts @@ -1,45 +1,53 @@ import { expect } from '@playwright/test'; -import type { SessionContext } from '@sentry/core'; +import type { SerializedSession } from '@sentry/core/src'; import { sentryTest } from '../../../utils/fixtures'; -import { getMultipleSentryEnvelopeRequests } from '../../../utils/helpers'; +import { + envelopeRequestParser, + getMultipleSentryEnvelopeRequests, + waitForErrorRequest, + waitForSession, +} from '../../../utils/helpers'; -sentryTest('should start a session on pageload with page lifecycle.', async ({ getLocalTestUrl, page }) => { +sentryTest('starts a session on pageload with page lifecycle.', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - const sessions = await getMultipleSentryEnvelopeRequests(page, 1, { - url, - envelopeType: 'session', - timeout: 2000, - }); + const sessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + await page.goto(url); + const session = await sessionPromise; - expect(sessions.length).toBeGreaterThanOrEqual(1); - const session = sessions[0]; expect(session).toBeDefined(); - expect(session.init).toBe(true); - expect(session.errors).toBe(0); - expect(session.status).toBe('ok'); + expect(session).toEqual({ + attrs: { + environment: 'production', + release: '0.1', + user_agent: expect.any(String), + }, + errors: 0, + init: true, + sid: expect.any(String), + started: expect.any(String), + status: 'ok', + timestamp: expect.any(String), + }); }); sentryTest( - 'should NOT start a new session on pushState navigation with page lifecycle.', + "doesn't start a new session on pushState navigation with page lifecycle.", async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - const sessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + const sessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { url, envelopeType: 'session', timeout: 4000, }); - const manualSessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + const manualSessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { envelopeType: 'session', timeout: 4000, }); - const eventsPromise = getMultipleSentryEnvelopeRequests(page, 10, { - envelopeType: 'event', - timeout: 4000, - }); + const eventsPromise = waitForErrorRequest(page, e => e.message === 'Test error from manual session'); await page.waitForSelector('#navigate'); @@ -56,17 +64,17 @@ sentryTest( await page.locator('#manual-session').click(); const newSessions = (await manualSessionsPromise).filter(session => session.init); - const events = await eventsPromise; + const event = envelopeRequestParser(await eventsPromise); expect(newSessions.length).toBe(2); expect(newSessions[0].init).toBe(true); expect(newSessions[1].init).toBe(true); expect(newSessions[1].sid).not.toBe(newSessions[0].sid); - expect(events).toEqual([ + expect(event).toEqual( expect.objectContaining({ level: 'error', message: 'Test error from manual session', }), - ]); + ); }, ); diff --git a/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/test.ts b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/test.ts index 4f4f0641feeb..1ccee9cd2728 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { SessionContext } from '@sentry/core'; +import type { SerializedSession } from '@sentry/core/src'; import { sentryTest } from '../../../utils/fixtures'; import { getMultipleSentryEnvelopeRequests } from '../../../utils/helpers'; @@ -8,7 +8,7 @@ sentryTest( async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - const sessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + const sessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { url, envelopeType: 'session', timeout: 4000, @@ -20,8 +20,8 @@ sentryTest( await page.locator('#navigate').click(); await page.locator('#navigate').click(); - const sessions = (await sessionsPromise).filter(session => session.init); + const startedSessions = (await sessionsPromise).filter(session => session.init); - expect(sessions.length).toBe(3); + expect(startedSessions.length).toBe(4); }, ); diff --git a/dev-packages/browser-integration-tests/suites/sessions/start-session/template.html b/dev-packages/browser-integration-tests/suites/sessions/start-session/template.html index 291d2f23499b..e7c463998c02 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/start-session/template.html +++ b/dev-packages/browser-integration-tests/suites/sessions/start-session/template.html @@ -4,6 +4,6 @@ - Navigate + Navigate diff --git a/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts b/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts index 063fcb3dbd13..d5a0d3e7837a 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts @@ -1,12 +1,12 @@ -import type { Route } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { SessionContext } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers'; +import { waitForSession } from '../../../utils/helpers'; sentryTest('should start a new session on pageload.', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - const session = await getFirstSentryEnvelopeRequest(page, url); + const sessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + await page.goto(url); + const session = await sessionPromise; expect(session).toBeDefined(); expect(session.init).toBe(true); @@ -16,18 +16,20 @@ sentryTest('should start a new session on pageload.', async ({ getLocalTestUrl, sentryTest('should start a new session with navigation.', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - await page.route('**/foo', (route: Route) => route.continue({ url })); - const initSession = await getFirstSentryEnvelopeRequest(page, url); + const initSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + await page.goto(url); + const initSession = await initSessionPromise; + const newSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); await page.locator('#navigate').click(); - - const newSession = await getFirstSentryEnvelopeRequest(page, url); + const newSession = await newSessionPromise; expect(newSession).toBeDefined(); expect(newSession.init).toBe(true); expect(newSession.errors).toBe(0); expect(newSession.status).toBe('ok'); expect(newSession.sid).toBeDefined(); + expect(initSession.sid).not.toBe(newSession.sid); }); diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-session/test.ts b/dev-packages/browser-integration-tests/suites/sessions/update-session/test.ts index 816cdf1dc056..96c68858b361 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/update-session/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/update-session/test.ts @@ -1,21 +1,22 @@ import { expect } from '@playwright/test'; -import type { SessionContext } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest, waitForSession } from '../../../utils/helpers'; +import { waitForSession } from '../../../utils/helpers'; sentryTest('should update session when an error is thrown.', async ({ getLocalTestUrl, page }) => { + const pageloadSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); const url = await getLocalTestUrl({ testDir: __dirname }); - const pageloadSession = await getFirstSentryEnvelopeRequest(page, url); + await page.goto(url); + const pageloadSession = await pageloadSessionPromise; - const updatedSessionPromise = waitForSession(page); + const updatedSessionPromise = waitForSession(page, s => !s.init); await page.locator('#throw-error').click(); const updatedSession = await updatedSessionPromise; expect(pageloadSession).toBeDefined(); expect(pageloadSession.init).toBe(true); expect(pageloadSession.errors).toBe(0); - expect(updatedSession).toBeDefined(); + expect(updatedSession.init).toBe(false); expect(updatedSession.errors).toBe(1); expect(updatedSession.status).toBe('crashed'); @@ -25,7 +26,9 @@ sentryTest('should update session when an error is thrown.', async ({ getLocalTe sentryTest('should update session when an exception is captured.', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - const pageloadSession = await getFirstSentryEnvelopeRequest(page, url); + const pageloadSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + await page.goto(url); + const pageloadSession = await pageloadSessionPromise; const updatedSessionPromise = waitForSession(page); await page.locator('#capture-exception').click(); @@ -34,6 +37,7 @@ sentryTest('should update session when an exception is captured.', async ({ getL expect(pageloadSession).toBeDefined(); expect(pageloadSession.init).toBe(true); expect(pageloadSession.errors).toBe(0); + expect(updatedSession).toBeDefined(); expect(updatedSession.init).toBe(false); expect(updatedSession.errors).toBe(1); diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts b/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts deleted file mode 100644 index 4b262d360fbd..000000000000 --- a/dev-packages/browser-integration-tests/suites/sessions/update-user/test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect } from '@playwright/test'; -import { sentryTest } from '../../../utils/fixtures'; -import { waitForSession } from '../../../utils/helpers'; - -sentryTest('starts a new session on pageload with user id', async ({ getLocalTestUrl, page }) => { - const initialSessionPromise = waitForSession(page, s => !!s.init); - const updatedSessionPromise = waitForSession(page, s => !s.init); - - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const initialSession = await initialSessionPromise; - const updatedSession = await updatedSessionPromise; - - expect(initialSession).toEqual({ - attrs: { - environment: 'production', - release: '0.1', - user_agent: expect.any(String), - }, - errors: 0, - init: true, - sid: expect.any(String), - started: expect.any(String), - status: 'ok', - timestamp: expect.any(String), - }); - - expect(updatedSession).toEqual({ - ...initialSession, - init: false, - timestamp: expect.any(String), - did: '1337', - }); - - const secondSessionPromise = waitForSession(page, s => !s.init); - - await page.locator('#navigate').click(); - const secondSession = await secondSessionPromise; - - expect(secondSession.sid).not.toBe(initialSession.sid); - expect(secondSession.did).toBe('1337'); -}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js b/dev-packages/browser-integration-tests/suites/sessions/user/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/sessions/update-user/subject.js rename to dev-packages/browser-integration-tests/suites/sessions/user/subject.js diff --git a/dev-packages/browser-integration-tests/suites/sessions/update-user/template.html b/dev-packages/browser-integration-tests/suites/sessions/user/template.html similarity index 55% rename from dev-packages/browser-integration-tests/suites/sessions/update-user/template.html rename to dev-packages/browser-integration-tests/suites/sessions/user/template.html index 77906444cbce..e7c463998c02 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/update-user/template.html +++ b/dev-packages/browser-integration-tests/suites/sessions/user/template.html @@ -1,9 +1,9 @@ - + - Navigate + Navigate diff --git a/dev-packages/browser-integration-tests/suites/sessions/user/test.ts b/dev-packages/browser-integration-tests/suites/sessions/user/test.ts new file mode 100644 index 000000000000..f9ba096356ce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/user/test.ts @@ -0,0 +1,90 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { waitForSession } from '../../../utils/helpers'; + +sentryTest('updates the session when setting the user', async ({ getLocalTestUrl, page }) => { + const initialSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + const updatedSessionPromise = waitForSession(page, s => !s.init && s.status === 'ok'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const initialSession = await initialSessionPromise; + const updatedSession = await updatedSessionPromise; + + expect(initialSession).toEqual({ + attrs: { + environment: 'production', + release: '0.1', + user_agent: expect.any(String), + }, + errors: 0, + init: true, + sid: expect.any(String), + started: expect.any(String), + status: 'ok', + timestamp: expect.any(String), + }); + + expect(updatedSession).toEqual({ + ...initialSession, + init: false, + timestamp: expect.any(String), + did: '1337', + }); +}); + +sentryTest('includes the user id in the exited session', async ({ getLocalTestUrl, page }) => { + const initialSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const initialSession = await initialSessionPromise; + + const exitedInitialSessionPromise = waitForSession(page, s => !s.init && s.status === 'exited'); + + await page.locator('#navigate').click(); + + const exitedInitialSession = await exitedInitialSessionPromise; + + expect(exitedInitialSession).toEqual({ + ...initialSession, + timestamp: expect.any(String), + init: false, + status: 'exited', + did: '1337', + }); +}); + +sentryTest('includes the user id in the subsequent session', async ({ getLocalTestUrl, page }) => { + const initialSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const initialSession = await initialSessionPromise; + + expect(initialSession).toEqual({ + attrs: { + environment: 'production', + release: '0.1', + user_agent: expect.any(String), + }, + errors: 0, + init: true, + sid: expect.any(String), + started: expect.any(String), + status: 'ok', + timestamp: expect.any(String), + }); + + const secondSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok' && s.sid !== initialSession.sid); + + await page.locator('#navigate').click(); + + const secondSession = await secondSessionPromise; + + expect(secondSession.sid).not.toBe(initialSession.sid); + expect(secondSession.did).toBe('1337'); +}); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 86172d945173..50150c6bee20 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -300,7 +300,7 @@ export async function waitForSession( return callback(event); } - return true; + return typeof event.init === 'boolean' && event.started !== undefined; } catch { return false; } diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc index 5ef4f1ff11f6..0b7b36047973 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc @@ -8,11 +8,10 @@ "SENTRY_DSN": "https://username@domain/123", "SENTRY_ENVIRONMENT": "qa", "SENTRY_TRACES_SAMPLE_RATE": "1.0", - "SENTRY_TUNNEL": "http://localhost:3031/" + "SENTRY_TUNNEL": "http://localhost:3031/", }, "assets": { "binding": "ASSETS", - "directory": "./dist" - } + "directory": "./dist", + }, } - diff --git a/packages/browser/src/integrations/browsersession.ts b/packages/browser/src/integrations/browsersession.ts index 2e3b7e2a7e48..23fdb4087af9 100644 --- a/packages/browser/src/integrations/browsersession.ts +++ b/packages/browser/src/integrations/browsersession.ts @@ -54,8 +54,9 @@ export const browserSessionIntegration = defineIntegration((options: BrowserSess // scope behaviour in the browser. const isolationScope = getIsolationScope(); let previousUser = isolationScope.getUser(); - getIsolationScope().addScopeListener(scope => { + isolationScope.addScopeListener(scope => { const maybeNewUser = scope.getUser(); + // sessions only care about user id and ip address, so we only need to capture the session if the user has changed if (previousUser?.id !== maybeNewUser?.id || previousUser?.ip_address !== maybeNewUser?.ip_address) { // the scope class already writes the user to its session, so we only need to capture the session here captureSession(); @@ -67,7 +68,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 !== undefined && from !== to) { + if (from !== to) { startSession({ ignoreDuration: true }); captureSession(); } diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index d7931565b7ab..3442ee7a9b8a 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -276,7 +276,7 @@ export function startSession(context?: SessionContext): Session { const { userAgent } = GLOBAL_OBJ.navigator || {}; const session = makeSession({ - user: currentScope.getUser() || isolationScope.getUser(), + user: Object.keys(currentScope.getUser() || {}).length > 0 ? currentScope.getUser() : isolationScope.getUser(), ...(userAgent && { userAgent }), ...context, }); From cc570f6498bc2fcaac5dbea4cf37b23cf84c70f3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Feb 2026 17:16:31 +0100 Subject: [PATCH 4/8] simpler user retrieval in startSession --- packages/core/src/exports.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 3442ee7a9b8a..7af14befc185 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -16,6 +16,7 @@ import { isThenable } from './utils/is'; import { uuid4 } from './utils/misc'; import type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; import { parseEventHintOrCaptureContext } from './utils/prepareEvent'; +import { getCombinedScopeData } from './utils/scopeData'; import { timestampInSeconds } from './utils/time'; import { GLOBAL_OBJ } from './utils/worldwide'; @@ -270,13 +271,14 @@ export function addEventProcessor(callback: EventProcessor): void { */ export function startSession(context?: SessionContext): Session { const isolationScope = getIsolationScope(); - const currentScope = getCurrentScope(); + + const { user } = getCombinedScopeData(isolationScope, getCurrentScope()); // Will fetch userAgent if called from browser sdk const { userAgent } = GLOBAL_OBJ.navigator || {}; const session = makeSession({ - user: Object.keys(currentScope.getUser() || {}).length > 0 ? currentScope.getUser() : isolationScope.getUser(), + user, ...(userAgent && { userAgent }), ...context, }); From 10d7822a4b1839aba239af6ae17bf5f70e85e86e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Feb 2026 17:30:29 +0100 Subject: [PATCH 5/8] size limit and changelog --- .size-limit.js | 8 ++++---- CHANGELOG.md | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 4f86a9f8a2ea..f4bf45b47b40 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -15,7 +15,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '24.1 KB', + limit: '24.5 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -190,7 +190,7 @@ module.exports = [ name: 'CDN Bundle (incl. Logs, Metrics)', path: createCDNPath('bundle.logs.metrics.min.js'), gzip: true, - limit: '29 KB', + limit: '30 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', @@ -214,7 +214,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '81 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', @@ -241,7 +241,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '128 KB', + limit: '129 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d01dd3117e..3abc631b8d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + - **feat(tanstackstart-react): Add global sentry exception middlewares ([#19330](https://github.com/getsentry/sentry-javascript/pull/19330))** The `sentryGlobalRequestMiddleware` and `sentryGlobalFunctionMiddleware` global middlewares capture unhandled exceptions thrown in TanStack Start API routes and server functions. Add them as the first entries in the `requestMiddleware` and `functionMiddleware` arrays of `createStart()`: @@ -18,11 +20,21 @@ }); ``` -### Important Changes +- **fix(node-core): Reduce bundle size by removing apm-js-collab and requiring pino >= 9.10 ([#18631](https://github.com/getsentry/sentry-javascript/pull/18631))** + + In order to keep receiving pino logs, you need to update your pino version to >= 9.10, the reason for the support bump is to reduce the bundle size of the node-core SDK in frameworks that cannot tree-shake the apm-js-collab dependency. + +- **fix(browser): Ensure user id is consistently added to sessions ([#19341](https://github.com/getsentry/sentry-javascript/pull/19341))** -- fix(node-core): Reduce bundle size by removing apm-js-collab and requiring pino >= 9.10 ([#18631](https://github.com/getsentry/sentry-javascript/pull/18631)) + Previously, the SDK inconsistently set the user id on sessions, meaning sessions were often lacking proper coupling to the user set for example via `Sentry.setUser()`. + Additionally, the SDK incorrectly skipped starting a new session for the first soft navigation after the pageload. + This patch fixes these issues. As a result, metrics around sessions, like "Crash Ffree Sessions" or "Crash Free Users" might change. + This could also trigger alerts, depending on your set thresholds and conditions. + We apologize for any inconvenience caused! -In order to keep receiving pino logs, you need to update your pino version to >= 9.10, the reason for the support bump is to reduce the bundle size of the node-core SDK in frameworks that cannot tree-shake the apm-js-collab dependency. + While we're at it, if you're using Sentry in a Single Page App or meta framework, you might want to give the new `'page'` session lifecycle a try! + This new mode no longer creates a session per soft navigation but continues the initial session until the next hard page refresh. + Check out the [docs](TODO LINK) to learn more! ## 10.39.0 From a50393f29847dbc324c280d14d7985d537655fec Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 17 Feb 2026 10:06:56 +0100 Subject: [PATCH 6/8] Update CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jan Peer Stöcklmair --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abc631b8d66..61cfaaecbb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ Previously, the SDK inconsistently set the user id on sessions, meaning sessions were often lacking proper coupling to the user set for example via `Sentry.setUser()`. Additionally, the SDK incorrectly skipped starting a new session for the first soft navigation after the pageload. - This patch fixes these issues. As a result, metrics around sessions, like "Crash Ffree Sessions" or "Crash Free Users" might change. + This patch fixes these issues. As a result, metrics around sessions, like "Crash Free Sessions" or "Crash Free Users" might change. This could also trigger alerts, depending on your set thresholds and conditions. We apologize for any inconvenience caused! From 2f8adc083c91dfdbde5ba97ef15090a5398905ca Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 18 Feb 2026 10:25:19 +0100 Subject: [PATCH 7/8] update docs link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61cfaaecbb56..599f5ff26c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ While we're at it, if you're using Sentry in a Single Page App or meta framework, you might want to give the new `'page'` session lifecycle a try! This new mode no longer creates a session per soft navigation but continues the initial session until the next hard page refresh. - Check out the [docs](TODO LINK) to learn more! + Check out the [docs](https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/integrations/browsersession/) to learn more! ## 10.39.0 From 5a94cadb5558f93670535f750564571c7ea49ca6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 18 Feb 2026 10:43:34 +0100 Subject: [PATCH 8/8] add error session update test for page lifecycle --- .../suites/sessions/page-lifecycle/subject.js | 4 +++ .../sessions/page-lifecycle/template.html | 1 + .../suites/sessions/page-lifecycle/test.ts | 25 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/subject.js b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/subject.js index 11d2e4fd8ada..812e436a0a1f 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/subject.js +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/subject.js @@ -10,3 +10,7 @@ document.getElementById('manual-session').addEventListener('click', () => { Sentry.startSession(); Sentry.captureException('Test error from manual session'); }); + +document.getElementById('error').addEventListener('click', () => { + throw new Error('Test error from error button'); +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/template.html b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/template.html index 3e0d0aefc7df..994ca54735c6 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/template.html +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/template.html @@ -6,5 +6,6 @@ + diff --git a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts index f5b58b503dc9..869b194031cf 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts @@ -78,3 +78,28 @@ sentryTest( ); }, ); + +sentryTest('Updates the session when an error is thrown', 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; + + // for good measure, throw in a few navigations + await page.locator('#navigate').click(); + await page.locator('#navigate').click(); + await page.locator('#navigate').click(); + + const updatedSessionPromise = waitForSession(page, s => !s.init && s.status !== 'ok'); + await page.locator('#error').click(); + const updatedSession = await updatedSessionPromise; + + expect(updatedSession).toEqual({ + ...initialSession, + errors: 1, + init: false, + status: 'crashed', + timestamp: expect.any(String), + }); +});