diff --git a/MIGRATION.md b/MIGRATION.md index ae5c2d0583d7..66a283adb042 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -575,6 +575,34 @@ Sentry.init({ }); ``` +### Web vitals are reported per soft navigation + +Affected SDKs: All SDKs running in the browser. + +`webVitalsIntegration` (auto-registered by `browserTracingIntegration`) now reports its own set of LCP, CLS and INP for every soft navigation the browser detects through the [Soft Navigations API](https://developer.chrome.com/docs/web-platform/soft-navigations-experiment), attributed to the navigation span it belongs to. + +This also changes how the initial page load is measured. Previously a page reported a single set of vitals that accumulated over the whole page lifetime. Now the page load's vitals are finalized at the first soft navigation, so **expect the values reported for page loads to drop** on apps that do client-side routing, most noticeably for CLS and INP. Aggregates such as p75s will shift after upgrading. + +Reporting per soft navigation requires span streaming (`traceLifecycle: 'stream'`, the default) and is ignored in browsers without support for the Soft Navigations API (Chromium 151+). Navigations the browser does not detect as soft navigations (programmatic navigations, navigations that never paint) report no vitals at all, so coverage is lower than for page loads. + +To keep the previous behaviour of one set of vitals for the whole page lifetime: + +```js +Sentry.init({ + integrations: [Sentry.browserTracingIntegration({ webVitals: { softNavigations: false } })], +}); +``` + +### CLS and LCP no longer report intermediate values + +Affected SDKs: All SDKs running in the browser. + +With soft navigation reporting enabled (the default, see above), the SDK no longer subscribes to every intermediate CLS and LCP update. `web-vitals` reports once per navigation, with the final value. + +This is required for per-navigation values to be correct: `web-vitals` skips any report with a zero delta, including the forced report at a navigation boundary, so subscribing to all changes means the page load never receives its final value. + +The visible effect is in Session Replay, which records `web-vital` breadcrumbs from the same instrumentation. Replays now contain one LCP and one CLS entry per navigation instead of one per intermediate update. Where soft navigation reporting is disabled or unsupported, the previous behaviour is unchanged. + ### `DOMException.code` is no longer set as a tag Affected SDKs: All SDKs running in the browser. diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 2f0fa0b60bb1..6ebd77451657 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -77,6 +77,7 @@ sentryTest( 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, 'sentry.segment.name': { value: 'test-url', type: 'string' }, 'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts index 1b84943103c8..fc57a1f137ba 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -77,6 +77,7 @@ sentryTest( 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, 'sentry.segment.name': { value: 'test-url', type: 'string' }, 'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' }, @@ -162,6 +163,7 @@ sentryTest( 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, 'sentry.segment.name': { value: 'test-url', type: 'string' }, 'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index 8c857c2bf134..2687f411aeed 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -78,6 +78,7 @@ sentryTest( 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.navigation.type': { value: 'navigate', type: 'string' }, // the parametrized route name flows onto the INP span 'sentry.transaction': { value: 'test-route', type: 'string' }, 'sentry.segment.name': { value: 'test-route', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index ecf4ae17a995..880d8b5ca605 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -78,6 +78,7 @@ sentryTest( 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.navigation.type': { value: 'navigate', type: 'string' }, // the parametrized route name flows onto the INP span 'sentry.transaction': { value: 'test-route', type: 'string' }, 'sentry.segment.name': { value: 'test-route', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index f6fc0c2e5d34..c48ffe6feb10 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -81,6 +81,7 @@ sentryTest( 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, 'sentry.segment.name': { value: 'test-url', type: 'string' }, 'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' }, @@ -147,6 +148,7 @@ sentryTest( 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, 'sentry.segment.name': { value: 'test-url', type: 'string' }, 'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' }, diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 7c185ee911de..92509d60fbe1 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -35,7 +35,7 @@ "dependencies": { "@sentry/core": "10.67.0", "@sentry/conventions": "^0.20.0", - "web-vitals": "^6.0.1" + "web-vitals": "^6.1.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 5e20876bcffc..5fa3ea37ebe5 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -5,8 +5,11 @@ export { addLcpInstrumentationHandler, addInpInstrumentationHandler, addFcpInstrumentationHandler, + enableSoftNavigationReporting, } from './instrumentation/performanceObserver'; +export { startSoftNavigationCorrelation, supportsSoftNavigations } from './web-vitals/softNavs'; + export { addPerformanceEntries, startTrackingLongTasks, startTrackingLongAnimationFrames } from './performance/entries'; export { diff --git a/packages/browser-utils/src/instrumentation/performanceObserver.ts b/packages/browser-utils/src/instrumentation/performanceObserver.ts index 9b74e59d3156..5c2b761b742e 100644 --- a/packages/browser-utils/src/instrumentation/performanceObserver.ts +++ b/packages/browser-utils/src/instrumentation/performanceObserver.ts @@ -9,6 +9,7 @@ type InstrumentHandlerTypePerformanceObserver = | 'paint' | 'resource' | 'element' + | 'soft-navigation' // fist-input is still needed for INP | 'first-input'; @@ -32,6 +33,16 @@ export interface PerformanceEventTiming extends PerformanceEntry { interactionId?: number; } +/** + * A `soft-navigation` entry, minted by the browser once a history change is followed by a + * confirming paint. `interactionId` is the id of the `PerformanceEventTiming` entry for the + * interaction that drove the navigation, which is how we join it back to a Sentry navigation span. + */ +export interface PerformanceSoftNavigation extends PerformanceEntry { + readonly interactionId: number; + readonly navigationId: number; +} + interface PerformanceScriptTiming extends PerformanceEntry { sourceURL: string; sourceFunctionName: string; @@ -47,6 +58,19 @@ export interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { // entrypoint carries a `declare global` block that references DOM globals not present in every // TypeScript lib version (e.g. `NavigationType`), which leaks into and breaks consumers on older // TS. Keeping this local keeps web-vitals' global augmentations out of our published types. +/** + * The navigation types web-vitals reports a metric for. Wider than the set the + * `browser.navigation.type` attribute uses - see `toBrowserNavigationType`. + */ +export type MetricNavigationType = + | 'navigate' + | 'reload' + | 'back-forward' + | 'back-forward-cache' + | 'prerender' + | 'restore' + | 'soft-navigation'; + interface Metric { /** * The name of the metric (in acronym form). @@ -95,14 +119,30 @@ interface Metric { * support that API). For pages that are restored from the bfcache, this * value will be 'back-forward-cache'. */ - navigationType: - | 'navigate' - | 'reload' - | 'back-forward' - | 'back-forward-cache' - | 'prerender' - | 'restore' - | 'soft-navigation'; + navigationType: MetricNavigationType; + + /** + * The id of the navigation the metric belongs to. For soft navigations this is the + * `navigationId` of the `soft-navigation` entry, otherwise it's the id of the hard navigation. + */ + navigationId: number; + + /** + * For soft navigations, the `interactionId` of the interaction that triggered the navigation. + */ + navigationInteractionId?: number; + + /** + * The start time the metric value is relative to. Non-zero for soft navigations, where the + * time origin is the triggering interaction rather than the start of the document. + */ + navigationStartTime?: number; + + /** + * The URL the metric was recorded for. Relevant for soft navigations, where a metric can be + * reported long after the URL has moved on. + */ + navigationURL?: string; } type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver; @@ -123,6 +163,31 @@ let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; let _previousFcp: Metric | undefined; +const stopListeners: Partial> = {}; + +let _reportSoftNavs = false; + +/** + * Opt the CLS, LCP and INP observers into reporting metrics for soft navigations. + * + * This also turns `reportAllChanges` off for CLS and LCP. web-vitals force-reports a metric when + * the navigation it belongs to is over, so without the intermediate updates every value a handler + * receives is already the final one for its navigation. That only holds because soft navigations + * are limited to span streaming, where CLS and LCP are sent as their own spans - the static + * lifecycle instead writes them onto the pageload span as it ends, which is what `reportAllChanges` + * was originally added for (#11934, #12360). + * + * Each observer is instrumented lazily, on its first handler, and web-vitals takes its options at + * that point only. So this has to be called before any of the `add*InstrumentationHandler` + * functions, otherwise it won't take effect for observers that are already running. + * + * On browsers without the Soft Navigation API this is a no-op: web-vitals feature-detects the API + * and keeps reporting hard-navigation metrics as usual. + */ +export function enableSoftNavigationReporting(): void { + _reportSoftNavs = true; +} + /** * Add a callback that will be triggered when a CLS metric is available. * Returns a cleanup callback which can be called to remove the instrumentation handler. @@ -255,7 +320,7 @@ function instrumentCls(): StopListening { }), // We want the callback to be called whenever the CLS value updates. // By default, the callback is only called when the tab goes to the background. - { reportAllChanges: true }, + { reportAllChanges: !_reportSoftNavs, reportSoftNavs: _reportSoftNavs }, ); } @@ -269,7 +334,7 @@ function instrumentLcp(): StopListening { }), // We want the callback to be called whenever the LCP value updates. // By default, the callback is only called when the tab goes to the background. - { reportAllChanges: true }, + { reportAllChanges: !_reportSoftNavs, reportSoftNavs: _reportSoftNavs }, ); } @@ -303,6 +368,7 @@ function instrumentInp(): StopListening { }); _previousInp = metric; }), + { reportSoftNavs: _reportSoftNavs }, ); } @@ -315,18 +381,24 @@ function addMetricObserver( ): CleanupHandlerCallback { addHandler(type, callback); - let stopListening: StopListening | undefined; - if (!instrumented[type]) { - stopListening = instrumentFn(); instrumented[type] = true; + // Deferred by a microtask rather than started here, because web-vitals reads its options once, + // when the observer is created. Registering a handler would otherwise pin those options for + // every other consumer of this observer, so whichever integration happened to run first would + // decide whether soft navigations and bfcache restores are reported. Client setup is + // synchronous, so every `enable*Reporting()` call has landed by the time this runs, and the + // observers are buffered so no entries are missed in the meantime. + void Promise.resolve().then(() => { + stopListeners[type] = instrumentFn(); + }); } if (previousValue) { callback({ metric: previousValue }); } - return getCleanupCallback(type, callback, stopOnCallback ? stopListening : undefined); + return getCleanupCallback(type, callback, stopOnCallback); } function instrumentPerformanceObserver(type: InstrumentHandlerTypePerformanceObserver): void { @@ -363,11 +435,13 @@ function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallb function getCleanupCallback( type: InstrumentHandlerType, callback: InstrumentHandlerCallback, - stopListening: StopListening, + stopOnCleanup = false, ): CleanupHandlerCallback { return () => { - if (stopListening) { - stopListening(); + // Looked up rather than captured: the observer is started in a microtask, so its stop function + // does not exist yet when this callback is built. + if (stopOnCleanup) { + stopListeners[type]?.(); } const typeHandlers = handlers[type]; diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts new file mode 100644 index 000000000000..d95815da3a76 --- /dev/null +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -0,0 +1,186 @@ +import type { Integration, Span, SpanAttributes } from '@sentry/core'; +import { + getClient, + getCurrentScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, +} from '@sentry/core'; +import { startInactiveSpan } from '@sentry/core/browser'; +import { + SENTRY_REPLAY_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRANSACTION, + USER_AGENT_ORIGINAL, +} from '@sentry/conventions/attributes'; +import { WINDOW } from '../types'; +import type { MetricNavigationType } from '../instrumentation/performanceObserver'; +import type { WebVitalReportEvent } from './reportEvents'; +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'; + +// 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 +// into `navigate`, including a back/forward that missed the bfcache and a discarded-tab restore. +const BROWSER_NAVIGATION_TYPES: Partial> = { + reload: 'reload', + prerender: 'prerender', + 'back-forward-cache': 'bfcache', + 'soft-navigation': 'soft-navigation', +}; + +function toBrowserNavigationType(navigationType: MetricNavigationType): string { + return BROWSER_NAVIGATION_TYPES[navigationType] ?? 'navigate'; +} + +// Locally-defined interfaces to avoid leaking bare global type references into the +// generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these +// available during this package's compilation but are NOT carried to consumers. +// This mirrors the pattern used for PerformanceEventTiming in instrument.ts. +export interface LayoutShift extends PerformanceEntry { + value: number; + sources: Array<{ node: Node | null }>; + hadRecentInput: boolean; +} + +export interface LargestContentfulPaint extends PerformanceEntry { + readonly renderTime: DOMHighResTimeStamp; + readonly loadTime: DOMHighResTimeStamp; + readonly size: number; + readonly id: string; + readonly url: string; + readonly element: Element | null; +} + +interface WebVitalSpanOptions { + name: string; + op: string; + origin: string; + metricName: 'lcp' | 'cls' | 'inp'; + value: number; + attributes?: SpanAttributes; + parentSpan?: Span; + reportEvent?: WebVitalReportEvent; + startTime: number; + endTime?: number; + /** Set when the vital was reported for a soft navigation rather than the initial page load. */ + softNavigationId?: number; + /** The navigation the vital was measured on, as reported by web-vitals. */ + navigationType?: MetricNavigationType; + /** + * When `true`, the span is sent on its own as a v2 streamed span instead of being folded into a + * transaction. Used for INP when span streaming is disabled (it reports late, so it can't ride + * the pageload transaction). + * + * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always streams. + */ + standalone?: boolean; +} + +/** + * Emits a web vital span. When `standalone` is set it is sent on its own as a v2 streamed span; + * otherwise it flows through the span streaming pipeline as a child of `parentSpan`. + */ +export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { + const { + name, + op, + origin, + metricName, + value, + attributes: passedAttributes, + parentSpan, + reportEvent, + startTime, + endTime, + standalone, + softNavigationId, + navigationType, + } = options; + + // Taken off the segment span itself, so it can't diverge from it: a routing instrumentation may + // rename that span (a pageload span is named `Pageload` until its route resolves), and the scope's + // transaction name is deliberately not kept in sync with it. Only a standalone span, which is sent + // without its segment span, has to fall back to the scope. + const segmentSpan = parentSpan && getRootSpan(parentSpan); + const segmentName = segmentSpan ? spanToJSON(segmentSpan).name : getCurrentScope().getScopeData().transactionName; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + [`browser.web_vital.${metricName}.value`]: value, + // oxlint-disable-next-line typescript-eslint/no-deprecated + [SENTRY_TRANSACTION]: segmentName, + [SENTRY_SEGMENT_NAME]: segmentName, + // Web vital score calculation relies on the user agent + [USER_AGENT_ORIGINAL]: WINDOW.navigator?.userAgent, + ...passedAttributes, + }; + + if (parentSpan && spanToJSON(parentSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'pageload') { + // for LCP and CLS, we collect the pageload span id as an attribute + attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId; + } + + if (reportEvent) { + attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent; + } + + if (softNavigationId != null) { + attributes[SOFT_NAVIGATION_ID_ATTRIBUTE] = softNavigationId; + } + + if (navigationType) { + attributes[BROWSER_NAVIGATION_TYPE_ATTRIBUTE] = toBrowserNavigationType(navigationType); + } + + // A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see + // `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it + // here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1. + // TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always + // streams, at which point Replay's `processSpan` runs and attaches the replay id. + if (standalone) { + Object.assign(attributes, getReplayAttributes()); + } + + const span = startInactiveSpan({ + name, + attributes, + startTime, + parentSpan, + // oxlint-disable-next-line typescript/no-deprecated -- intentional during the v1/v2 transition; see the TODO(standalone) above + experimental: standalone ? { standalone: true } : undefined, + }); + + if (span) { + span.end(endTime ?? startTime); + } +} + +interface ReplayIntegration extends Integration { + getReplayId: (onlyIfSampled?: boolean) => string | undefined; + getRecordingMode: () => 'session' | 'buffer' | undefined; +} + +// TODO(standalone): remove once the static (transaction) trace lifecycle is dropped; Replay's +// `processSpan` then attaches the replay id to the streamed INP span instead. +function getReplayAttributes(): SpanAttributes { + const replay = getClient()?.getIntegrationByName('Replay'); + const replayId = replay?.getReplayId(true); + if (!replayId) { + return {}; + } + + return { + [SENTRY_REPLAY_ID]: replayId, + // Not the `SENTRY_REPLAY_IS_BUFFERING` convention: that one has no `sentry._internal.` prefix, and + // the rest of the SDK sets the prefixed key (see `logs/internal.ts`, `metrics/internal.ts`). + 'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined, + }; +} diff --git a/packages/browser-utils/src/web-vitals/softNavs.ts b/packages/browser-utils/src/web-vitals/softNavs.ts new file mode 100644 index 000000000000..2b3ef135f02b --- /dev/null +++ b/packages/browser-utils/src/web-vitals/softNavs.ts @@ -0,0 +1,174 @@ +import type { Client, Span } from '@sentry/core'; +import { debug, LRUMap, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import type { PerformanceSoftNavigation } from '../instrumentation/performanceObserver'; +import { addPerformanceInstrumentationHandler, isPerformanceEventTiming } from '../instrumentation/performanceObserver'; +import { WINDOW } from '../types'; + +/** + * The browser's `navigationId` for the soft navigation a span belongs to. Set on the navigation + * span itself as well as on the web vital spans reported for it, so both sides of the correlation + * are visible in the product. + */ +export const SOFT_NAVIGATION_ID_ATTRIBUTE = 'browser.soft_navigation.id'; + +/** + * A page only ever needs its most recent navigations to still be joinable: web vitals for a soft + * navigation are finalized at the next soft navigation or on pagehide, never later than that. + */ +const MAX_TRACKED_NAVIGATIONS = 5; + +/** + * Tolerance when matching a DOM event's `timeStamp` against the `startTime` of its Event Timing + * entry. Both are `DOMHighResTimeStamp`s from the same clock, so this only absorbs rounding. + */ +const INTERACTION_MATCH_TOLERANCE_MS = 5; + +interface SoftNavMetric { + navigationType: string; + navigationId: number; + navigationInteractionId?: number; +} + +interface PendingNavigation { + span: Span; + interactionTimestamp: number; +} + +// The navigation span whose triggering interaction we haven't identified yet. +let _pendingNavigation: PendingNavigation | undefined; +// The timestamp of the most recent trusted click/keydown, i.e. our best guess at the interaction +// that a history change happening right now was driven by. +let _lastInteractionTimestamp: number | undefined; + +const _interactionIdToNavigationSpan = new LRUMap(MAX_TRACKED_NAVIGATIONS); +const _navigationIdToNavigationSpan = new LRUMap(MAX_TRACKED_NAVIGATIONS); + +let _correlationStarted = false; + +/** + * Whether the browser can report web vitals for soft navigations. + * + * This mirrors web-vitals' own feature detection: passing `reportSoftNavs` on a browser that fails + * this check is a no-op there, so it has to be a no-op here too. + */ +export function supportsSoftNavigations(): boolean { + try { + return ( + PerformanceObserver.supportedEntryTypes.includes('soft-navigation') && + // Older implementations exposed this as an attribute rather than a method. Only the method + // form shipped unflagged, so it's what web-vitals gates on. + typeof ( + WINDOW as { + PerformanceSoftNavigation?: { prototype?: { getLargestInteractionContentfulPaint?: unknown } }; + } + ).PerformanceSoftNavigation?.prototype?.getLargestInteractionContentfulPaint === 'function' + ); + } catch { + return false; + } +} + +/** + * Start correlating the browser's soft navigations with the SDK's navigation spans. + * + * A navigation span is created synchronously on the history change, but the browser only mints the + * `soft-navigation` entry (and with it the `navigationId` that web vitals are reported against) + * once the navigation has been confirmed by a paint. So the `navigationId` cannot be known at span + * creation time and the two have to be joined after the fact. + * + * The join key is the `interactionId` of the interaction that drove the navigation: per the Soft + * Navigations spec the `soft-navigation` entry carries the `interactionId` of the interaction that + * triggered it, which is the same id the interaction's own `PerformanceEventTiming` entry carries. + * So we bind a navigation span to the interaction it happened during, and the soft navigation + * joins back to that span through the shared id. + * + * This is inherently partial. Navigations that don't meet the browser's soft navigation heuristic + * (programmatic navigations, navigations that never paint, back/forward from the browser chrome) + * produce no entry at all, so those navigation spans simply have no web vitals. + */ +export function startSoftNavigationCorrelation(client: Client): void { + if (_correlationStarted || !supportsSoftNavigations()) { + return; + } + _correlationStarted = true; + + const onInteraction = (event: Event): void => { + if (event.isTrusted) { + _lastInteractionTimestamp = event.timeStamp; + } + }; + // Only click and keydown can start a soft navigation, which is also what the SDK's redirect + // detection listens for. + WINDOW.addEventListener('click', onInteraction, { capture: true, passive: true }); + WINDOW.addEventListener('keydown', onInteraction, { capture: true, passive: true }); + + client.on('spanStart', span => { + if (spanToJSON(span).attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'navigation') { + return; + } + + // A navigation with no preceding interaction can't produce a soft navigation, so there is + // nothing to wait for. Dropping the pending span here also keeps us from binding a stale one. + _pendingNavigation = + _lastInteractionTimestamp != null ? { span, interactionTimestamp: _lastInteractionTimestamp } : undefined; + }); + + const bindInteractionToNavigationSpan = ({ entries }: { entries: PerformanceEntry[] }): void => { + for (const entry of entries) { + const pending = _pendingNavigation; + if (!pending || !isPerformanceEventTiming(entry) || !entry.interactionId) { + continue; + } + + if (Math.abs(entry.startTime - pending.interactionTimestamp) > INTERACTION_MATCH_TOLERANCE_MS) { + continue; + } + + _interactionIdToNavigationSpan.set(entry.interactionId, pending.span); + _pendingNavigation = undefined; + } + }; + + // `durationThreshold: 0` is applied for `event` by the shared observer, which matters here: + // interactions below the 104ms default would otherwise never surface an `interactionId`. + addPerformanceInstrumentationHandler('event', bindInteractionToNavigationSpan); + addPerformanceInstrumentationHandler('first-input', bindInteractionToNavigationSpan); + + addPerformanceInstrumentationHandler('soft-navigation', ({ entries }) => { + for (const entry of entries as PerformanceSoftNavigation[]) { + const span = _interactionIdToNavigationSpan.get(entry.interactionId); + if (!span) { + DEBUG_BUILD && debug.log(`[SoftNav] No navigation span found for soft navigation ${entry.navigationId}`, entry); + continue; + } + + _navigationIdToNavigationSpan.set(entry.navigationId, span); + // Best effort: the soft navigation entry usually lands well within the navigation span's idle + // window, but if the span has already been sent this attribute is dropped. + span.setAttribute(SOFT_NAVIGATION_ID_ATTRIBUTE, entry.navigationId); + } + }); +} + +/** + * The navigation span a soft navigation web vital belongs to, or `undefined` if the metric isn't + * for a soft navigation or we failed to correlate it. + */ +export function getNavigationSpanForMetric(metric: SoftNavMetric): Span | undefined { + if (metric.navigationType !== 'soft-navigation') { + return undefined; + } + + const span = _navigationIdToNavigationSpan.get(metric.navigationId); + if (span) { + return span; + } + + // The `soft-navigation` observer may not have run for this navigation yet - entries from + // different observers aren't delivered in a guaranteed order - so fall back to the join key the + // metric carries itself. + return metric.navigationInteractionId != null + ? _interactionIdToNavigationSpan.get(metric.navigationInteractionId) + : undefined; +} diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 456a07701b3c..4a0f22e95cf5 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -1,36 +1,31 @@ -import type { Client, Integration, Span, SpanAttributes } from '@sentry/core'; +import type { Client, Span, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, getActiveSpan, - getClient, - getCurrentScope, getRootSpan, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - spanToJSON, timestampInSeconds, } from '@sentry/core'; -import { startInactiveSpan } from '@sentry/core/browser'; import { DEBUG_BUILD } from '../debug-build'; import { htmlTreeAsString } from '../htmlTreeAsString'; -import { WINDOW } from '../types'; import type { InteractionType } from './inp'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; -import type { InstrumentationHandlerCallback } from '../instrumentation/performanceObserver'; +import type { InstrumentationHandlerCallback, MetricNavigationType } from '../instrumentation/performanceObserver'; import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler, } from '../instrumentation/performanceObserver'; +import type { LargestContentfulPaint, LayoutShift } from './emitSpan'; +import { _emitWebVitalSpan } from './emitSpan'; import { isValidLcpMetric } from './lcp'; import type { WebVitalReportEvent } from './reportEvents'; import { listenForWebVitalReportEvents } from './reportEvents'; +import { getNavigationSpanForMetric } from './softNavs'; import { getBrowserPerformanceAPI, msToSec, supportsWebVital } from '../performance/utils'; import type { PerformanceEventTiming } from '../instrumentation/performanceObserver'; -import { SENTRY_SEGMENT_NAME, SENTRY_TRANSACTION } from '@sentry/conventions/attributes'; import { UI_INTERACTION_CLICK, UI_INTERACTION_DRAG, @@ -47,149 +42,78 @@ const INTERACTION_TYPE_TO_SPAN_OP: Record = { press: UI_INTERACTION_PRESS, }; -// Locally-defined interfaces to avoid leaking bare global type references into the -// generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these -// available during this package's compilation but are NOT carried to consumers. -// This mirrors the pattern used for PerformanceEventTiming in instrument.ts. -export interface LayoutShift extends PerformanceEntry { - value: number; - sources: Array<{ node: Node | null }>; - hadRecentInput: boolean; -} - -export interface LargestContentfulPaint extends PerformanceEntry { - readonly renderTime: DOMHighResTimeStamp; - readonly loadTime: DOMHighResTimeStamp; - readonly size: number; - readonly id: string; - readonly url: string; - readonly element: Element | null; -} - -interface WebVitalSpanOptions { - name: string; - op: string; - origin: string; - metricName: 'lcp' | 'cls' | 'inp'; - value: number; - attributes?: SpanAttributes; - parentSpan?: Span; - reportEvent?: WebVitalReportEvent; - startTime: number; - endTime?: number; - /** - * When `true`, the span is sent on its own as a v2 streamed span instead of being folded into a - * transaction. Used for INP when span streaming is disabled (it reports late, so it can't ride - * the pageload transaction). - * - * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always streams. - */ - standalone?: boolean; -} +type WebVitalMetric = Parameters[0]>[0]['metric']; +type InpMetric = Parameters[0]['metric']; /** - * Emits a web vital span. When `standalone` is set it is sent on its own as a v2 streamed span; - * otherwise it flows through the span streaming pipeline as a child of `parentSpan`. + * Reports a web vital once per navigation, for browsers reporting soft navigations. + * + * With `reportSoftNavs`, web-vitals restarts the metric on every soft navigation and force-reports + * the previous one just before it does (and again on pagehide). Since we also drop + * `reportAllChanges` in this mode, every value we're handed is already the final one for its + * navigation, so there is nothing to accumulate: each report is a span. */ -export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { - const { - name, - op, - origin, - metricName, - value, - attributes: passedAttributes, - parentSpan, - reportEvent, - startTime, - endTime, - standalone, - } = options; - - // Taken off the segment span itself, so it can't diverge from it: a routing instrumentation may - // rename that span (a pageload span is named `Pageload` until its route resolves), and the scope's - // transaction name is deliberately not kept in sync with it. Only a standalone span, which is sent - // without its segment span, has to fall back to the scope. - const segmentSpan = parentSpan && getRootSpan(parentSpan); - const segmentName = segmentSpan ? spanToJSON(segmentSpan).name : getCurrentScope().getScopeData().transactionName; - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, - [`browser.web_vital.${metricName}.value`]: value, - // oxlint-disable-next-line typescript-eslint/no-deprecated - [SENTRY_TRANSACTION]: segmentName, - [SENTRY_SEGMENT_NAME]: segmentName, - // Web vital score calculation relies on the user agent - 'user_agent.original': WINDOW.navigator?.userAgent, - ...passedAttributes, - }; - - if (parentSpan && spanToJSON(parentSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'pageload') { - // for LCP and CLS, we collect the pageload span id as an attribute - attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId; - } - - if (reportEvent) { - attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent; - } - - // A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see - // `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it - // here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1. - // TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always - // streams, at which point Replay's `processSpan` runs and attaches the replay id. - if (standalone) { - Object.assign(attributes, getReplayAttributes()); - } - - const span = startInactiveSpan({ - name, - attributes, - startTime, - parentSpan, - // oxlint-disable-next-line typescript/no-deprecated -- intentional during the v1/v2 transition; see the TODO(standalone) above - experimental: standalone ? { standalone: true } : undefined, +function trackWebVitalPerNavigation( + client: Client, + addInstrumentationHandler: (callback: (data: { metric: M }) => void) => unknown, + send: (metric: M, parentSpan: Span | undefined, softNavigationId: number | undefined) => void, +): void { + let pageloadSpan: Span | undefined; + client.on('afterStartPageLoadSpan', span => { + pageloadSpan = span; }); - if (span) { - span.end(endTime ?? startTime); - } -} - -interface ReplayIntegration extends Integration { - getReplayId: (onlyIfSampled?: boolean) => string | undefined; - getRecordingMode: () => 'session' | 'buffer' | undefined; -} - -// TODO(standalone): remove once the static (transaction) trace lifecycle is dropped; Replay's -// `processSpan` then attaches the replay id to the streamed INP span instead. -function getReplayAttributes(): SpanAttributes { - const replay = getClient()?.getIntegrationByName('Replay'); - const replayId = replay?.getReplayId(true); - if (!replayId) { - return {}; - } + addInstrumentationHandler(({ metric }) => { + const navigationSpan = getNavigationSpanForMetric(metric); + if (metric.navigationType === 'soft-navigation') { + // Reporting an uncorrelated soft navigation vital would attribute it to the wrong route, so + // it's dropped instead. + if (navigationSpan) { + send(metric, navigationSpan, metric.navigationId); + } else { + DEBUG_BUILD && + debug.log(`[SoftNav] Dropping ${metric.name} for uncorrelated soft navigation ${metric.navigationId}`); + } + return; + } - return { - 'sentry.replay_id': replayId, - 'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined, - }; + send(metric, pageloadSpan, undefined); + }); } /** * Tracks LCP as a streamed span. */ -export function trackLcpAsSpan(client: Client): void { - let lcpValue = 0; - let lcpEntry: LargestContentfulPaint | undefined; - +export function trackLcpAsSpan(client: Client, reportSoftNavs = false): void { if (!supportsWebVital('largest-contentful-paint')) { return; } + if (reportSoftNavs) { + trackWebVitalPerNavigation(client, addLcpInstrumentationHandler, (metric, parentSpan, softNavigationId) => { + const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; + _sendLcpSpan( + metric.value, + entry, + parentSpan, + undefined, + softNavigationId, + metric.navigationType, + metric.navigationStartTime, + ); + }); + return; + } + + let lcpValue = 0; + let lcpEntry: LargestContentfulPaint | undefined; + let lcpNavigationType: MetricNavigationType | undefined; + const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { + // The navigation type describes the page, not the entry, so it is worth keeping even for a + // report we otherwise discard. + lcpNavigationType = metric.navigationType; + const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; if (!entry || !isValidLcpMetric(metric.value)) { return; @@ -199,7 +123,7 @@ export function trackLcpAsSpan(client: Client): void { }, true); listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { - _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent); + _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent, undefined, lcpNavigationType); cleanupLcpHandler(); }); } @@ -212,6 +136,9 @@ export function _sendLcpSpan( entry: LargestContentfulPaint | undefined, pageloadSpan?: Span, reportEvent?: WebVitalReportEvent, + softNavigationId?: number, + navigationType?: MetricNavigationType, + navigationStartTime?: number, ): void { if (!isValidLcpMetric(lcpValue)) { return; @@ -220,8 +147,13 @@ export function _sendLcpSpan( DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0; - const timeOrigin = msToSec(performanceTimeOrigin); - const endTime = msToSec(performanceTimeOrigin + (entry?.startTime || 0)); + // A soft navigation's LCP is measured from the triggering interaction, not the document time + // origin. Starting the span there too keeps it inside the navigation span it is parented to and + // keeps its duration equal to the value it reports. + const startTime = msToSec(performanceTimeOrigin + (navigationStartTime || 0)); + // Without an entry there is no render time to end at, so the span lasts the value it reports, + // like an entry-less INP does. Ending at the time origin instead would invert the span. + const endTime = entry ? msToSec(performanceTimeOrigin + entry.startTime) : startTime + msToSec(lcpValue); const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; const attributes: SpanAttributes = {}; @@ -242,23 +174,46 @@ export function _sendLcpSpan( attributes, parentSpan: pageloadSpan, reportEvent, - startTime: timeOrigin, + startTime, endTime, + softNavigationId, + navigationType, }); } /** * Tracks CLS as a streamed span. */ -export function trackClsAsSpan(client: Client): void { - let clsValue = 0; - let clsEntry: LayoutShift | undefined; - +export function trackClsAsSpan(client: Client, reportSoftNavs = false): void { if (!supportsWebVital('layout-shift')) { return; } + if (reportSoftNavs) { + trackWebVitalPerNavigation(client, addClsInstrumentationHandler, (metric, parentSpan, softNavigationId) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + _sendClsSpan( + metric.value, + entry, + parentSpan, + undefined, + softNavigationId, + metric.navigationType, + metric.navigationStartTime, + ); + }); + return; + } + + let clsValue = 0; + let clsEntry: LayoutShift | undefined; + let clsNavigationType: MetricNavigationType | undefined; + const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { + // A CLS of 0 is reported with no entries and still emits a span, so the navigation type has to + // be captured before the entry check rather than alongside the value. + clsNavigationType = metric.navigationType; + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; if (!entry) { return; @@ -268,7 +223,7 @@ export function trackClsAsSpan(client: Client): void { }, true); listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { - _sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent); + _sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent, undefined, clsNavigationType); cleanupClsHandler(); }); } @@ -281,10 +236,18 @@ export function _sendClsSpan( entry: LayoutShift | undefined, pageloadSpan?: Span, reportEvent?: WebVitalReportEvent, + softNavigationId?: number, + navigationType?: MetricNavigationType, + navigationStartTime?: number, ): void { DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); - const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); + const performanceTimeOrigin = browserPerformanceTimeOrigin(); + // A CLS of 0 has no shift to place the span at. It is reported when the navigation it was + // measured on is already over - the next soft navigation, or pagehide - so the current time would + // land it outside that navigation, on the route that follows it. + const offset = entry?.startTime ?? navigationStartTime ?? 0; + const startTime = performanceTimeOrigin ? msToSec(performanceTimeOrigin + offset) : timestampInSeconds(); const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; const attributes: SpanAttributes = {}; @@ -305,6 +268,8 @@ export function _sendClsSpan( parentSpan: pageloadSpan, reportEvent, startTime, + softNavigationId, + navigationType, }); } @@ -313,7 +278,7 @@ export function _sendClsSpan( * Requires `registerInpInteractionListener()` to be called separately for cached element names and * root spans per interaction. */ -export function trackInpAsSpan(client: Client): void { +export function trackInpAsSpan(client: Client, reportSoftNavs = false): void { const performance = getBrowserPerformanceAPI(); if (!performance || !browserPerformanceTimeOrigin()) { return; @@ -326,49 +291,77 @@ export function trackInpAsSpan(client: Client): void { // TODO(standalone): once the static trace lifecycle is dropped, INP always streams; drop this flag. const standalone = !hasSpanStreamingEnabled(client); - const onInp: InstrumentationHandlerCallback = ({ metric }) => { - if (metric.value == null) { - return; - } - - const duration = msToSec(metric.value); - - if (duration > MAX_PLAUSIBLE_INP_DURATION) { - return; - } - - const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]); + if (reportSoftNavs) { + // INP restarts per navigation and reports once that navigation is over, by which point the + // navigation span has ended and the interaction cache no longer knows about it. The metric + // says which navigation it belongs to, so INP is attributed exactly like LCP and CLS. + trackWebVitalPerNavigation(client, addInpInstrumentationHandler, (metric, parentSpan, softNavigationId) => { + if (isPlausibleInp(metric)) { + _sendInpSpan(metric.value, findInpEntry(metric), standalone, parentSpan, softNavigationId, metric); + } + }); + return; + } - if (!entry) { - return; + const onInp: InstrumentationHandlerCallback = ({ metric }) => { + if (isPlausibleInp(metric)) { + _sendInpSpan(metric.value, findInpEntry(metric), standalone, undefined, undefined, metric); } - - _sendInpSpan(metric.value, entry, standalone); }; addInpInstrumentationHandler(onInp); } +function isPlausibleInp(metric: InpMetric): boolean { + return metric.value != null && msToSec(metric.value) <= MAX_PLAUSIBLE_INP_DURATION; +} + +/** + * The entry an INP span is built from: the one whose duration the reported value came from. + * + * There isn't always one. When every interaction of a soft navigation stayed below the Event Timing + * threshold, web-vitals reports a synthetic value with no entries at all - see + * `_estimateP98LongestInteraction`. The span still gets reported in that case, just without the + * element and interaction type an entry would have supplied. + */ +function findInpEntry(metric: InpMetric): PerformanceEventTiming | undefined { + return metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]); +} + /** * Exported only for testing. */ -export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming, standalone = false): void { +export function _sendInpSpan( + inpValue: number, + entry: PerformanceEventTiming | undefined, + standalone = false, + attributedSpan?: Span, + softNavigationId?: number, + metric?: InpMetric, +): void { DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`); - const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); + // A web vital span carries the metric, not a real interaction timing, so an INP without an entry + // is still worth reporting. It just has no element or interaction type to describe, and is placed + // at the start of the navigation it belongs to rather than at the interaction. + const startTime = msToSec( + (browserPerformanceTimeOrigin() as number) + (entry?.startTime ?? metric?.navigationStartTime ?? 0), + ); const duration = msToSec(inpValue); - const interactionType = INP_ENTRY_MAP[entry.name]; - - if (!interactionType) { - return; - } + // An INP without an entry has no interaction type to report. It still has to land inside the + // `ui.interaction.*` family, because falling outside it would hide exactly the fast navigations + // that web-vitals synthesizes these values for (GoogleChrome/web-vitals#724), reintroducing the + // reporting bias they were added to remove. + const interactionType = (entry && INP_ENTRY_MAP[entry.name]) || 'click'; - const cachedContext = getCachedInteractionContext(entry.interactionId); + const cachedContext = entry && getCachedInteractionContext(entry.interactionId); const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const spanToUse = cachedContext?.span || rootSpan; - const name = cachedContext?.elementName || htmlTreeAsString(entry.target); + // With soft navigations the caller knows exactly which navigation the metric belongs to. Without + // them we fall back to the span that was active when the interaction was observed. + const spanToUse = attributedSpan || cachedContext?.span || rootSpan; + const name = cachedContext?.elementName || (entry ? htmlTreeAsString(entry.target) : 'Interaction to next paint'); _emitWebVitalSpan({ name, @@ -377,11 +370,13 @@ export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming, st metricName: 'inp', value: inpValue, attributes: { - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, }, startTime, endTime: startTime + duration, + navigationType: metric?.navigationType, parentSpan: spanToUse, standalone, + softNavigationId, }); } diff --git a/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts b/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts new file mode 100644 index 000000000000..abaf22003625 --- /dev/null +++ b/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The web vital observers are shared: whoever registers the first handler used to create them, which +// froze web-vitals' options for every other consumer. Replay registers its handlers from its own +// `afterAllSetup`, so ordering it before `browserTracingIntegration` used to pin the observers to +// `reportSoftNavs: false` and silently drop soft navigation vitals. +describe('metric observer instrumentation ordering', () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock('web-vitals'); + }); + + async function loadWithSpies() { + const onCLS = vi.fn(); + vi.doMock('web-vitals', () => ({ + onCLS, + onLCP: vi.fn(), + onINP: vi.fn(), + onTTFB: vi.fn(), + onFCP: vi.fn(), + })); + return { onCLS, mod: await import('../../src/instrumentation/performanceObserver') }; + } + + // Both orders have to reach web-vitals with the same options; only the settling is awaited. + const settled = () => new Promise(resolve => setTimeout(resolve, 0)); + + it('reports soft navigations when enabled before any handler is added', async () => { + const { onCLS, mod } = await loadWithSpies(); + + mod.enableSoftNavigationReporting(); + mod.addClsInstrumentationHandler(() => {}); + await settled(); + + expect(onCLS).toHaveBeenCalledWith(expect.any(Function), { reportAllChanges: false, reportSoftNavs: true }); + }); + + it('reports soft navigations when enabled after a handler is already added', async () => { + const { onCLS, mod } = await loadWithSpies(); + + // Stands in for Replay, which registers before the tracing side has opted in. + mod.addClsInstrumentationHandler(() => {}); + mod.enableSoftNavigationReporting(); + await settled(); + + expect(onCLS).toHaveBeenCalledWith(expect.any(Function), { reportAllChanges: false, reportSoftNavs: true }); + }); + + it('starts the observer once no matter how many handlers register', async () => { + const { onCLS, mod } = await loadWithSpies(); + + mod.addClsInstrumentationHandler(() => {}); + mod.addClsInstrumentationHandler(() => {}); + await settled(); + + expect(onCLS).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/browser-utils/test/web-vitals/softNavs.test.ts b/packages/browser-utils/test/web-vitals/softNavs.test.ts new file mode 100644 index 000000000000..b329d23ef2f6 --- /dev/null +++ b/packages/browser-utils/test/web-vitals/softNavs.test.ts @@ -0,0 +1,170 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const windowListeners = vi.hoisted(() => new Map void>()); +const performanceHandlers = vi.hoisted(() => new Map void>()); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { ...actual, spanToJSON: vi.fn() }; +}); + +vi.mock('../../src/types', () => ({ + WINDOW: { + addEventListener: (type: string, listener: (event: unknown) => void) => windowListeners.set(type, listener), + PerformanceSoftNavigation: { prototype: { getLargestInteractionContentfulPaint: () => null } }, + }, +})); + +vi.mock('../../src/instrumentation/performanceObserver', async () => { + const actual = await vi.importActual('../../src/instrumentation/performanceObserver'); + return { + ...actual, + addPerformanceInstrumentationHandler: (type: string, callback: (data: { entries: unknown[] }) => void) => { + performanceHandlers.set(type, callback); + return () => undefined; + }, + }; +}); + +function createMockSpan(op: string) { + vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: { 'sentry.op': op } } as never); + return { setAttribute: vi.fn() }; +} + +function createMockClient() { + const hooks = new Map void>(); + return { + client: { on: (hook: string, callback: (...args: never[]) => void) => hooks.set(hook, callback) }, + startSpan: (span: unknown) => hooks.get('spanStart')?.(span as never), + }; +} + +/** Each test needs a fresh module: the correlation state is per page, so it's module-level. */ +async function loadSoftNavs() { + vi.resetModules(); + return import('../../src/web-vitals/softNavs'); +} + +describe('soft navigation correlation', () => { + beforeEach(() => { + windowListeners.clear(); + performanceHandlers.clear(); + vi.stubGlobal('PerformanceObserver', { supportedEntryTypes: ['event', 'soft-navigation'] }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('correlates a soft navigation to the navigation span its interaction triggered', async () => { + const { getNavigationSpanForMetric, SOFT_NAVIGATION_ID_ATTRIBUTE, startSoftNavigationCorrelation } = + await loadSoftNavs(); + const { client, startSpan } = createMockClient(); + + startSoftNavigationCorrelation(client as never); + + windowListeners.get('click')?.({ isTrusted: true, timeStamp: 1234 }); + const navigationSpan = createMockSpan('navigation'); + startSpan(navigationSpan); + + performanceHandlers.get('event')?.({ entries: [{ duration: 8, startTime: 1234, interactionId: 42 }] }); + performanceHandlers.get('soft-navigation')?.({ entries: [{ navigationId: 7, interactionId: 42 }] }); + + expect(navigationSpan.setAttribute).toHaveBeenCalledWith(SOFT_NAVIGATION_ID_ATTRIBUTE, 7); + expect(getNavigationSpanForMetric({ navigationType: 'soft-navigation', navigationId: 7 })).toBe(navigationSpan); + }); + + it('falls back to the interaction id when the soft navigation entry has not been observed yet', async () => { + const { getNavigationSpanForMetric, startSoftNavigationCorrelation } = await loadSoftNavs(); + const { client, startSpan } = createMockClient(); + + startSoftNavigationCorrelation(client as never); + + windowListeners.get('click')?.({ isTrusted: true, timeStamp: 1234 }); + const navigationSpan = createMockSpan('navigation'); + startSpan(navigationSpan); + + performanceHandlers.get('event')?.({ entries: [{ duration: 8, startTime: 1234, interactionId: 42 }] }); + + expect( + getNavigationSpanForMetric({ navigationType: 'soft-navigation', navigationId: 7, navigationInteractionId: 42 }), + ).toBe(navigationSpan); + }); + + it('does not bind an interaction that the navigation did not happen during', async () => { + const { getNavigationSpanForMetric, startSoftNavigationCorrelation } = await loadSoftNavs(); + const { client, startSpan } = createMockClient(); + + startSoftNavigationCorrelation(client as never); + + windowListeners.get('click')?.({ isTrusted: true, timeStamp: 1234 }); + const navigationSpan = createMockSpan('navigation'); + startSpan(navigationSpan); + + // An earlier, unrelated interaction whose entry is only delivered now. + performanceHandlers.get('event')?.({ entries: [{ duration: 8, startTime: 500, interactionId: 1 }] }); + performanceHandlers.get('soft-navigation')?.({ entries: [{ navigationId: 7, interactionId: 1 }] }); + + expect(navigationSpan.setAttribute).not.toHaveBeenCalled(); + expect(getNavigationSpanForMetric({ navigationType: 'soft-navigation', navigationId: 7 })).toBeUndefined(); + }); + + it('ignores navigations that did not follow an interaction', async () => { + const { getNavigationSpanForMetric, startSoftNavigationCorrelation } = await loadSoftNavs(); + const { client, startSpan } = createMockClient(); + + startSoftNavigationCorrelation(client as never); + + startSpan(createMockSpan('navigation')); + performanceHandlers.get('event')?.({ entries: [{ duration: 8, startTime: 1234, interactionId: 42 }] }); + + expect( + getNavigationSpanForMetric({ navigationType: 'soft-navigation', navigationId: 7, navigationInteractionId: 42 }), + ).toBeUndefined(); + }); + + it('ignores spans that are not navigations', async () => { + const { getNavigationSpanForMetric, startSoftNavigationCorrelation } = await loadSoftNavs(); + const { client, startSpan } = createMockClient(); + + startSoftNavigationCorrelation(client as never); + + windowListeners.get('click')?.({ isTrusted: true, timeStamp: 1234 }); + startSpan(createMockSpan('pageload')); + performanceHandlers.get('event')?.({ entries: [{ duration: 8, startTime: 1234, interactionId: 42 }] }); + + expect( + getNavigationSpanForMetric({ navigationType: 'soft-navigation', navigationId: 7, navigationInteractionId: 42 }), + ).toBeUndefined(); + }); + + it('does not correlate metrics that are not for a soft navigation', async () => { + const { getNavigationSpanForMetric, startSoftNavigationCorrelation } = await loadSoftNavs(); + const { client, startSpan } = createMockClient(); + + startSoftNavigationCorrelation(client as never); + + windowListeners.get('click')?.({ isTrusted: true, timeStamp: 1234 }); + startSpan(createMockSpan('navigation')); + performanceHandlers.get('event')?.({ entries: [{ duration: 8, startTime: 1234, interactionId: 42 }] }); + performanceHandlers.get('soft-navigation')?.({ entries: [{ navigationId: 7, interactionId: 42 }] }); + + expect(getNavigationSpanForMetric({ navigationType: 'navigate', navigationId: 7 })).toBeUndefined(); + }); + + it('is a no-op in browsers without the Soft Navigations API', async () => { + vi.stubGlobal('PerformanceObserver', { supportedEntryTypes: ['event'] }); + + const { startSoftNavigationCorrelation, supportsSoftNavigations } = await loadSoftNavs(); + const { client } = createMockClient(); + + expect(supportsSoftNavigations()).toBe(false); + + startSoftNavigationCorrelation(client as never); + + expect(windowListeners.size).toBe(0); + expect(performanceHandlers.size).toBe(0); + }); +}); diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index 802add7e1c7b..f48077199cb7 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -5,12 +5,16 @@ import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import * as inpModule from '../../src/web-vitals/inp'; import * as instrument from '../../src/instrumentation/performanceObserver'; import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/web-vitals/lcp'; +import { _emitWebVitalSpan } from '../../src/web-vitals/emitSpan'; +import * as reportEvents from '../../src/web-vitals/reportEvents'; +import * as softNavs from '../../src/web-vitals/softNavs'; import { - _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan, + trackClsAsSpan, trackInpAsSpan, + trackLcpAsSpan, } from '../../src/web-vitals/spans'; vi.mock('@sentry/core', async () => { @@ -327,6 +331,50 @@ describe('_emitWebVitalSpan', () => { }); }).not.toThrow(); }); + + it.each([ + ['navigate', 'navigate'], + ['reload', 'reload'], + ['prerender', 'prerender'], + ['soft-navigation', 'soft-navigation'], + ['back-forward-cache', 'bfcache'], + // Ordinary document navigations the attribute has no separate value for. + ['back-forward', 'navigate'], + ['restore', 'navigate'], + ] as const)('reports navigationType %s as browser.navigation.type %s', (navigationType, expected) => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: 50, + startTime: 1.0, + navigationType, + }); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'browser.navigation.type': expected }), + }), + ); + }); + + it('omits browser.navigation.type when the navigation type is unknown', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: 50, + startTime: 1.0, + }); + + expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'browser.navigation.type': expect.anything() }), + }), + ); + }); }); describe('_sendLcpSpan', () => { @@ -409,6 +457,15 @@ describe('_sendLcpSpan', () => { ); }); + it('lasts the reported value when there is no entry to end at', () => { + // A soft navigation 2000ms into the page. Ending at the time origin would put the end before + // the start. + _sendLcpSpan(250, undefined, undefined, undefined, 2, 'soft-navigation', 2000); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ startTime: 3 })); + expect(mockSpan.end).toHaveBeenCalledWith(3.25); + }); + it('drops implausible LCP values', () => { _sendLcpSpan(0, undefined); _sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined); @@ -489,17 +546,26 @@ describe('_sendClsSpan', () => { ); }); - it('sends a streamed CLS span without entry data', () => { + it('anchors a CLS span without entry data to the start of the navigation', () => { _sendClsSpan(0, undefined); - expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'Layout shift', - startTime: 1.5, + // timeOrigin 1000 / 1000, i.e. the start of the page rather than the report time + startTime: 1, }), ); }); + + it('falls back to the current time when there is no performance time origin', () => { + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined); + + _sendClsSpan(0, undefined); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ startTime: 1.5 })); + }); }); describe('_sendInpSpan', () => { @@ -683,9 +749,224 @@ describe('trackInpAsSpan', () => { expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalled(); }); - it('ignores INP metrics without a matching interaction entry', () => { + it('reports INP without an interaction entry to describe it', () => { + // web-vitals decides what an INP is. When it reports a value we have no entry for, we still + // report the value it gave us rather than second-guessing the library. trackInpAsSpan(streamingClient); inpCallback({ metric: { value: 120, entries: [{ name: 'scroll', duration: 120 }] } }); - expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalled(); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': 'ui.interaction.click', + 'browser.web_vital.inp.value': 120, + }), + }), + ); + }); +}); + +describe('soft navigation web vitals', () => { + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ transactionName: 'test-route' }), + }; + + const navigationSpan = { spanContext: () => ({ spanId: 'nav-1' }) } as any; + const pageloadSpan = createMockPageloadSpan('pageload-1'); + + let lcpCallback: (arg: { metric: any }) => void; + let clsCallback: (arg: { metric: any }) => void; + let client: any; + + function lcpMetric(navigationId: number, value: number, navigationType = 'soft-navigation') { + return { value, navigationId, navigationType, entries: [{ startTime: value, element: {} }] }; + } + + beforeEach(() => { + vi.stubGlobal('PerformanceObserver', { + supportedEntryTypes: ['largest-contentful-paint', 'layout-shift', 'soft-navigation'], + }); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue({ end: vi.fn() } as any); + vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: {} } as any); + vi.mocked(htmlTreeAsString).mockReturnValue('
'); + vi.spyOn(softNavs, 'getNavigationSpanForMetric').mockImplementation((metric: any) => + metric.navigationType === 'soft-navigation' ? navigationSpan : undefined, + ); + vi.spyOn(instrument, 'addLcpInstrumentationHandler').mockImplementation((cb: any) => { + lcpCallback = cb; + return () => undefined; + }); + vi.spyOn(instrument, 'addClsInstrumentationHandler').mockImplementation((cb: any) => { + clsCallback = cb; + return () => undefined; + }); + client = { + getOptions: () => ({ traceLifecycle: 'stream' }), + on: vi.fn((hook: string, cb: any) => { + if (hook === 'afterStartPageLoadSpan') { + cb(pageloadSpan); + } + }), + }; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('sends every reported LCP as a span, against the navigation it belongs to', () => { + trackLcpAsSpan(client, true); + + lcpCallback({ metric: lcpMetric(1, 800, 'navigate') }); + lcpCallback({ metric: lcpMetric(2, 300) }); + + const calls = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0]![0].attributes?.['browser.web_vital.lcp.value']).toBe(800); + expect(calls[0]![0].attributes?.['browser.soft_navigation.id']).toBeUndefined(); + expect(calls[0]![0].attributes?.['browser.navigation.type']).toBe('navigate'); + expect(calls[0]![0].parentSpan).toBe(pageloadSpan); + expect(calls[1]![0].attributes?.['browser.web_vital.lcp.value']).toBe(300); + expect(calls[1]![0].attributes?.['browser.soft_navigation.id']).toBe(2); + expect(calls[1]![0].attributes?.['browser.navigation.type']).toBe('soft-navigation'); + expect(calls[1]![0].parentSpan).toBe(navigationSpan); + }); + + it('starts a soft navigation LCP span at the navigation, not the document time origin', () => { + const end = vi.fn(); + vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue({ end } as any); + + trackLcpAsSpan(client, true); + + // web-vitals reports the value relative to the soft navigation while the entry keeps its + // absolute time: a 300ms LCP on a navigation that started 2000ms into the document. + lcpCallback({ + metric: { + value: 300, + navigationId: 2, + navigationType: 'soft-navigation', + navigationStartTime: 2000, + entries: [{ startTime: 2300, element: {} }], + }, + }); + + // (timeOrigin 1000 + navigationStartTime 2000) / 1000 + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ startTime: 3 })); + // Ends 300ms later, so the span lasts exactly the LCP it reports and stays inside its parent. + expect(end).toHaveBeenCalledWith(3.3); + }); + + it('starts a soft navigation CLS of 0 at the navigation, not at the report time', () => { + trackClsAsSpan(client, true); + + // No layout shifts, so there is no entry to place the span at. The report only happens once the + // navigation is over, so the current time would land the span on the following route. + clsCallback({ + metric: { + value: 0, + navigationId: 2, + navigationType: 'soft-navigation', + navigationStartTime: 2000, + entries: [], + }, + }); + + // (timeOrigin 1000 + navigationStartTime 2000) / 1000 + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ startTime: 3 })); + }); + + it('keeps a page load LCP span anchored to the document time origin', () => { + trackLcpAsSpan(client, true); + + lcpCallback({ metric: lcpMetric(1, 800, 'navigate') }); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ startTime: 1 })); + }); + + it('drops soft navigation vitals that could not be correlated', () => { + vi.spyOn(softNavs, 'getNavigationSpanForMetric').mockReturnValue(undefined); + + trackLcpAsSpan(client, true); + + lcpCallback({ metric: lcpMetric(1, 800, 'navigate') }); + lcpCallback({ metric: lcpMetric(2, 300) }); + + expect(vi.mocked(SentryCoreBrowser.startInactiveSpan)).toHaveBeenCalledTimes(1); + }); + + it('sends a CLS of 0 for a soft navigation without layout shifts', () => { + trackClsAsSpan(client, true); + + clsCallback({ metric: { value: 0, navigationId: 2, navigationType: 'soft-navigation', entries: [] } }); + + const call = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls[0]![0]; + expect(call.attributes?.['browser.web_vital.cls.value']).toBe(0); + expect(call.attributes?.['browser.soft_navigation.id']).toBe(2); + expect(call.parentSpan).toBe(navigationSpan); + }); + + it('attributes INP by navigation instead of the interaction cache', () => { + let inpCallback: (arg: { metric: any }) => void = () => undefined; + vi.spyOn(instrument, 'addInpInstrumentationHandler').mockImplementation((cb: any) => { + inpCallback = cb; + return () => undefined; + }); + // The cache would attribute the hard navigation's INP to whatever span was active when the + // interaction was observed, which is the following navigation span. + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue({ + span: navigationSpan, + elementName: '', + } as any); + + trackInpAsSpan(client, true); + + const entry = { name: 'pointerdown', startTime: 500, duration: 120, interactionId: 1 }; + inpCallback({ metric: { value: 120, navigationId: 1, navigationType: 'navigate', entries: [entry] } }); + inpCallback({ metric: { value: 120, navigationId: 2, navigationType: 'soft-navigation', entries: [entry] } }); + + const calls = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0]![0].parentSpan).toBe(pageloadSpan); + expect(calls[0]![0].attributes?.['browser.soft_navigation.id']).toBeUndefined(); + expect(calls[1]![0].parentSpan).toBe(navigationSpan); + expect(calls[1]![0].attributes?.['browser.soft_navigation.id']).toBe(2); + }); + + it('still reports INP when web-vitals has no entry to describe it', () => { + let inpCallback: (arg: { metric: any }) => void = () => undefined; + vi.spyOn(instrument, 'addInpInstrumentationHandler').mockImplementation((cb: any) => { + inpCallback = cb; + return () => undefined; + }); + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + + trackInpAsSpan(client, true); + + // web-vitals synthesizes a value with no entries when every interaction of a soft navigation + // stayed below the Event Timing threshold. The value still belongs on the navigation. + inpCallback({ + metric: { value: 8, navigationId: 2, navigationType: 'soft-navigation', navigationStartTime: 500, entries: [] }, + }); + + const call = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls[0]![0]; + expect(call.name).toBe('Interaction to next paint'); + // No entry means no interaction type. The op still has to stay inside `ui.interaction.*` so + // these fast navigations are not excluded from INP aggregations. + expect(call.attributes?.['sentry.op']).toBe('ui.interaction.click'); + expect(call.attributes?.['browser.web_vital.inp.value']).toBe(8); + expect(call.attributes?.['browser.soft_navigation.id']).toBe(2); + expect(call.parentSpan).toBe(navigationSpan); + }); + + it('does not use the page load report events when soft navigations are on', () => { + const listenSpy = vi.spyOn(reportEvents, 'listenForWebVitalReportEvents'); + + trackLcpAsSpan(client, true); + trackClsAsSpan(client, true); + + expect(listenSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts index aa0114b87fec..3b64b031f67c 100644 --- a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts +++ b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts @@ -70,6 +70,10 @@ describe('startTrackingWebVitals', () => { const cleanupWebVitals = startTrackingWebVitals({ trackCls: false, trackLcp: false, client: getClient()! }); + // The metric observers are started in a microtask. A real one is buffered and would still see + // entries emitted before that, but this stub only delivers to observers that already exist. + await new Promise(resolve => setTimeout(resolve, 0)); + await emitPaintEntries([ { entryType: 'paint', name: 'first-paint', duration: 0, startTime: 12, toJSON: () => ({}) }, { entryType: 'paint', name: 'first-contentful-paint', duration: 0, startTime: 18, toJSON: () => ({}) }, diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts index 33a99cb29e43..2bc49e571bc3 100644 --- a/packages/browser/src/integrations/webVitals.ts +++ b/packages/browser/src/integrations/webVitals.ts @@ -2,8 +2,11 @@ import type { IntegrationFn, Span } from '@sentry/core'; import { defineIntegration, hasSpanStreamingEnabled } from '@sentry/core'; import { addWebVitalsToSpan, + enableSoftNavigationReporting, registerInpInteractionListener, + startSoftNavigationCorrelation, startTrackingWebVitals, + supportsSoftNavigations, trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan, @@ -18,6 +21,27 @@ export interface WebVitalsOptions { * Web vitals to skip. */ ignore?: WebVitalName[]; + + /** + * Give each soft navigation its own set of LCP, CLS and INP, detected through the browser's + * [Soft Navigations API](https://developer.chrome.com/docs/web-platform/soft-navigations-experiment) + * (Chromium 151+). + * + * Each soft navigation's vitals are reported against the navigation span they belong to. This + * also changes how the initial page load is measured: its vitals are finalized at the first soft + * navigation rather than accumulating over the page's lifetime. + * + * Soft navigations the browser doesn't detect (programmatic navigations, navigations that never + * paint) report no vitals at all, so coverage is lower than for page loads. Set this to `false` + * to report a single set of vitals for the whole page lifetime instead. + * + * Requires span streaming (`traceLifecycle: 'stream'`, the default), since soft navigation vitals + * are finalized long after the navigation span they belong to has ended. Ignored in browsers + * without support for the Soft Navigations API. + * + * Default: `true` + */ + softNavigations?: boolean; } /** @@ -28,13 +52,25 @@ export interface WebVitalsOptions { * needed to customize options or to use it without `browserTracingIntegration`. */ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions = {}) => { - const ignored = new Set(options.ignore ?? []); + const { ignore = [], softNavigations = true } = options; + const ignored = new Set(ignore); return { name: WEB_VITALS_INTEGRATION_NAME, setup(client) { const spanStreamingEnabled = hasSpanStreamingEnabled(client); + // Soft navigation vitals are finalized at the next soft navigation or on pagehide, long after + // the navigation span they belong to has ended. Only span streaming can still send them. + const reportSoftNavs = softNavigations && spanStreamingEnabled && supportsSoftNavigations(); + + if (reportSoftNavs) { + // Has to run before any web vital observer is instrumented, since web-vitals only reads its + // options when the observer is set up. + enableSoftNavigationReporting(); + startSoftNavigationCorrelation(client); + } + // With span streaming enabled, CLS and LCP are tracked as standalone v2 spans (like INP). // Otherwise, they're recorded as measurements on the pageload span. const trackClsOnPageloadSpan = !spanStreamingEnabled && !ignored.has('cls'); @@ -67,17 +103,17 @@ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions if (spanStreamingEnabled) { if (!ignored.has('lcp')) { - trackLcpAsSpan(client); + trackLcpAsSpan(client, reportSoftNavs); } if (!ignored.has('cls')) { - trackClsAsSpan(client); + trackClsAsSpan(client, reportSoftNavs); } } // INP is always sent as a streamed web vital span. When span streaming is disabled, INP still // streams (it overrides the static trace lifecycle for INP only), see `trackInpAsSpan`. if (!ignored.has('inp')) { - trackInpAsSpan(client); + trackInpAsSpan(client, reportSoftNavs); } }, afterAllSetup() { diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 6afc276088b1..451c67dabae1 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -37,6 +37,7 @@ import { import { DEBUG_BUILD } from '../debug-build'; import { filterCollectedUrl } from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; +import type { WebVitalsOptions } from '../integrations/webVitals'; import { WEB_VITALS_INTEGRATION_NAME, webVitalsIntegration } from '../integrations/webVitals'; import { registerBackgroundTabDetection } from './backgroundtab'; import { linkTraces } from './linkedTraces'; @@ -120,9 +121,17 @@ export interface BrowserTracingOptions { * If true, Sentry will capture first input delay and add it to the corresponding transaction. * * Default: true + * + * @deprecated Use {@link BrowserTracingOptions.webVitals} instead: `webVitals: { ignore: ['inp'] }`. */ enableInp: boolean; + /** + * Options for the `webVitalsIntegration` that is auto-registered when none is present. + * Ignored if you register `webVitalsIntegration` yourself. + */ + webVitals?: WebVitalsOptions; + /** * @deprecated This option is no longer used. Element timing is now tracked via the standalone * `elementTimingIntegration`. Add it to your `integrations` array to collect element timing metrics. @@ -258,6 +267,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { markBackgroundSpan: true, enableLongTask: true, enableLongAnimationFrame: true, + // oxlint-disable-next-line typescript/no-deprecated -- still honoured until it is removed enableInp: true, ignoreResourceSpans: [], detectRedirects: true, @@ -293,9 +303,11 @@ export const browserTracingIntegration = ((options: Partial vi.fn()); const mockTrackClsAsSpan = vi.hoisted(() => vi.fn()); const mockTrackInpAsSpan = vi.hoisted(() => vi.fn()); const mockTrackLcpAsSpan = vi.hoisted(() => vi.fn()); +const mockEnableSoftNavigationReporting = vi.hoisted(() => vi.fn()); +const mockStartSoftNavigationCorrelation = vi.hoisted(() => vi.fn()); +const mockSupportsSoftNavigations = vi.hoisted(() => vi.fn()); vi.mock('@sentry/browser-utils', () => ({ addWebVitalsToSpan: mockAddWebVitalsToSpan, + enableSoftNavigationReporting: mockEnableSoftNavigationReporting, registerInpInteractionListener: mockRegisterInpInteractionListener, + startSoftNavigationCorrelation: mockStartSoftNavigationCorrelation, startTrackingWebVitals: mockStartTrackingWebVitals, + supportsSoftNavigations: mockSupportsSoftNavigations, trackClsAsSpan: mockTrackClsAsSpan, trackInpAsSpan: mockTrackInpAsSpan, trackLcpAsSpan: mockTrackLcpAsSpan, @@ -44,6 +50,7 @@ describe('webVitalsIntegration', () => { beforeEach(() => { vi.clearAllMocks(); mockStartTrackingWebVitals.mockReturnValue(vi.fn()); + mockSupportsSoftNavigations.mockReturnValue(false); }); afterEach(() => { @@ -81,8 +88,8 @@ describe('webVitalsIntegration', () => { trackLcp: false, client, }); - expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client); - expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client, false); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client, false); expect(mockTrackInpAsSpan).toHaveBeenCalledTimes(1); expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); }); @@ -95,10 +102,60 @@ describe('webVitalsIntegration', () => { integration.afterAllSetup?.(client as never); expect(mockTrackLcpAsSpan).not.toHaveBeenCalled(); - expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client, false); expect(mockTrackInpAsSpan).toHaveBeenCalledTimes(1); }); + it('reports soft navigation web vitals by default when supported', () => { + mockSupportsSoftNavigations.mockReturnValue(true); + const client = getMockClient({ traceLifecycle: 'stream' }); + const integration = webVitalsIntegration(); + + integration.setup?.(client as never); + + expect(mockEnableSoftNavigationReporting).toHaveBeenCalledTimes(1); + expect(mockStartSoftNavigationCorrelation).toHaveBeenCalledWith(client); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client, true); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client, true); + expect(mockTrackInpAsSpan).toHaveBeenCalledWith(client, true); + }); + + it('does not report soft navigation web vitals when opted out', () => { + mockSupportsSoftNavigations.mockReturnValue(true); + const client = getMockClient({ traceLifecycle: 'stream' }); + const integration = webVitalsIntegration({ softNavigations: false }); + + integration.setup?.(client as never); + + expect(mockEnableSoftNavigationReporting).not.toHaveBeenCalled(); + expect(mockStartSoftNavigationCorrelation).not.toHaveBeenCalled(); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client, false); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client, false); + expect(mockTrackInpAsSpan).toHaveBeenCalledWith(client, false); + }); + + it('does not report soft navigation web vitals without span streaming', () => { + mockSupportsSoftNavigations.mockReturnValue(true); + const client = getMockClient(); + const integration = webVitalsIntegration(); + + integration.setup?.(client as never); + + expect(mockEnableSoftNavigationReporting).not.toHaveBeenCalled(); + expect(mockStartSoftNavigationCorrelation).not.toHaveBeenCalled(); + expect(mockTrackInpAsSpan).toHaveBeenCalledWith(client, false); + }); + + it('does not report soft navigation web vitals in unsupporting browsers', () => { + const client = getMockClient({ traceLifecycle: 'stream' }); + const integration = webVitalsIntegration(); + + integration.setup?.(client as never); + + expect(mockEnableSoftNavigationReporting).not.toHaveBeenCalled(); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client, false); + }); + it('supports ignoring selected web vitals', () => { const client = getMockClient(); const integration = webVitalsIntegration({ ignore: ['cls', 'inp', 'lcp'] }); diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 5f3d6d3858fa..85c666421ae8 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -31,6 +31,7 @@ import { startBrowserTracingPageLoadSpan, } from '../../src/tracing/browserTracingIntegration'; import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/linkedTraces'; +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'; @@ -203,6 +204,61 @@ describe('browserTracingIntegration', () => { expect(client.getIntegrationByName('WebVitals')).toBeDefined(); }); + it('does not auto-register when the user supplies their own webVitalsIntegration', () => { + const webVitalsSpy = vi.spyOn(webVitalsModule, 'webVitalsIntegration'); + const userWebVitals = webVitalsModule.webVitalsIntegration({ softNavigations: false }); + webVitalsSpy.mockClear(); + + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration(), userWebVitals], + }), + ); + setCurrentClient(client); + client.init(); + + expect(webVitalsSpy).not.toHaveBeenCalled(); + }); + + it('forwards webVitals options to the auto-registered integration', () => { + const webVitalsSpy = vi.spyOn(webVitalsModule, 'webVitalsIntegration'); + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ webVitals: { softNavigations: false } })], + }), + ); + setCurrentClient(client); + client.init(); + + expect(webVitalsSpy).toHaveBeenCalledWith(expect.objectContaining({ softNavigations: false })); + }); + + it.each([ + ['leaves the ignore list alone when INP is enabled', {}, []], + // oxlint-disable-next-line typescript/no-deprecated + ['appends inp to the ignore list when disabled', { enableInp: false }, ['inp']], + [ + 'keeps user-provided entries when appending inp', + // oxlint-disable-next-line typescript/no-deprecated + { enableInp: false, webVitals: { ignore: ['cls' as const] } }, + ['cls', 'inp'], + ], + ])('enableInp %s', (_name, options, expected) => { + const webVitalsSpy = vi.spyOn(webVitalsModule, 'webVitalsIntegration'); + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration(options)], + }), + ); + setCurrentClient(client); + client.init(); + + expect(webVitalsSpy).toHaveBeenCalledWith(expect.objectContaining({ ignore: expected })); + }); + it('works with tracing disabled', () => { const client = new BrowserClient( getDefaultBrowserClientOptions({ diff --git a/yarn.lock b/yarn.lock index 833a91e96f53..b302c041fbb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27724,10 +27724,10 @@ web-streams-polyfill@^3.1.1: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== -web-vitals@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-6.0.1.tgz#774de5fba561154313bcc09d125990f046fdd174" - integrity sha512-iF3+kno3Anlqi5fPVTQk9gYLfsCiL8P69Hof+AmZyVVx00E3e/BiGVvu+EsOy6jvTLqTKuiaRloPyw0JtdKbSw== +web-vitals@^6.1.1: + version "6.1.1" + resolved "https://sfw.security.sentry.io/npm/web-vitals/-/web-vitals-6.1.1.tgz#855ba63ed40847c55e061ac13f55ee5f6435b723" + integrity sha512-r93gKnR6ZC2O8F3JrS0AAGIQ0v0OhXuyg4DAj2oG7K+GPkwXfUSc3ZcWeu50AsV5yIc6xxnTjERipJ6EvSw2vg== webidl-conversions@^3.0.0: version "3.0.1"