Skip to content

Commit 08b4500

Browse files
committed
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.
1 parent 1e06724 commit 08b4500

4 files changed

Lines changed: 136 additions & 1 deletion

File tree

packages/browser-utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export { userTimingIntegration } from './performance/userTiming';
3232

3333
export { extractNetworkProtocol } from './performance/utils';
3434

35+
export { BROWSER_NAVIGATION_TYPE_ATTRIBUTE } from './web-vitals/emitSpan';
36+
3537
export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './web-vitals/spans';
3638

3739
export { whenIdleOrHidden } from './web-vitals/utils';

packages/browser-utils/src/web-vitals/emitSpan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs';
1717

1818
// TODO(conventions): replace with `BROWSER_NAVIGATION_TYPE` from `@sentry/conventions/attributes`
1919
// once https://github.com/getsentry/sentry-conventions/pull/600 is released.
20-
const BROWSER_NAVIGATION_TYPE_ATTRIBUTE = 'browser.navigation.type';
20+
export const BROWSER_NAVIGATION_TYPE_ATTRIBUTE = 'browser.navigation.type';
2121

2222
// web-vitals reports a wider set of navigation types than the attribute defines. Only the states
2323
// Navigation Timing cannot express keep their own value; every ordinary document navigation folds

packages/browser/src/tracing/browserTracingIntegration.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { _INTERNAL_ensureBrowserSpanStreaming, startIdleSpan, startInactiveSpan } from '@sentry/core/browser';
3131
import {
3232
addHistoryInstrumentationHandler,
33+
BROWSER_NAVIGATION_TYPE_ATTRIBUTE,
3334
addPerformanceEntries,
3435
getLocationHref,
3536
isBotUserAgent,
@@ -663,6 +664,41 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
663664
{ url: to, isRedirect: navigationIsRedirect },
664665
);
665666
});
667+
668+
// A bfcache restore resurrects the frozen document, so there is no document load and no
669+
// usable history event: `popstate` either doesn't fire or is swallowed because the URL is
670+
// unchanged from when the page was frozen. Without a span of its own, everything after the
671+
// restore joins the trace the page had before it was frozen, separated by however long it
672+
// sat in the cache.
673+
WINDOW.addEventListener?.('pageshow', (event: PageTransitionEvent) => {
674+
if (!event.persisted) {
675+
return;
676+
}
677+
678+
// A navigation has happened, so the pageload guard in the history handler above must not
679+
// suppress the next one.
680+
startingUrl = undefined;
681+
682+
startBrowserTracingNavigationSpan(
683+
client,
684+
{
685+
// Deliberately no `startTime`: the span starts now, at the restore. The
686+
// `PerformanceNavigationTiming` entry still describes the original document load and
687+
// would date the span to before the page was frozen.
688+
name: hasSpanStreamingEnabled(client)
689+
? NAVIGATION_SPAN_NAME_FALLBACK
690+
: WINDOW.location?.pathname || '/',
691+
attributes: {
692+
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
693+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache',
694+
// A bfcache restore is near-instant, so these spans would otherwise drag
695+
// navigation duration percentiles down with no way to tell them apart.
696+
[BROWSER_NAVIGATION_TYPE_ATTRIBUTE]: 'bfcache',
697+
},
698+
},
699+
{ url: WINDOW.location?.href },
700+
);
701+
});
666702
}
667703
}
668704

packages/browser/test/tracing/browserTracingIntegration.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getCurrentScope,
99
getDynamicSamplingContextFromSpan,
1010
getMainCarrier,
11+
metrics,
1112
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1213
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1314
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
@@ -31,6 +32,7 @@ import {
3132
startBrowserTracingPageLoadSpan,
3233
} from '../../src/tracing/browserTracingIntegration';
3334
import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/linkedTraces';
35+
import { bfcacheIntegration } from '../../src/integrations/bfcache';
3436
import * as webVitalsModule from '../../src/integrations/webVitals';
3537
import { getDefaultBrowserClientOptions } from '../helper/browser-client-options';
3638
import { SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
@@ -849,6 +851,101 @@ describe('browserTracingIntegration', () => {
849851
});
850852
});
851853

854+
describe('bfcache restores', () => {
855+
function firePageShow(persisted: boolean): void {
856+
const event = new Event('pageshow') as PageTransitionEvent;
857+
Object.defineProperty(event, 'persisted', { value: persisted });
858+
WINDOW.dispatchEvent(event);
859+
}
860+
861+
function initClient(options = {}): BrowserClient {
862+
const client = new BrowserClient(
863+
getDefaultBrowserClientOptions({
864+
tracesSampleRate: 1,
865+
integrations: [browserTracingIntegration({ instrumentPageLoad: false, ...options })],
866+
}),
867+
);
868+
setCurrentClient(client);
869+
client.init();
870+
return client;
871+
}
872+
873+
it('starts a navigation span when the page is restored from the bfcache', () => {
874+
initClient();
875+
876+
firePageShow(true);
877+
878+
const span = getActiveSpan()!;
879+
expect(span).toBeDefined();
880+
expect(spanToJSON(span).attributes).toEqual(
881+
expect.objectContaining({
882+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
883+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache',
884+
'browser.navigation.type': 'bfcache',
885+
}),
886+
);
887+
});
888+
889+
it('ignores a pageshow that is not a bfcache restore', () => {
890+
initClient();
891+
892+
firePageShow(false);
893+
894+
expect(getActiveSpan()).toBeUndefined();
895+
});
896+
897+
it('starts a new trace, rather than continuing the one from before the freeze', () => {
898+
initClient();
899+
900+
firePageShow(true);
901+
const firstTraceId = spanToJSON(getActiveSpan()!).trace_id;
902+
903+
vi.advanceTimersByTime(1600);
904+
firePageShow(true);
905+
const secondTraceId = spanToJSON(getActiveSpan()!).trace_id;
906+
907+
expect(firstTraceId).toBeDefined();
908+
expect(secondTraceId).not.toBe(firstTraceId);
909+
});
910+
911+
it('does not start a span when navigation instrumentation is off', () => {
912+
initClient({ instrumentNavigation: false });
913+
914+
firePageShow(true);
915+
916+
expect(getActiveSpan()).toBeUndefined();
917+
});
918+
919+
// Pins a known ordering problem rather than endorsing it. `bfcacheIntegration` registers its
920+
// `pageshow` listener from `setupOnce`, which core always runs before every `afterAllSetup`,
921+
// so its hit/miss metric is emitted before this navigation span exists and lands on the trace
922+
// the page had before it was frozen. See the note on the pageshow handler.
923+
it('emits the bfcache metric on the pre-freeze trace, before the navigation span exists', () => {
924+
const countSpy = vi.spyOn(metrics, 'count').mockImplementation(() => {});
925+
const client = new BrowserClient(
926+
getDefaultBrowserClientOptions({
927+
tracesSampleRate: 1,
928+
integrations: [browserTracingIntegration({ instrumentPageLoad: false }), bfcacheIntegration()],
929+
}),
930+
);
931+
setCurrentClient(client);
932+
client.init();
933+
934+
const traceIdBeforeRestore = getCurrentScope().getPropagationContext().traceId;
935+
936+
let traceIdAtMetricTime: string | undefined;
937+
countSpy.mockImplementation(() => {
938+
traceIdAtMetricTime = getCurrentScope().getPropagationContext().traceId;
939+
});
940+
941+
firePageShow(true);
942+
943+
const navigationTraceId = spanToJSON(getActiveSpan()!).trace_id;
944+
expect(traceIdAtMetricTime).toBe(traceIdBeforeRestore);
945+
expect(traceIdAtMetricTime).not.toBe(navigationTraceId);
946+
});
947+
});
948+
852949
describe('startBrowserTracingNavigationSpan', () => {
853950
it('works without integration setup', () => {
854951
const client = new BrowserClient(

0 commit comments

Comments
 (0)