Skip to content

Commit e2c0c91

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 67ede89 commit e2c0c91

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
@@ -22,7 +22,7 @@ import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs';
2222

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

2727
// web-vitals reports a wider set of navigation types than the attribute defines. Only the states
2828
// 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
@@ -28,6 +28,7 @@ import {
2828
import { _INTERNAL_ensureBrowserSpanStreaming, startIdleSpan, startInactiveSpan } from '@sentry/core/browser';
2929
import {
3030
addHistoryInstrumentationHandler,
31+
BROWSER_NAVIGATION_TYPE_ATTRIBUTE,
3132
addPerformanceEntries,
3233
getLocationHref,
3334
isBotUserAgent,
@@ -672,6 +673,41 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
672673
{ url: to, isRedirect: navigationIsRedirect },
673674
);
674675
});
676+
677+
// A bfcache restore resurrects the frozen document, so there is no document load and no
678+
// usable history event: `popstate` either doesn't fire or is swallowed because the URL is
679+
// unchanged from when the page was frozen. Without a span of its own, everything after the
680+
// restore joins the trace the page had before it was frozen, separated by however long it
681+
// sat in the cache.
682+
WINDOW.addEventListener?.('pageshow', (event: PageTransitionEvent) => {
683+
if (!event.persisted) {
684+
return;
685+
}
686+
687+
// A navigation has happened, so the pageload guard in the history handler above must not
688+
// suppress the next one.
689+
startingUrl = undefined;
690+
691+
startBrowserTracingNavigationSpan(
692+
client,
693+
{
694+
// Deliberately no `startTime`: the span starts now, at the restore. The
695+
// `PerformanceNavigationTiming` entry still describes the original document load and
696+
// would date the span to before the page was frozen.
697+
name: hasSpanStreamingEnabled(client)
698+
? NAVIGATION_SPAN_NAME_FALLBACK
699+
: WINDOW.location?.pathname || '/',
700+
attributes: {
701+
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
702+
[SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache',
703+
// A bfcache restore is near-instant, so these spans would otherwise drag
704+
// navigation duration percentiles down with no way to tell them apart.
705+
[BROWSER_NAVIGATION_TYPE_ATTRIBUTE]: 'bfcache',
706+
},
707+
},
708+
{ url: WINDOW.location?.href },
709+
);
710+
});
675711
}
676712
}
677713

‎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 { bfcacheMetricsIntegration } from '../../src/integrations/bfcacheMetrics';
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';
@@ -887,6 +889,101 @@ describe('browserTracingIntegration', () => {
887889
});
888890
});
889891

892+
describe('bfcache restores', () => {
893+
function firePageShow(persisted: boolean): void {
894+
const event = new Event('pageshow') as PageTransitionEvent;
895+
Object.defineProperty(event, 'persisted', { value: persisted });
896+
WINDOW.dispatchEvent(event);
897+
}
898+
899+
function initClient(options = {}): BrowserClient {
900+
const client = new BrowserClient(
901+
getDefaultBrowserClientOptions({
902+
tracesSampleRate: 1,
903+
integrations: [browserTracingIntegration({ instrumentPageLoad: false, ...options })],
904+
}),
905+
);
906+
setCurrentClient(client);
907+
client.init();
908+
return client;
909+
}
910+
911+
it('starts a navigation span when the page is restored from the bfcache', () => {
912+
initClient();
913+
914+
firePageShow(true);
915+
916+
const span = getActiveSpan()!;
917+
expect(span).toBeDefined();
918+
expect(spanToJSON(span).attributes).toEqual(
919+
expect.objectContaining({
920+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
921+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache',
922+
'browser.navigation.type': 'bfcache',
923+
}),
924+
);
925+
});
926+
927+
it('ignores a pageshow that is not a bfcache restore', () => {
928+
initClient();
929+
930+
firePageShow(false);
931+
932+
expect(getActiveSpan()).toBeUndefined();
933+
});
934+
935+
it('starts a new trace, rather than continuing the one from before the freeze', () => {
936+
initClient();
937+
938+
firePageShow(true);
939+
const firstTraceId = spanToJSON(getActiveSpan()!).trace_id;
940+
941+
vi.advanceTimersByTime(1600);
942+
firePageShow(true);
943+
const secondTraceId = spanToJSON(getActiveSpan()!).trace_id;
944+
945+
expect(firstTraceId).toBeDefined();
946+
expect(secondTraceId).not.toBe(firstTraceId);
947+
});
948+
949+
it('does not start a span when navigation instrumentation is off', () => {
950+
initClient({ instrumentNavigation: false });
951+
952+
firePageShow(true);
953+
954+
expect(getActiveSpan()).toBeUndefined();
955+
});
956+
957+
// Pins a known ordering problem rather than endorsing it. `bfcacheMetricsIntegration` registers its
958+
// `pageshow` listener from `setupOnce`, which core always runs before every `afterAllSetup`,
959+
// so its hit/miss metric is emitted before this navigation span exists and lands on the trace
960+
// the page had before it was frozen. See the note on the pageshow handler.
961+
it('emits the bfcache metric on the pre-freeze trace, before the navigation span exists', () => {
962+
const countSpy = vi.spyOn(metrics, 'count').mockImplementation(() => {});
963+
const client = new BrowserClient(
964+
getDefaultBrowserClientOptions({
965+
tracesSampleRate: 1,
966+
integrations: [browserTracingIntegration({ instrumentPageLoad: false }), bfcacheMetricsIntegration()],
967+
}),
968+
);
969+
setCurrentClient(client);
970+
client.init();
971+
972+
const traceIdBeforeRestore = getCurrentScope().getPropagationContext().traceId;
973+
974+
let traceIdAtMetricTime: string | undefined;
975+
countSpy.mockImplementation(() => {
976+
traceIdAtMetricTime = getCurrentScope().getPropagationContext().traceId;
977+
});
978+
979+
firePageShow(true);
980+
981+
const navigationTraceId = spanToJSON(getActiveSpan()!).trace_id;
982+
expect(traceIdAtMetricTime).toBe(traceIdBeforeRestore);
983+
expect(traceIdAtMetricTime).not.toBe(navigationTraceId);
984+
});
985+
});
986+
890987
describe('startBrowserTracingNavigationSpan', () => {
891988
it('works without integration setup', () => {
892989
const client = new BrowserClient(

0 commit comments

Comments
 (0)