Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export { userTimingIntegration } from './performance/userTiming';

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

export { BROWSER_NAVIGATION_TYPE_ATTRIBUTE } from './web-vitals/emitSpan';

export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './web-vitals/spans';

export { whenIdleOrHidden } from './web-vitals/utils';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/web-vitals/emitSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs';

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

// web-vitals reports a wider set of navigation types than the attribute defines. Only the states
// Navigation Timing cannot express keep their own value; every ordinary document navigation folds
Expand Down
36 changes: 36 additions & 0 deletions packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { _INTERNAL_ensureBrowserSpanStreaming, startIdleSpan, startInactiveSpan } from '@sentry/core/browser';
import {
addHistoryInstrumentationHandler,
BROWSER_NAVIGATION_TYPE_ATTRIBUTE,
addPerformanceEntries,
getLocationHref,
isBotUserAgent,
Expand Down Expand Up @@ -672,6 +673,41 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
{ url: to, isRedirect: navigationIsRedirect },
);
});

// 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.
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;
Comment thread
cursor[bot] marked this conversation as resolved.

startBrowserTracingNavigationSpan(
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.
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.
Comment on lines +694 to +704

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Multiple Sentry client initializations lead to duplicate pageshow event listeners on the global WINDOW object, causing redundant navigation spans to be created.
Severity: MEDIUM

Suggested Fix

To prevent duplicate listeners, add a guard within the afterAllSetup function to check if the pageshow listener has already been attached to the WINDOW object before adding it. This will ensure that only one listener is active, regardless of how many clients are initialized.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/browser/src/tracing/browserTracingIntegration.ts#L698-L720

Potential issue: Initializing multiple Sentry clients, which can occur in environments
like microfrontends or with hot reloading, causes the `afterAllSetup` hook to run for
each client instance. This hook adds a `pageshow` event listener to the global `WINDOW`
object. Because there is no check to see if a listener has already been attached,
multiple identical listeners accumulate. When a `pageshow` event with `persisted=true`
is fired by the browser, all of these accumulated listeners will execute, resulting in
the creation of duplicate navigation spans for a single user navigation.

Did we get this right? 👍 / 👎 to inform future reviews.

[BROWSER_NAVIGATION_TYPE_ATTRIBUTE]: 'bfcache',
},
},
{ url: WINDOW.location?.href },
);
});
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

Expand Down
97 changes: 97 additions & 0 deletions packages/browser/test/tracing/browserTracingIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getCurrentScope,
getDynamicSamplingContextFromSpan,
getMainCarrier,
metrics,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
Expand All @@ -31,6 +32,7 @@ import {
startBrowserTracingPageLoadSpan,
} from '../../src/tracing/browserTracingIntegration';
import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/linkedTraces';
import { bfcacheMetricsIntegration } from '../../src/integrations/bfcacheMetrics';
import * as webVitalsModule from '../../src/integrations/webVitals';
import { getDefaultBrowserClientOptions } from '../helper/browser-client-options';
import { SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
Expand Down Expand Up @@ -887,6 +889,101 @@ describe('browserTracingIntegration', () => {
});
});

describe('bfcache restores', () => {
function firePageShow(persisted: boolean): void {
const event = new Event('pageshow') as PageTransitionEvent;
Object.defineProperty(event, 'persisted', { value: persisted });
WINDOW.dispatchEvent(event);
}

function initClient(options = {}): BrowserClient {
const client = new BrowserClient(
getDefaultBrowserClientOptions({
tracesSampleRate: 1,
integrations: [browserTracingIntegration({ instrumentPageLoad: false, ...options })],
}),
);
setCurrentClient(client);
client.init();
return client;
}

it('starts a navigation span when the page is restored from the bfcache', () => {
initClient();

firePageShow(true);

const span = getActiveSpan()!;
expect(span).toBeDefined();
expect(spanToJSON(span).attributes).toEqual(
expect.objectContaining({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache',
'browser.navigation.type': 'bfcache',
}),
);
});

it('ignores a pageshow that is not a bfcache restore', () => {
initClient();

firePageShow(false);

expect(getActiveSpan()).toBeUndefined();
});

it('starts a new trace, rather than continuing the one from before the freeze', () => {
initClient();

firePageShow(true);
const firstTraceId = spanToJSON(getActiveSpan()!).trace_id;

vi.advanceTimersByTime(1600);
firePageShow(true);
const secondTraceId = spanToJSON(getActiveSpan()!).trace_id;

expect(firstTraceId).toBeDefined();
expect(secondTraceId).not.toBe(firstTraceId);
});

it('does not start a span when navigation instrumentation is off', () => {
initClient({ instrumentNavigation: false });

firePageShow(true);

expect(getActiveSpan()).toBeUndefined();
});

// Pins a known ordering problem rather than endorsing it. `bfcacheMetricsIntegration` registers its
// `pageshow` listener from `setupOnce`, which core always runs before every `afterAllSetup`,
// so its hit/miss metric is emitted before this navigation span exists and lands on the trace
// the page had before it was frozen. See the note on the pageshow handler.
it('emits the bfcache metric on the pre-freeze trace, before the navigation span exists', () => {
const countSpy = vi.spyOn(metrics, 'count').mockImplementation(() => {});
const client = new BrowserClient(
getDefaultBrowserClientOptions({
tracesSampleRate: 1,
integrations: [browserTracingIntegration({ instrumentPageLoad: false }), bfcacheMetricsIntegration()],
}),
);
setCurrentClient(client);
client.init();

const traceIdBeforeRestore = getCurrentScope().getPropagationContext().traceId;

let traceIdAtMetricTime: string | undefined;
countSpy.mockImplementation(() => {
traceIdAtMetricTime = getCurrentScope().getPropagationContext().traceId;
});

firePageShow(true);

const navigationTraceId = spanToJSON(getActiveSpan()!).trace_id;
expect(traceIdAtMetricTime).toBe(traceIdBeforeRestore);
expect(traceIdAtMetricTime).not.toBe(navigationTraceId);
});
});
Comment thread
cursor[bot] marked this conversation as resolved.

describe('startBrowserTracingNavigationSpan', () => {
it('works without integration setup', () => {
const client = new BrowserClient(
Expand Down
Loading