diff --git a/CHANGELOG.md b/CHANGELOG.md index f8190e95a4c4..1e1db309ab78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(browser): Add `bfcacheMetricsIntegration` to track back/forward cache health** + + The new opt-in `bfcacheMetricsIntegration` emits metrics about browser back/forward cache (bfcache) navigations, so you can + measure how often back-button navigation is instant and what's blocking it. + + ```js + Sentry.init({ + integrations: [Sentry.bfcacheMetricsIntegration()], + }); + ``` + + It emits: + - `browser.bfcache.navigation` — a counter split by outcome (`hit`/`miss`). + - `browser.bfcache.not_restored` — a counter of the (Chromium-only) `notRestoredReasons` for a miss. + - `browser.bfcache.reload.duration` — a distribution of how expensive the fallback reload was on a miss. + ## 10.67.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/.gitignore b/dev-packages/e2e-tests/test-applications/browser-bfcache/.gitignore new file mode 100644 index 000000000000..6a8319073dbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/.gitignore @@ -0,0 +1,10 @@ +/node_modules +/dist +.DS_Store + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +*.log +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md new file mode 100644 index 000000000000..86318a9986d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/README.md @@ -0,0 +1,28 @@ +# browser-bfcache + +Exercises `bfcacheMetricsIntegration` against a **real** browser back/forward cache, covering hits, +misses, and the real `notRestoredReasons` the browser reports (Chromium-only, and this app is +Chromium). Deliberately bfcache-ineligible pages are produced via `?botch=` (see `src/main.ts`). + +Why this needs its own app rather than living in `browser-integration-tests`: + +- **Real documents, real server.** bfcache only kicks in on a genuine cross-document history + traversal served over real HTTP. The shared browser suite serves pages via CDP `page.route` + interception on a fake host, which bfcache treats as ineligible. Here two static pages + (`index.html`, `page-2.html`) are built with Vite and served with `vite preview`. It must be the + production build, not `vite dev` (the dev server's HMR websocket is itself a bfcache blocker). +- **A specific Chromium launch recipe** (see `playwright.config.mjs`): the full Chrome-for-Testing + binary (`channel: 'chromium'`, not the default `chromium_headless_shell`, which has no bfcache), + with Playwright's `--disable-back-forward-cache` flag stripped and `BackForwardCache` enabled. +- **Renderer-initiated navigation.** Restores are triggered with `history.back()` from the page; + Playwright's CDP `goBack` bypasses bfcache. + +Which conditions actually block bfcache (and the exact reason strings) is version-specific and more +permissive than web.dev's list suggests, so the individual `?botch=` cases and their assertions are +the source of truth, not prose here. Some are gated on the browser version where behavior changed. + +Reason extraction/classification (top/child/masked frames, nesting, caps) is covered by the unit test +at `packages/browser/test/integrations/bfcache.test.ts`; this app verifies the real end-to-end +hit/miss + reason path. + +If other tests later fit these same constraints, this app can be renamed to something broader. diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/iframe.html b/dev-packages/e2e-tests/test-applications/browser-bfcache/iframe.html new file mode 100644 index 000000000000..ac5f4912ee94 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/iframe.html @@ -0,0 +1,18 @@ + + + + + BFCache E2E - iframe + + + + iframe + + diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/index.html b/dev-packages/e2e-tests/test-applications/browser-bfcache/index.html new file mode 100644 index 000000000000..6539e254cdc6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/index.html @@ -0,0 +1,13 @@ + + + + + + BFCache E2E - Page 1 + + + +

BFCache E2E - Page 1

+ Go to page 2 + + diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json new file mode 100644 index 000000000000..8db0fc777163 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json @@ -0,0 +1,29 @@ +{ + "name": "browser-bfcache-test-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 3030", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/browser": "file:../../packed/sentry-browser-packed.tgz" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "~5.8.3", + "vite": "^7.3.2" + }, + "volta": { + "node": "20.19.2", + "yarn": "1.22.22", + "pnpm": "9.15.9" + } +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/page-2.html b/dev-packages/e2e-tests/test-applications/browser-bfcache/page-2.html new file mode 100644 index 000000000000..6dd121435ca9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/page-2.html @@ -0,0 +1,13 @@ + + + + + + BFCache E2E - Page 2 + + + +

BFCache E2E - Page 2

+ Back to page 1 + + diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/browser-bfcache/playwright.config.mjs new file mode 100644 index 000000000000..44fa114309ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/playwright.config.mjs @@ -0,0 +1,30 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview`, +}); + +// Real bfcache only restores under the full Chrome-for-Testing binary (`channel: 'chromium'`) with +// Playwright's default `--disable-back-forward-cache` flag stripped and the feature enabled. The +// default headless binary (`chromium_headless_shell`) has no bfcache, so without this the restore +// never happens and the test would be meaningless. +for (const project of config.projects ?? []) { + project.use = { + ...project.use, + channel: 'chromium', + launchOptions: { + ignoreDefaultArgs: ['--disable-back-forward-cache'], + args: ['--enable-features=BackForwardCache'], + }, + }; +} + +// Extra server that holds a WebSocket open, used by the `?botch=websocket` blocker case. +config.webServer.push({ + command: 'node start-ws-server.mjs', + port: 3034, + stdout: 'pipe', + stderr: 'pipe', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts new file mode 100644 index 000000000000..e53f75c4a45a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/src/main.ts @@ -0,0 +1,85 @@ +import * as Sentry from '@sentry/browser'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + integrations: [Sentry.bfcacheMetricsIntegration()], + release: 'e2e-test', + environment: 'qa', + tunnel: 'http://localhost:3031', +}); + +(window as unknown as { Sentry: typeof Sentry }).Sentry = Sentry; + +// Test-only marker: lets the test distinguish a genuine bfcache restore (environment working) from a +// fresh reload (environment not restoring), independently of whether our integration emitted a metric. +window.addEventListener( + 'pageshow', + event => { + if ((event as PageTransitionEvent).persisted) { + (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored = true; + } + }, + true, +); + +// Deliberately make this page bfcache-ineligible when the test asks for it via `?botch=...`, so we can +// assert the real miss + notRestoredReasons the browser reports. Each botcher is a documented blocker. +const botch = new URLSearchParams(window.location.search).get('botch'); + +if (botch === 'unload') { + // An `unload` listener is the canonical, version-stable bfcache blocker. + window.addEventListener('unload', () => {}); +} + +if (botch === 'websocket') { + // An open WebSocket blocks bfcache in Chrome < 149; from 149 on it no longer does. + const ws = new WebSocket('ws://localhost:3034'); + const w = window as unknown as { __wsOpen?: boolean; __ws?: WebSocket }; + w.__wsOpen = false; + ws.addEventListener('open', () => { + w.__wsOpen = true; + }); + w.__ws = ws; +} + +if (botch === 'nostore') { + // The document is served with `Cache-Control: no-store` (see vite.config.ts). A CCNS page is + // cached but evicted once a cookie changes, so mutate a cookie to force the miss. + document.cookie = 'bf=1; Path=/'; + (window as unknown as { __nostoreReady?: boolean }).__nostoreReady = true; +} + +if (botch === 'indexeddb') { + // A plain open IndexedDB connection (or even an in-flight transaction) does NOT block bfcache in + // current Chrome. What still blocks is a connection holding up a version upgrade: open v1 without + // closing it on `versionchange`, then request v2 - the upgrade is blocked and the page holds it up. + // A fresh db name per load avoids cross-run persistence. + const dbName = `bf_${Math.random().toString(36).slice(2)}`; + const w = window as unknown as { __idbBlocked?: boolean; __db?: IDBDatabase }; + w.__idbBlocked = false; + + const open1 = indexedDB.open(dbName, 1); + open1.addEventListener('upgradeneeded', event => { + (event.target as IDBOpenDBRequest).result.createObjectStore('s'); + }); + open1.addEventListener('success', event => { + w.__db = (event.target as IDBOpenDBRequest).result; // intentionally no `versionchange` handler + const open2 = indexedDB.open(dbName, 2); + open2.addEventListener('blocked', () => { + w.__idbBlocked = true; + }); + }); +} + +if (botch === 'iframe-clean' || botch === 'iframe-unload') { + // Embed a same-origin child frame. A clean child keeps the top page eligible (hit); a child with an + // unload listener makes the whole top page ineligible, and the reason comes from the child frame. + const iframe = document.createElement('iframe'); + iframe.src = botch === 'iframe-unload' ? '/iframe.html?blocker=unload' : '/iframe.html'; + const w = window as unknown as { __iframeLoaded?: boolean }; + w.__iframeLoaded = false; + iframe.addEventListener('load', () => { + w.__iframeLoaded = true; + }); + document.body.appendChild(iframe); +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-event-proxy.mjs new file mode 100644 index 000000000000..3104fe87cc85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'browser-bfcache', +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/start-ws-server.mjs b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-ws-server.mjs new file mode 100644 index 000000000000..e9f97faad1c5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/start-ws-server.mjs @@ -0,0 +1,26 @@ +import crypto from 'crypto'; +import http from 'http'; + +// Minimal WebSocket endpoint used only to hold an open connection open on the page, which makes the +// page bfcache-ineligible in Chrome < 149. It completes the handshake and then does nothing. +const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ws server'); +}); + +server.on('upgrade', (req, socket) => { + const key = req.headers['sec-websocket-key']; + if (!key) { + socket.destroy(); + return; + } + const accept = crypto.createHash('sha1').update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64'); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); +}); + +server.listen(3034, () => { + // eslint-disable-next-line no-console + console.log('bfcache ws blocker server listening on 3034'); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts new file mode 100644 index 000000000000..a057d02813aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -0,0 +1,246 @@ +import { expect, test } from '@playwright/test'; +import type { SerializedMetric } from '@sentry/core'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +const PROXY_SERVER_NAME = 'browser-bfcache'; + +function attr(metric: SerializedMetric, key: string): unknown { + return metric.attributes?.[key]?.value; +} + +function isNavigation(metric: SerializedMetric, outcome: 'hit' | 'miss'): boolean { + return metric.name === 'browser.bfcache.navigation' && attr(metric, 'browser.bfcache.outcome') === outcome; +} + +function chromeMajorVersion(browserVersion: string): number { + return parseInt(browserVersion.split('.')[0]!, 10); +} + +test('reports a hit on a genuine back/forward-cache restore', async ({ page }) => { + const hitPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'hit')); + + await page.goto('/'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + // Renderer-initiated history navigation restores the page from bfcache. + // (Playwright's CDP `goBack` bypasses bfcache, so we trigger it from within the page.) + await page.evaluate(() => history.back()); + + // Fail fast with a clear signal if the environment did not actually restore from bfcache. + await page.waitForFunction(() => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, { + timeout: 5000, + }); + + // No manual flush(): this asserts the real capture -> buffer -> send path. + const hit = await hitPromise; + expect(hit.value).toBe(1); +}); + +test('reports a miss with notRestoredReasons when an unload listener blocks bfcache', async ({ page }) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + const unloadReasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'unload-listener', + ); + // Chrome reports a privacy-masked reason alongside the real one. It's a top-frame reason here, so + // the integration must frame it positionally (`top`) and pass the value through untouched. + const maskedReasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'masked', + ); + const reloadDurationPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => metric.name === 'browser.bfcache.reload.duration', + ); + + await page.goto('/?botch=unload'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + // The unload listener makes the page ineligible, so this back navigation is a fresh reload (a miss). + await page.evaluate(() => history.back()); + await page.waitForFunction( + () => + (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.type === + 'back_forward', + { timeout: 5000 }, + ); + + const miss = await missPromise; + expect(miss.value).toBe(1); + expect(attr(miss, 'browser.bfcache.not_restored_reason_count')).toBeGreaterThanOrEqual(1); + + const unloadReason = await unloadReasonPromise; + expect(attr(unloadReason, 'browser.bfcache.frame')).toBe('top'); + + const maskedReason = await maskedReasonPromise; + expect(attr(maskedReason, 'browser.bfcache.frame')).toBe('top'); + + const reloadDuration = await reloadDurationPromise; + expect(reloadDuration.type).toBe('distribution'); + expect(reloadDuration.unit).toBe('millisecond'); + expect(typeof reloadDuration.value).toBe('number'); +}); + +test('reports a miss for an open WebSocket on Chrome < 149 (a hit from 149 on)', async ({ page, browser }) => { + const major = chromeMajorVersion(browser.version()); + const websocketBlocks = major < 149; + + // Set up every waiter before navigating: the navigation and not_restored metrics flush in the same + // envelope, and `waitForMetric` only matches metrics that arrive after it was created. + const outcomePromise = waitForMetric(PROXY_SERVER_NAME, metric => + isNavigation(metric, websocketBlocks ? 'miss' : 'hit'), + ); + const websocketReasonPromise = websocketBlocks + ? waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && attr(metric, 'browser.bfcache.reason') === 'websocket', + ) + : null; + + await page.goto('/?botch=websocket'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + // Only proceed once the socket is actually open, otherwise it wouldn't block anything. + await page.waitForFunction(() => (window as unknown as { __wsOpen?: boolean }).__wsOpen === true, { timeout: 5000 }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const outcome = await outcomePromise; + expect(outcome.value).toBe(1); + + if (websocketReasonPromise) { + const websocketReason = await websocketReasonPromise; + expect(attr(websocketReason, 'browser.bfcache.frame')).toBe('top'); + } +}); + +test('reports a miss with an idbversionchangeevent reason when a connection blocks an upgrade', async ({ page }) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + const reasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && + attr(metric, 'browser.bfcache.reason') === 'idbversionchangeevent', + ); + + await page.goto('/?botch=indexeddb'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + // Only proceed once the version upgrade is actually blocked by the open connection. + await page.waitForFunction(() => (window as unknown as { __idbBlocked?: boolean }).__idbBlocked === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const miss = await missPromise; + expect(miss.value).toBe(1); + const reason = await reasonPromise; + expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); +}); + +test('reports a miss with a response-cache-control-no-store reason when a CCNS page cookie changes', async ({ + page, +}) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + const reasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && + attr(metric, 'browser.bfcache.reason') === 'response-cache-control-no-store', + ); + + await page.goto('/?botch=nostore'); + await page.waitForFunction(() => (window as unknown as { __nostoreReady?: boolean }).__nostoreReady === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const miss = await missPromise; + expect(miss.value).toBe(1); + const reason = await reasonPromise; + expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); +}); + +test('restores a page whose embedded iframe is bfcache-eligible', async ({ page }) => { + const hitPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'hit')); + + await page.goto('/?botch=iframe-clean'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + await page.waitForFunction(() => (window as unknown as { __iframeLoaded?: boolean }).__iframeLoaded === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + await page.waitForFunction(() => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, { + timeout: 5000, + }); + + const hit = await hitPromise; + expect(hit.value).toBe(1); +}); + +test('reports a child-frame reason when an ineligible iframe blocks the top page', async ({ page }) => { + const missPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'miss')); + // The blocker lives in the child frame, so the reason must be classified as a `child` frame. + const childReasonPromise = waitForMetric( + PROXY_SERVER_NAME, + metric => + metric.name === 'browser.bfcache.not_restored' && + attr(metric, 'browser.bfcache.reason') === 'unload-listener' && + attr(metric, 'browser.bfcache.frame') === 'child', + ); + + await page.goto('/?botch=iframe-unload'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + await page.waitForFunction(() => (window as unknown as { __iframeLoaded?: boolean }).__iframeLoaded === true, { + timeout: 5000, + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + + const miss = await missPromise; + expect(miss.value).toBe(1); + + const childReason = await childReasonPromise; + expect(attr(childReason, 'browser.bfcache.frame')).toBe('child'); +}); + +test('does not treat an ordinary forward navigation as a restore', async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + const restored = await page.evaluate( + () => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, + ); + expect(restored).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tsconfig.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/tsconfig.json new file mode 100644 index 000000000000..9970c2017f2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts new file mode 100644 index 000000000000..63ad5a42871b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/vite.config.ts @@ -0,0 +1,48 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + // Two separate HTML documents so a back/forward navigation between them is a genuine + // cross-document history traversal that the browser can serve from bfcache. + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + page2: resolve(__dirname, 'page-2.html'), + iframe: resolve(__dirname, 'iframe.html'), + }, + }, + }, + // Expose the DSN the e2e harness injects to the app code, matching the `process.env.E2E_TEST_DSN` + // convention used by the other browser test apps. + define: { + 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), + }, + plugins: [ + { + // The `?botch=nostore` case needs the document served with `Cache-Control: no-store`; a later + // cookie mutation then makes the browser evict the CCNS page from bfcache. Neither the header + // nor the cookie change blocks on its own - only the combination does. A static server can't + // set per-request headers, so add it here for that URL only. + name: 'bfcache-nostore-headers', + configurePreviewServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url?.includes('botch=nostore')) { + res.setHeader('Cache-Control', 'no-store'); + // vite's static handler runs after this and would otherwise reset Cache-Control to + // `no-cache`, so lock the header for this request only. + const originalSetHeader = res.setHeader.bind(res); + res.setHeader = (name, value) => { + if (String(name).toLowerCase() === 'cache-control') { + return res; + } + + return originalSetHeader(name, value); + }; + } + next(); + }); + }, + }, + ], +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index a9e7b568ea97..bc5d7ff3b22d 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -48,6 +48,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; export { webVitalsIntegration } from './integrations/webVitals'; +export { bfcacheMetricsIntegration } from './integrations/bfcache'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/bfcache.ts b/packages/browser/src/integrations/bfcache.ts new file mode 100644 index 000000000000..4894f9cce249 --- /dev/null +++ b/packages/browser/src/integrations/bfcache.ts @@ -0,0 +1,168 @@ +import type { IntegrationFn } from '@sentry/core/browser'; +import { debug, defineIntegration, getCurrentScope, metrics } from '@sentry/core/browser'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; + +const INTEGRATION_NAME = 'BFCacheMetrics'; +const DEFAULT_MAX_REASONS = 5; + +type BFCacheFrame = 'top' | 'child'; + +interface BFCacheIntegrationOptions { + /** + * Maximum number of not-restored reasons to emit per miss. + * + * Defaults to 5. + */ + maxReasons: number; +} + +interface NotRestoredReason { + reason?: string; +} + +interface NotRestoredReasons { + children?: NotRestoredReasons[] | null; + reasons?: (NotRestoredReason | string)[] | null; +} + +interface NavigationTimingWithNotRestoredReasons extends PerformanceNavigationTiming { + notRestoredReasons?: NotRestoredReasons | null; +} + +interface CollectedReason { + reason: string; + frame: BFCacheFrame; +} + +/** + * Captures bfcache hit/miss counters and Chromium notRestoredReasons when available. + */ +export const bfcacheMetricsIntegration = defineIntegration((options: Partial = {}) => { + const maxReasons = options.maxReasons ?? DEFAULT_MAX_REASONS; + + return { + name: INTEGRATION_NAME, + + setup() { + if (!WINDOW.addEventListener || !WINDOW.performance?.getEntriesByType) { + DEBUG_BUILD && debug.log('[BFCache] Browser APIs unavailable, skipping instrumentation.'); + return; + } + + WINDOW.addEventListener( + 'pageshow', + event => { + if (event.persisted) { + _captureBFCacheNavigation('hit'); + return; + } + + const navigationEntry = WINDOW.performance.getEntriesByType('navigation')[0] as + | NavigationTimingWithNotRestoredReasons + | undefined; + + if (navigationEntry?.type !== 'back_forward') { + return; + } + + const reasons = _collectNotRestoredReasons(navigationEntry.notRestoredReasons, maxReasons); + + _captureBFCacheNavigation('miss', reasons.length); + + // Measures how expensive the fallback reload was when a back/forward navigation missed bfcache. + if (typeof navigationEntry.duration === 'number' && navigationEntry.duration > 0) { + const transactionName = _getTransactionName(); + + metrics.distribution('browser.bfcache.reload.duration', navigationEntry.duration, { + unit: 'millisecond', + attributes: { + ...(transactionName ? { 'sentry.transaction': transactionName } : {}), + }, + }); + } + + reasons.forEach(({ reason, frame }) => { + const transactionName = _getTransactionName(); + + metrics.count('browser.bfcache.not_restored', 1, { + attributes: { + 'browser.bfcache.reason': reason, + 'browser.bfcache.frame': frame, + ...(transactionName ? { 'sentry.transaction': transactionName } : {}), + }, + }); + }); + }, + true, + ); + }, + }; +}) satisfies IntegrationFn; + +function _captureBFCacheNavigation(outcome: 'hit' | 'miss', reasonCount?: number): void { + const transactionName = _getTransactionName(); + + metrics.count('browser.bfcache.navigation', 1, { + attributes: { + 'browser.bfcache.outcome': outcome, + ...(reasonCount != null ? { 'browser.bfcache.not_restored_reason_count': reasonCount } : {}), + ...(transactionName ? { 'sentry.transaction': transactionName } : {}), + }, + }); +} + +function _getTransactionName(): string | undefined { + return getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname; +} + +/** + * Flattens the (possibly nested) bfcache `notRestoredReasons` tree into a capped list of reasons. + * + * Exported for tests only. + */ +export function _collectNotRestoredReasons( + notRestoredReasons: NotRestoredReasons | null | undefined, + maxReasons: number, +): CollectedReason[] { + const reasons: CollectedReason[] = []; + + if (!notRestoredReasons || maxReasons <= 0) { + return reasons; + } + + _collectReasonsFromFrame(notRestoredReasons, 'top', reasons, maxReasons); + + return reasons; +} + +function _collectReasonsFromFrame( + frame: NotRestoredReasons, + frameType: BFCacheFrame, + collectedReasons: CollectedReason[], + maxReasons: number, +): void { + if (collectedReasons.length >= maxReasons) { + return; + } + + frame.reasons?.forEach(reason => { + if (collectedReasons.length >= maxReasons) { + return; + } + + const reasonValue = typeof reason === 'string' ? reason : reason.reason; + if (!reasonValue) { + return; + } + + collectedReasons.push({ + reason: reasonValue, + frame: frameType, + }); + }); + + frame.children?.forEach(child => { + _collectReasonsFromFrame(child, 'child', collectedReasons, maxReasons); + }); +} diff --git a/packages/browser/test/integrations/bfcache.test.ts b/packages/browser/test/integrations/bfcache.test.ts new file mode 100644 index 000000000000..2fe3097b1ba6 --- /dev/null +++ b/packages/browser/test/integrations/bfcache.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { _collectNotRestoredReasons } from '../../src/integrations/bfcache'; + +describe('bfcacheMetricsIntegration', () => { + describe('_collectNotRestoredReasons', () => { + it('returns an empty list when there are no reasons', () => { + expect(_collectNotRestoredReasons(undefined, 5)).toEqual([]); + expect(_collectNotRestoredReasons(null, 5)).toEqual([]); + expect(_collectNotRestoredReasons({ reasons: [], children: [] }, 5)).toEqual([]); + }); + + it('returns an empty list when maxReasons is zero or negative', () => { + const tree = { reasons: [{ reason: 'unload-listener' }] }; + expect(_collectNotRestoredReasons(tree, 0)).toEqual([]); + expect(_collectNotRestoredReasons(tree, -1)).toEqual([]); + }); + + it('collects top-frame reasons', () => { + const tree = { + reasons: [{ reason: 'unload-listener' }, { reason: 'response-cache-control-no-store' }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'unload-listener', frame: 'top' }, + { reason: 'response-cache-control-no-store', frame: 'top' }, + ]); + }); + + it('supports both string and object reason shapes', () => { + const tree = { + reasons: ['unload-listener', { reason: 'websocket' }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'unload-listener', frame: 'top' }, + { reason: 'websocket', frame: 'top' }, + ]); + }); + + it('skips empty/invalid reason entries', () => { + const tree = { + reasons: [{ reason: '' }, {}, { reason: 'unload-listener' }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([{ reason: 'unload-listener', frame: 'top' }]); + }); + + it('marks reasons from child frames as "child"', () => { + const tree = { + reasons: [{ reason: 'unload-listener' }], + children: [{ reasons: [{ reason: 'websocket' }] }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'unload-listener', frame: 'top' }, + { reason: 'websocket', frame: 'child' }, + ]); + }); + + it('recurses into deeply nested child frames', () => { + const tree = { + reasons: [], + children: [ + { + reasons: [], + children: [{ reasons: [{ reason: 'fetch' }] }], + }, + ], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([{ reason: 'fetch', frame: 'child' }]); + }); + + it('frames every reason by its position, without special-casing the reason value', () => { + const tree = { + reasons: [{ reason: 'masked' }], + children: [{ reasons: [{ reason: 'masked' }] }], + }; + + expect(_collectNotRestoredReasons(tree, 5)).toEqual([ + { reason: 'masked', frame: 'top' }, + { reason: 'masked', frame: 'child' }, + ]); + }); + + it('caps the number of collected reasons at maxReasons', () => { + const tree = { + reasons: [{ reason: 'a' }, { reason: 'b' }, { reason: 'c' }], + children: [{ reasons: [{ reason: 'd' }, { reason: 'e' }] }], + }; + + const collected = _collectNotRestoredReasons(tree, 2); + expect(collected).toHaveLength(2); + expect(collected).toEqual([ + { reason: 'a', frame: 'top' }, + { reason: 'b', frame: 'top' }, + ]); + }); + + it('stops recursing into children once the cap is reached', () => { + const tree = { + reasons: [{ reason: 'a' }, { reason: 'b' }], + children: [{ reasons: [{ reason: 'c' }] }], + }; + + expect(_collectNotRestoredReasons(tree, 2)).toEqual([ + { reason: 'a', frame: 'top' }, + { reason: 'b', frame: 'top' }, + ]); + }); + }); +});