From 831a2065b47c897fbb5c0fabc8919c97a11849b5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 28 Aug 2026 12:12:13 -0400 Subject: [PATCH] feat(browser): Start a navigation span when the page is restored from bfcache Prototype. A bfcache restore resurrects the frozen document, so there is no document load and no usable history event: `popstate` either doesn't fire or is swallowed, because the URL is unchanged from when the page was frozen. Two independent guards in the existing path suppress it, neither written with bfcache in mind, so there is no small nudge that gets a span out of it. Without one, everything after the restore joins the trace the page had before it was frozen, separated by however long it sat in the cache. That misattributes errors, breadcrumbs, clicks and fetches, not just the web vitals that prompted this. The span is started from a `pageshow` listener in `browserTracingIntegration` rather than `bfcacheIntegration`, so it does not depend on an opt-in integration that is about hit/miss diagnostics. It is gated on `instrumentNavigation` and on by default. It carries `browser.navigation.type: bfcache`. A restore is near-instant, so without a way to filter these out they would drag navigation duration percentiles down exactly the way bfcache vitals would have dragged LCP. The span deliberately starts at the `pageshow` event rather than from `PerformanceNavigationTiming`, which is not replaced on restore and still describes the original document load. Known gap, pinned by a test: `bfcacheIntegration` registers its own `pageshow` listener from `setupOnce`, which core always runs before every `afterAllSetup`, so its hit/miss metric is emitted before this span exists and still lands on the pre-freeze trace. --- packages/browser-utils/src/index.ts | 2 + .../browser-utils/src/web-vitals/emitSpan.ts | 2 +- .../src/tracing/browserTracingIntegration.ts | 36 +++++++ .../tracing/browserTracingIntegration.test.ts | 97 +++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 0cfd3c451168..20b8fcc146f2 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -31,6 +31,8 @@ export { userTimingIntegration } from './performance/userTiming'; export { extractNetworkProtocol } from './performance/utils'; +export { BROWSER_NAVIGATION_TYPE_ATTRIBUTE } from './web-vitals/emitSpan'; + export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './web-vitals/spans'; export { whenIdleOrHidden } from './web-vitals/utils'; diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts index 6668126283e8..d0d63125568c 100644 --- a/packages/browser-utils/src/web-vitals/emitSpan.ts +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -17,7 +17,7 @@ import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs'; // TODO(conventions): replace with `BROWSER_NAVIGATION_TYPE` from `@sentry/conventions/attributes` // once https://github.com/getsentry/sentry-conventions/pull/600 is released. -const BROWSER_NAVIGATION_TYPE_ATTRIBUTE = 'browser.navigation.type'; +export const BROWSER_NAVIGATION_TYPE_ATTRIBUTE = 'browser.navigation.type'; // web-vitals reports a wider set of navigation types than the attribute defines. Only the states // Navigation Timing cannot express keep their own value; every ordinary document navigation folds diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 51951514ae05..bd83b37e131b 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -39,6 +39,7 @@ import { } from '@sentry/core/browser'; import { addHistoryInstrumentationHandler, + BROWSER_NAVIGATION_TYPE_ATTRIBUTE, addPerformanceEntries, getLocationHref, isBotUserAgent, @@ -660,6 +661,41 @@ export const browserTracingIntegration = ((options: Partial { + if (!event.persisted) { + return; + } + + // A navigation has happened, so the pageload guard in the history handler above must not + // suppress the next one. + startingUrl = undefined; + + startBrowserTracingNavigationSpan( + client, + { + // Deliberately no `startTime`: the span starts now, at the restore. The + // `PerformanceNavigationTiming` entry still describes the original document load and + // would date the span to before the page was frozen. + name: hasSpanStreamingEnabled(client) + ? NAVIGATION_SPAN_NAME_FALLBACK + : WINDOW.location?.pathname || '/', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache', + // A bfcache restore is near-instant, so these spans would otherwise drag + // navigation duration percentiles down with no way to tell them apart. + [BROWSER_NAVIGATION_TYPE_ATTRIBUTE]: 'bfcache', + }, + }, + { url: WINDOW.location?.href }, + ); + }); } } diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index a51e9b2850d5..3b46e0a7d5f1 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -8,6 +8,7 @@ import { getCurrentScope, getDynamicSamplingContextFromSpan, getMainCarrier, + metrics, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, @@ -31,6 +32,7 @@ import { startBrowserTracingPageLoadSpan, } from '../../src/tracing/browserTracingIntegration'; import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/linkedTraces'; +import { bfcacheIntegration } from '../../src/integrations/bfcache'; import * as webVitalsModule from '../../src/integrations/webVitals'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; import { SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; @@ -809,6 +811,101 @@ describe('browserTracingIntegration', () => { expect(spanToJSON(pageloadSpan!).attributes[SENTRY_SEGMENT_NAME_SOURCE]).toBe('custom'); }); + describe('bfcache restores', () => { + function firePageShow(persisted: boolean): void { + const event = new Event('pageshow') as PageTransitionEvent; + Object.defineProperty(event, 'persisted', { value: persisted }); + WINDOW.dispatchEvent(event); + } + + function initClient(options = {}): BrowserClient { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ instrumentPageLoad: false, ...options })], + }), + ); + setCurrentClient(client); + client.init(); + return client; + } + + it('starts a navigation span when the page is restored from the bfcache', () => { + initClient(); + + firePageShow(true); + + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span).attributes).toEqual( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache', + 'browser.navigation.type': 'bfcache', + }), + ); + }); + + it('ignores a pageshow that is not a bfcache restore', () => { + initClient(); + + firePageShow(false); + + expect(getActiveSpan()).toBeUndefined(); + }); + + it('starts a new trace, rather than continuing the one from before the freeze', () => { + initClient(); + + firePageShow(true); + const firstTraceId = spanToJSON(getActiveSpan()!).trace_id; + + vi.advanceTimersByTime(1600); + firePageShow(true); + const secondTraceId = spanToJSON(getActiveSpan()!).trace_id; + + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).not.toBe(firstTraceId); + }); + + it('does not start a span when navigation instrumentation is off', () => { + initClient({ instrumentNavigation: false }); + + firePageShow(true); + + expect(getActiveSpan()).toBeUndefined(); + }); + + // Pins a known ordering problem rather than endorsing it. `bfcacheIntegration` registers its + // `pageshow` listener from `setupOnce`, which core always runs before every `afterAllSetup`, + // so its hit/miss metric is emitted before this navigation span exists and lands on the trace + // the page had before it was frozen. See the note on the pageshow handler. + it('emits the bfcache metric on the pre-freeze trace, before the navigation span exists', () => { + const countSpy = vi.spyOn(metrics, 'count').mockImplementation(() => {}); + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ instrumentPageLoad: false }), bfcacheIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + const traceIdBeforeRestore = getCurrentScope().getPropagationContext().traceId; + + let traceIdAtMetricTime: string | undefined; + countSpy.mockImplementation(() => { + traceIdAtMetricTime = getCurrentScope().getPropagationContext().traceId; + }); + + firePageShow(true); + + const navigationTraceId = spanToJSON(getActiveSpan()!).trace_id; + expect(traceIdAtMetricTime).toBe(traceIdBeforeRestore); + expect(traceIdAtMetricTime).not.toBe(navigationTraceId); + }); + }); + describe('startBrowserTracingNavigationSpan', () => { it('works without integration setup', () => { const client = new BrowserClient(