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 index 33871c37fd02..cb431b1ff954 100644 --- 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 @@ -82,9 +82,13 @@ test('reports a miss with notRestoredReasons when an unload listener blocks bfca const unloadReason = await unloadReasonPromise; expect(attr(unloadReason, 'browser.bfcache.frame')).toBe('top'); + expect(attr(unloadReason, 'browser.bfcache.reason_category')).toBe('page_lifecycle'); + expect(attr(unloadReason, 'browser.bfcache.actionable')).toBe(true); const maskedReason = await maskedReasonPromise; expect(attr(maskedReason, 'browser.bfcache.frame')).toBe('masked'); + expect(attr(maskedReason, 'browser.bfcache.reason_category')).toBe('embed'); + expect(attr(maskedReason, 'browser.bfcache.actionable')).toBe(false); const reloadDuration = await reloadDurationPromise; expect(reloadDuration.type).toBe('distribution'); @@ -126,6 +130,8 @@ test('reports a miss for an open WebSocket on Chrome < 149 (a hit from 149 on)', if (websocketReasonPromise) { const websocketReason = await websocketReasonPromise; expect(attr(websocketReason, 'browser.bfcache.frame')).toBe('top'); + expect(attr(websocketReason, 'browser.bfcache.reason_category')).toBe('realtime'); + expect(attr(websocketReason, 'browser.bfcache.actionable')).toBe(true); } }); @@ -157,6 +163,8 @@ test('reports a miss with an idbversionchangeevent reason when a connection bloc const reason = await reasonPromise; expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); + expect(attr(reason, 'browser.bfcache.reason_category')).toBe('storage'); + expect(attr(reason, 'browser.bfcache.actionable')).toBe(true); }); test('reports a miss with a response-cache-control-no-store reason when a CCNS page cookie changes', async ({ @@ -187,6 +195,8 @@ test('reports a miss with a response-cache-control-no-store reason when a CCNS p const reason = await reasonPromise; expect(attr(reason, 'browser.bfcache.frame')).toBe('top'); + expect(attr(reason, 'browser.bfcache.reason_category')).toBe('network'); + expect(attr(reason, 'browser.bfcache.actionable')).toBe(true); }); test('does not treat an ordinary forward navigation as a restore', async ({ page }) => { diff --git a/packages/browser/src/integrations/bfcache.ts b/packages/browser/src/integrations/bfcache.ts index 195c72d979cf..4876060b4543 100644 --- a/packages/browser/src/integrations/bfcache.ts +++ b/packages/browser/src/integrations/bfcache.ts @@ -35,6 +35,39 @@ interface CollectedReason { frame: BFCacheFrame; } +type BFCacheReasonCategory = 'page_lifecycle' | 'network' | 'storage' | 'realtime' | 'embed' | 'browser' | 'unknown'; + +interface ReasonClassification { + category: BFCacheReasonCategory; + /** Whether the developer can plausibly fix this blocker (vs. browser-internal/opaque reasons). */ + actionable: boolean; +} + +// The browser's `notRestoredReasons` strings are a moving target that Chrome explicitly warns can +// change, so this maps the common, stable ones and degrades to `unknown`/non-actionable for the rest. +const REASON_CLASSIFICATIONS: Record = { + 'unload-listener': { category: 'page_lifecycle', actionable: true }, + 'response-cache-control-no-store': { category: 'network', actionable: true }, + 'keepalive-request': { category: 'network', actionable: true }, + fetch: { category: 'network', actionable: true }, + xhr: { category: 'network', actionable: true }, + idbversionchangeevent: { category: 'storage', actionable: true }, + websocket: { category: 'realtime', actionable: true }, + webrtc: { category: 'realtime', actionable: true }, + broadcastchannel: { category: 'realtime', actionable: true }, + lock: { category: 'realtime', actionable: true }, + masked: { category: 'embed', actionable: false }, +}; + +/** + * Classifies a raw not-restored reason into a durable category and whether it is developer-actionable. + * + * Exported for tests only. + */ +export function _classifyReason(reason: string): ReasonClassification { + return REASON_CLASSIFICATIONS[reason] ?? { category: 'unknown', actionable: false }; +} + /** * Captures bfcache hit/miss counters and Chromium notRestoredReasons when available. */ @@ -87,10 +120,13 @@ export const bfcacheMetricsIntegration = defineIntegration((options: Partial { const transactionName = _getTransactionName(); + const { category, actionable } = _classifyReason(reason); metrics.count('browser.bfcache.not_restored', 1, { attributes: { 'browser.bfcache.reason': reason, + 'browser.bfcache.reason_category': category, + 'browser.bfcache.actionable': actionable, 'browser.bfcache.frame': frame, ...(transactionName ? { 'sentry.transaction': transactionName } : {}), }, diff --git a/packages/browser/test/integrations/bfcache.test.ts b/packages/browser/test/integrations/bfcache.test.ts index 8e1fb726b7b7..ca99f6303597 100644 --- a/packages/browser/test/integrations/bfcache.test.ts +++ b/packages/browser/test/integrations/bfcache.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { _collectNotRestoredReasons } from '../../src/integrations/bfcache'; +import { _classifyReason, _collectNotRestoredReasons } from '../../src/integrations/bfcache'; describe('bfcacheMetricsIntegration', () => { describe('_collectNotRestoredReasons', () => { @@ -109,4 +109,22 @@ describe('bfcacheMetricsIntegration', () => { ]); }); }); + + describe('_classifyReason', () => { + it.each([ + ['unload-listener', 'page_lifecycle', true], + ['response-cache-control-no-store', 'network', true], + ['idbversionchangeevent', 'storage', true], + ['websocket', 'realtime', true], + ['lock', 'realtime', true], + ['masked', 'embed', false], + ] as const)('classifies %s as %s (actionable=%s)', (reason, category, actionable) => { + expect(_classifyReason(reason)).toEqual({ category, actionable }); + }); + + it('degrades unknown reasons to a non-actionable "unknown" category', () => { + expect(_classifyReason('some-future-reason')).toEqual({ category: 'unknown', actionable: false }); + expect(_classifyReason('')).toEqual({ category: 'unknown', actionable: false }); + }); + }); });