-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(browser): Start a navigation span for bfcache restores #23748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e2c0c91
aa4d47e
db629c8
da1928a
b883752
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -28,6 +28,7 @@ import { | |||
| import { _INTERNAL_ensureBrowserSpanStreaming, startIdleSpan, startInactiveSpan } from '@sentry/core/browser'; | ||||
| import { | ||||
| addHistoryInstrumentationHandler, | ||||
| BROWSER_NAVIGATION_TYPE_ATTRIBUTE, | ||||
| addPerformanceEntries, | ||||
| getLocationHref, | ||||
| isBotUserAgent, | ||||
|
|
@@ -94,6 +95,19 @@ export interface BrowserTracingOptions { | |||
| */ | ||||
| instrumentNavigation: boolean; | ||||
|
|
||||
| /** | ||||
| * If a navigation span should be created when the page is restored from the back/forward cache. | ||||
| * | ||||
| * This is deliberately independent of {@link BrowserTracingOptions.instrumentNavigation}: a restore | ||||
| * is not a history change, and the framework integrations that own their own navigation spans turn | ||||
| * that option off without ever handling a restore. The point of this span is trace hygiene, keeping | ||||
| * everything after the restore off the trace the page had before it was frozen, so it is worth | ||||
| * having even where history instrumentation is not. | ||||
| * | ||||
| * Default: true | ||||
| */ | ||||
| instrumentBfcacheRestore: boolean; | ||||
|
|
||||
| /** | ||||
| * Flag spans where tabs moved to background with "cancelled". Browser background tab timing is | ||||
| * not suited towards doing precise measurements of operations. By default, we recommend that this option | ||||
|
|
@@ -263,6 +277,7 @@ export interface BrowserTracingOptions { | |||
| const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { | ||||
| ...TRACING_DEFAULTS, | ||||
| instrumentNavigation: true, | ||||
| instrumentBfcacheRestore: true, | ||||
| instrumentPageLoad: true, | ||||
| markBackgroundSpan: true, | ||||
| enableLongTask: true, | ||||
|
|
@@ -320,6 +335,7 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption | |||
| ignoreResourceSpans, | ||||
| instrumentPageLoad, | ||||
| instrumentNavigation, | ||||
| instrumentBfcacheRestore, | ||||
| detectRedirects, | ||||
| linkPreviousTrace, | ||||
| consistentTraceSampling, | ||||
|
|
@@ -645,7 +661,7 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption | |||
| * only be caused in certain development environments where the usage of a hot module reloader is causing | ||||
| * errors. | ||||
| */ | ||||
| if (from === undefined && startingUrl?.indexOf(to) !== -1) { | ||||
| if (from === undefined && startingUrl !== undefined && startingUrl.indexOf(to) !== -1) { | ||||
| startingUrl = undefined; | ||||
| return; | ||||
| } | ||||
|
|
@@ -673,6 +689,47 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption | |||
| ); | ||||
| }); | ||||
| } | ||||
|
|
||||
| // 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. 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. | ||||
| if (instrumentBfcacheRestore) { | ||||
| WINDOW.addEventListener?.('pageshow', (event: PageTransitionEvent) => { | ||||
| 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; | ||||
|
cursor[bot] marked this conversation as resolved.
|
||||
|
|
||||
| startBrowserTracingNavigationSpan( | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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(...)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah yes, redundant then. sorry for the false flag! |
||||
| 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. | ||||
| // | ||||
| // TODO(routing): resolve the parameterized route via the route provider (#23551) and set | ||||
| // the source from it. No router event fires on a restore, so in a framework app this is | ||||
| // the only navigation span still named from a raw pathname. | ||||
| name: hasSpanStreamingEnabled(client) | ||||
| ? NAVIGATION_SPAN_NAME_FALLBACK | ||||
| : WINDOW.location?.pathname || '/', | ||||
| attributes: { | ||||
| [SENTRY_SEGMENT_NAME_SOURCE]: 'url', | ||||
| [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. | ||||
|
logaretm marked this conversation as resolved.
|
||||
| [BROWSER_NAVIGATION_TYPE_ATTRIBUTE]: 'bfcache', | ||||
| }, | ||||
| }, | ||||
| { url: WINDOW.location?.href }, | ||||
| ); | ||||
| }); | ||||
|
cursor[bot] marked this conversation as resolved.
|
||||
| } | ||||
| } | ||||
|
|
||||
| if (markBackgroundSpan) { | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
|
|
||
| import { | ||
| browserPerformanceTimeOrigin, | ||
| getActiveSpan, | ||
| getMainCarrier, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
| setCurrentClient, | ||
| spanToJSON, | ||
| } from '@sentry/core'; | ||
| import { JSDOM } from 'jsdom'; | ||
| import { TextDecoder, TextEncoder } from 'util'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { BrowserClient } from '../../src/client'; | ||
| import { WINDOW } from '../../src/helpers'; | ||
| import { browserTracingIntegration } from '../../src/tracing/browserTracingIntegration'; | ||
| import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; | ||
|
|
||
| // @ts-expect-error patch the encoder on the window, else importing JSDOM fails | ||
| delete global.window.TextEncoder; | ||
| // @ts-expect-error patch the encoder on the window, else importing JSDOM fails | ||
| delete global.window.TextDecoder; | ||
| global.window.TextEncoder = TextEncoder; | ||
| // @ts-expect-error patch the encoder on the window, else importing JSDOM fails | ||
| global.window.TextDecoder = TextDecoder; | ||
|
|
||
| const dom = new JSDOM(undefined, { url: 'https://example.com/' }); | ||
| Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); | ||
| Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); | ||
| Object.defineProperty(global, 'history', { value: dom.window.history, writable: true }); | ||
|
|
||
| // This lives in its own file on purpose. The history instrumentation tracks the previous URL in | ||
| // module state, so `from` is only `undefined` on the very first history event of the module's life. | ||
| // Any earlier `pushState` in the same file sets it and makes the case under test unreachable. | ||
| describe('bfcache restore, then the first history navigation of the document', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| vi.setSystemTime(browserPerformanceTimeOrigin()!); | ||
| getMainCarrier().__SENTRY__ = undefined; | ||
| vi.spyOn(console, 'warn').mockImplementation(() => {}); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| getActiveSpan()?.end(); | ||
| vi.useRealTimers(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| // A page that never touched the History API before it was frozen hits the pageload guard on its | ||
| // first navigation after the restore. Clearing `startingUrl` has to actually disable that guard, | ||
| // otherwise the guard swallows the navigation and that route stays on the restore span. | ||
| it('starts a navigation span rather than leaving the route on the restore span', () => { | ||
| const client = new BrowserClient( | ||
| getDefaultBrowserClientOptions({ | ||
| tracesSampleRate: 1, | ||
| integrations: [browserTracingIntegration({ instrumentPageLoad: false })], | ||
| }), | ||
| ); | ||
| setCurrentClient(client); | ||
| client.init(); | ||
|
|
||
| const event = new Event('pageshow') as PageTransitionEvent; | ||
| Object.defineProperty(event, 'persisted', { value: true }); | ||
| WINDOW.dispatchEvent(event); | ||
|
|
||
| expect(spanToJSON(getActiveSpan()!).attributes).toEqual( | ||
| expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache' }), | ||
| ); | ||
|
|
||
| // Past the redirect threshold, so the navigation is judged on the pageload guard alone rather | ||
| // than being folded into the restore span as a redirect. | ||
| vi.advanceTimersByTime(2000); | ||
| WINDOW.history.pushState({}, '', '/after-restore'); | ||
|
|
||
| expect(spanToJSON(getActiveSpan()!).attributes).toEqual( | ||
| expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser' }), | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 hereThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch, I will add a
instrumentBfcacheRestoreoption to control this.