Skip to content

Commit d7cd440

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 1e0115b commit d7cd440

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
@@ -39,6 +39,7 @@ import {
3939
} from '@sentry/core/browser';
4040
import {
4141
addHistoryInstrumentationHandler,
42+
BROWSER_NAVIGATION_TYPE_ATTRIBUTE,
4243
addPerformanceEntries,
4344
getLocationHref,
4445
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+
[SEMANTIC_ATTRIBUTE_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 { 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)