diff --git a/.size-limit.js b/.size-limit.js index 7f8e1809522f..10abf4062d7d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -163,7 +163,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '44.1 KB', + limit: '45 KB', }, // Svelte SDK (ESM) { @@ -178,7 +178,7 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '28 KB', + limit: '29 KB', }, { name: 'CDN Bundle (incl. Tracing)', diff --git a/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/init.js b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/init.js new file mode 100644 index 000000000000..6452dbf515f9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '0.1', + integrations: [Sentry.browserSessionIntegration({ lifecycle: 'page' })], +}); 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 new file mode 100644 index 000000000000..11d2e4fd8ada --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/subject.js @@ -0,0 +1,12 @@ +let clickCount = 0; + +document.getElementById('navigate').addEventListener('click', () => { + clickCount++; + // Each click navigates to a different page + history.pushState({}, '', `/page-${clickCount}`); +}); + +document.getElementById('manual-session').addEventListener('click', () => { + Sentry.startSession(); + Sentry.captureException('Test error from manual session'); +}); 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 new file mode 100644 index 000000000000..0677aeffb4a9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + 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 new file mode 100644 index 000000000000..1837393f86cc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/page-lifecycle/test.ts @@ -0,0 +1,72 @@ +import { expect } from '@playwright/test'; +import type { SessionContext } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests } from '../../../utils/helpers'; + +sentryTest('should start 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, + }); + + 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'); +}); + +sentryTest( + 'should NOT start a new session on pushState navigation with page lifecycle.', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const sessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + url, + envelopeType: 'session', + timeout: 4000, + }); + + const manualSessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + envelopeType: 'session', + timeout: 4000, + }); + + const eventsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + envelopeType: 'event', + timeout: 4000, + }); + + await page.waitForSelector('#navigate'); + + await page.locator('#navigate').click(); + await page.locator('#navigate').click(); + await page.locator('#navigate').click(); + + const sessions = (await sessionsPromise).filter(session => session.init); + + expect(sessions.length).toBe(1); + expect(sessions[0].init).toBe(true); + + // teardown and verify if nothing else got sent + await page.locator('#manual-session').click(); + + const newSessions = (await manualSessionsPromise).filter(session => session.init); + const events = 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.objectContaining({ + level: 'error', + message: 'Test error from manual session', + }), + ]); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/init.js b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/init.js new file mode 100644 index 000000000000..af2df91a7ceb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/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/route-lifecycle/subject.js b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/subject.js new file mode 100644 index 000000000000..41f372c3aa79 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/subject.js @@ -0,0 +1,6 @@ +let clickCount = 0; + +document.getElementById('navigate').addEventListener('click', () => { + clickCount++; + history.pushState({}, '', `/page-${clickCount}`); +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/template.html b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/template.html new file mode 100644 index 000000000000..2a1b5d400981 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/template.html @@ -0,0 +1,9 @@ + + + + + + + + + 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 new file mode 100644 index 000000000000..4f4f0641feeb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/route-lifecycle/test.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test'; +import type { SessionContext } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests } from '../../../utils/helpers'; + +sentryTest( + 'should start new sessions on pushState navigation with route lifecycle (default).', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const sessionsPromise = getMultipleSentryEnvelopeRequests(page, 10, { + url, + envelopeType: 'session', + timeout: 4000, + }); + + await page.waitForSelector('#navigate'); + + await page.locator('#navigate').click(); + await page.locator('#navigate').click(); + await page.locator('#navigate').click(); + + const sessions = (await sessionsPromise).filter(session => session.init); + + expect(sessions.length).toBe(3); + }, +); diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 9b733cff92bd..bf0de4783c5a 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -102,5 +102,6 @@ export { globalHandlersIntegration } from './integrations/globalhandlers'; export { httpContextIntegration } from './integrations/httpcontext'; export { linkedErrorsIntegration } from './integrations/linkederrors'; export { browserApiErrorsIntegration } from './integrations/browserapierrors'; +export { browserSessionIntegration } from './integrations/browsersession'; export { lazyLoadIntegration } from './utils/lazyLoadIntegration'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 6e7c54198edc..db89b6110866 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -75,7 +75,6 @@ export type { Span, FeatureFlagsIntegration } from '@sentry/core'; export { makeBrowserOfflineTransport } from './transports/offline'; export { browserProfilingIntegration } from './profiling/integration'; export { spotlightBrowserIntegration } from './integrations/spotlight'; -export { browserSessionIntegration } from './integrations/browsersession'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; export { unleashIntegration } from './integrations/featureFlags/unleash'; diff --git a/packages/browser/src/integrations/browsersession.ts b/packages/browser/src/integrations/browsersession.ts index 78a9228e3b29..a0eb63034b9f 100644 --- a/packages/browser/src/integrations/browsersession.ts +++ b/packages/browser/src/integrations/browsersession.ts @@ -3,13 +3,30 @@ import { addHistoryInstrumentationHandler } from '@sentry-internal/browser-utils import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; +interface BrowserSessionOptions { + /** + * Controls the session lifecycle - when new sessions are created. + * + * - `'route'`: A session is created on page load and on every navigation. + * This is the default behavior. + * - `'page'`: A session is created once when the page is loaded. Session is not + * updated on navigation. This is useful for webviews or single-page apps where + * URL changes should not trigger new sessions. + * + * @default 'route' + */ + lifecycle?: 'route' | 'page'; +} + /** * When added, automatically creates sessions which allow you to track adoption and crashes (crash free rate) in your Releases in Sentry. * More information: https://docs.sentry.io/product/releases/health/ * * Note: In order for session tracking to work, you need to set up Releases: https://docs.sentry.io/product/releases/ */ -export const browserSessionIntegration = defineIntegration(() => { +export const browserSessionIntegration = defineIntegration((options: BrowserSessionOptions = {}) => { + const lifecycle = options.lifecycle ?? 'route'; + return { name: 'BrowserSession', setupOnce() { @@ -26,14 +43,16 @@ export const browserSessionIntegration = defineIntegration(() => { startSession({ ignoreDuration: true }); captureSession(); - // 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) { - startSession({ ignoreDuration: true }); - captureSession(); - } - }); + if (lifecycle === 'route') { + // 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) { + startSession({ ignoreDuration: true }); + captureSession(); + } + }); + } }, }; });