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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/node_modules
/dist
.DS_Store

/test-results/
/playwright-report/
/playwright/.cache/

*.log
pnpm-lock.yaml
28 changes: 28 additions & 0 deletions dev-packages/e2e-tests/test-applications/browser-bfcache/README.md
Original file line number Diff line number Diff line change
@@ -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=<case>` (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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>BFCache E2E - iframe</title>
<script>
// A same-origin child frame. With ?blocker=unload it registers an unload listener, which makes
// the *whole* embedding page bfcache-ineligible - the not-restored reason then comes from this
// child frame (frame: 'child').
if (new URLSearchParams(window.location.search).get('blocker') === 'unload') {
window.addEventListener('unload', () => {});
}
</script>
</head>
<body>
iframe
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BFCache E2E - Page 1</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<h1>BFCache E2E - Page 1</h1>
<a id="to-page-2" href="/page-2.html">Go to page 2</a>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BFCache E2E - Page 2</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<h1>BFCache E2E - Page 2</h1>
<a id="to-page-1" href="/index.html">Back to page 1</a>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'browser-bfcache',
});
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading