feat(browser): Start a navigation span for bfcache restores - #23748
Conversation
c306449 to
831a206
Compare
size-limit report 📦
|
831a206 to
d7cd440
Compare
d7cd440 to
e67604c
Compare
e67604c to
08b4500
Compare
08b4500 to
62457db
Compare
62457db to
7e4c2bc
Compare
48fcffe to
b7439e2
Compare
b7439e2 to
14b181f
Compare
14b181f to
18c743a
Compare
18c743a to
5c7be80
Compare
5c7be80 to
18ab1ef
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 18ab1ef. Configure here.
18ab1ef to
268dcd7
Compare
268dcd7 to
4568daa
Compare
4568daa to
83d3b23
Compare
… 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.
83d3b23 to
e2c0c91
Compare
Lms24
left a comment
There was a problem hiding this comment.
Nice change!
One thing to think about: I think we briefly discussed if these spans should have a different op than navigation. Something along the lines of navigation.cache_restore (analogous to navigation.redirect). Just curious on your thoughts if this makes sense?
Happy to leave the decision up to you, I can see the argument being made in both ways. If we go with the separate op, please add it to conventions (op, name and description rules).
| // suppress the next one. | ||
| startingUrl = undefined; | ||
|
|
||
| startBrowserTracingNavigationSpan( |
There was a problem hiding this comment.
q: At this point, is there any way another span could already be active? I guess with #23779 we eliminate one case but just curious if we should do something like
const maybeActiveSpan = getActiveSpan();
const segmentSpan = getRootSpan(maybeActiveSpan);
if (segmentSpan && segmentSpan.isRecording()) {
segmentSpan.end()
}
startBrowserTracingNavigationSpan(...)There was a problem hiding this comment.
I think we already do that?
I didn't see this happening, so I checked why it didn't occur in my tests and found that line. so feels redundant. WDYT?
There was a problem hiding this comment.
ah yes, redundant then. sorry for the false flag!
| // unchanged from when the page was frozen. Without a span of its own, everything after the | ||
| // restore joins the trace the page had before it was frozen, separated by however long it | ||
| // sat in the cache. | ||
| WINDOW.addEventListener?.('pageshow', (event: PageTransitionEvent) => { |
There was a problem hiding this comment.
m: one more thing: Several browserTracingIntegrations call the base integration with instrumentNavigation: false. Meaning this code path won't be reached. Should we pull it out and gate it with another option? Or, we let the higher-level browserTracingIntegrations somehow control the behaviour in their implementations. Totally fine with whatever solution we find here
There was a problem hiding this comment.
Nice catch, I will add a instrumentBfcacheRestore option to control this.
The `pageshow` listener sat inside the `if (instrumentNavigation)` block, which gates the History API handler. A restore is not a history change: `popstate` doesn't fire, the URL is unchanged, and no router emits anything, so it never goes through that mechanism and had no business inheriting its gate. Every framework integration passes `instrumentNavigation: false` to the base integration so its own router can own history spans (react, nextjs, vue, angular, solid, sveltekit, ember), which left the restore span reaching plain `@sentry/browser` only. `instrumentBfcacheRestore` defaults to true and is independent of `instrumentNavigation`. The point of the span is trace hygiene, keeping what happens after a restore off the trace the page had before it was frozen, so it is worth having even where history instrumentation is not.
`startingUrl?.indexOf(to) !== -1` is true when `startingUrl` is undefined, since the optional chain short-circuits to undefined and `undefined !== -1`. Clearing `startingUrl` therefore did not disable the guard, it reduced the condition to `from === undefined` and made suppression unconditional. `from` is undefined only on the first history event of the document, and module state survives a restore, so this bites a page that never touched the History API before it was frozen: the bfcache handler clears `startingUrl`, and the first real navigation after the restore is then swallowed, leaving that route on the restore span. Without the clearing, `startingUrl` still held the original href and the guard correctly did not match. The comment above the guard already says it should only fire when a valid `startingUrl` exists, so the check now matches what it always meant. The regression test needs `from` to be undefined, which is only the first history event of the module's life, so it lives in its own file rather than behind the `pushState` calls in browserTracingIntegration.test.ts.
Yep! I thought about this last week and had some thoughts on keeping it:
|
The bfcache e2e app only exercised `bfcacheMetricsIntegration`, so nothing outside the jsdom unit tests asserted the navigation span this branch adds. Tracing is opt-in per test via `?tracing=1` rather than on for the whole app. Two of the existing metric tests assert the segment name the metric falls back to when the scope has no transaction name, and a pageload span would put one there. The second test pins the start timestamp against the pageload span rather than against the host clock. It is the property most likely to regress silently: the performance entries folded in when the span ends all predate the restore, and what keeps them from dragging the start timestamp back to before the freeze is a guard in `entries.ts` keyed on the `navigation` op.
… name No router event fires on a bfcache restore, so the span falls back to the raw pathname. In a plain browser app that matches every other navigation, but in a framework app it is the only navigation span not named from a parameterized route. #23551 gives us `resolveCurrentRoute()` to fix it.
Reports web vitals for back/forward cache restores, which we dropped outright before. Now that a restore gets its own navigation span in #23748, the vitals have a correct parent, so the hardcoded `withoutBfcache` drop becomes `webVitals: { bfcache: true }`. Off by default, since a restore is a different population from a page load. Stacked on #23748.

Starts a navigation span when the page is restored from the back/forward cache.
A restore resurrects the frozen document, so there is no document load and no usable history event:
popstateeither doesn't fire or is swallowed because the URL is unchanged. Without a span of its own, everything after the restore joins the trace the page had before it was frozen, which misattributes errors and breadcrumbs as much as vitals.It lives in
browserTracingIntegrationrather thanbfcacheIntegrationso it doesn't depend on an opt-in integration being enabled, and carriesbrowser.navigation.type: bfcachesince a restore is near-instant and would otherwise drag navigation duration percentiles down with no way to tell it apart.Stacked on #23425.