diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 3105a48181b9..78b26eee16ae 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,8 +4,11 @@ export { addTtfbInstrumentationHandler, addLcpInstrumentationHandler, addInpInstrumentationHandler, + enableSoftNavigationReporting, } from './instrumentation/performanceObserver'; +export { startSoftNavigationCorrelation, supportsSoftNavigations } from './web-vitals/softNavs'; + export { addPerformanceEntries, startTrackingInteractions, diff --git a/packages/browser-utils/src/instrumentation/performanceObserver.ts b/packages/browser-utils/src/instrumentation/performanceObserver.ts index 463908bca008..ef4101e2fc64 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; @@ -103,6 +114,29 @@ interface Metric { | 'prerender' | 'restore' | 'soft-navigation'; + + /** + * 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; @@ -122,6 +156,29 @@ let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; +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. @@ -247,7 +304,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 }, ); } @@ -261,7 +318,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 }, ); } @@ -284,6 +341,7 @@ function instrumentInp(): StopListening { }); _previousInp = metric; }), + { reportSoftNavs: _reportSoftNavs }, ); } 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..f1c97da2da1c --- /dev/null +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -0,0 +1,150 @@ +import type { Integration, Span, SpanAttributes } from '@sentry/core'; +import { + getClient, + getCurrentScope, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startInactiveSpan, +} from '@sentry/core'; +import { SENTRY_SEGMENT_NAME, SENTRY_TRANSACTION } from '@sentry/conventions/attributes'; +import { WINDOW } from '../types'; +import type { WebVitalReportEvent } from './reportEvents'; +import { SOFT_NAVIGATION_ID_ATTRIBUTE } from './softNavs'; + +// 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; + /** + * 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, + } = options; + + // A web vital can be reported long after the user left the route it belongs to: a soft + // navigation's CLS and INP only finalize at the next navigation. The scope's transaction name has + // moved on to that next route by then, so prefer the name of the span the vital is attributed to. + const routeName = (parentSpan && spanToJSON(parentSpan).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]: routeName, + [SENTRY_SEGMENT_NAME]: routeName, + // 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; + } + + // 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, + '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 f6ad68b7ec1c..cb329dc3e2a8 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -1,22 +1,15 @@ -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, - startInactiveSpan, timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { htmlTreeAsString } from '../htmlTreeAsString'; -import { WINDOW } from '../types'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; import type { InstrumentationHandlerCallback } from '../instrumentation/performanceObserver'; import { @@ -24,150 +17,73 @@ import { 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'; - -// 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; - - const routeName = 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]: routeName, - [SENTRY_SEGMENT_NAME]: routeName, - // 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); + }); + return; + } + + let lcpValue = 0; + let lcpEntry: LargestContentfulPaint | undefined; + const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; if (!entry || !isValidLcpMetric(metric.value)) { @@ -191,6 +107,7 @@ export function _sendLcpSpan( entry: LargestContentfulPaint | undefined, pageloadSpan?: Span, reportEvent?: WebVitalReportEvent, + softNavigationId?: number, ): void { if (!isValidLcpMetric(lcpValue)) { return; @@ -223,20 +140,29 @@ export function _sendLcpSpan( reportEvent, startTime: timeOrigin, endTime, + softNavigationId, }); } /** * 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); + }); + return; + } + + let clsValue = 0; + let clsEntry: LayoutShift | undefined; + const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; if (!entry) { @@ -260,6 +186,7 @@ export function _sendClsSpan( entry: LayoutShift | undefined, pageloadSpan?: Span, reportEvent?: WebVitalReportEvent, + softNavigationId?: number, ): void { DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); @@ -284,6 +211,7 @@ export function _sendClsSpan( parentSpan: pageloadSpan, reportEvent, startTime, + softNavigationId, }); } @@ -292,7 +220,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; @@ -305,46 +233,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]; + // 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 routeName = spanToUse ? spanToJSON(spanToUse).name : getCurrentScope().getScopeData().transactionName; - 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, @@ -353,14 +312,12 @@ export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming, st metricName: 'inp', value: inpValue, attributes: { - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, - // oxlint-disable-next-line typescript-eslint/no-deprecated - [SENTRY_TRANSACTION]: routeName, - [SENTRY_SEGMENT_NAME]: routeName, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, }, startTime, endTime: startTime + duration, parentSpan: spanToUse, standalone, + softNavigationId, }); } 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 d23949c3307d..170e71908a0d 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -4,12 +4,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 () => { @@ -637,9 +641,166 @@ describe('trackInpAsSpan', () => { expect(SentryCore.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(SentryCore.startInactiveSpan).not.toHaveBeenCalled(); + + const call = vi.mocked(SentryCore.startInactiveSpan).mock.calls[0]![0]; + expect(call.attributes?.['sentry.op']).toBe('ui.interaction.click'); + expect(call.attributes?.['browser.web_vital.inp.value']).toBe(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(SentryCore.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(SentryCore.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].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].parentSpan).toBe(navigationSpan); + }); + + 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(SentryCore.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(SentryCore.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(SentryCore.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(SentryCore.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/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts index a8b969a0ff6e..cd950a4b203d 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/browser'; import { defineIntegration, hasSpanStreamingEnabled } from '@sentry/core/browser'; import { addWebVitalsToSpan, + enableSoftNavigationReporting, registerInpInteractionListener, + startSoftNavigationCorrelation, startTrackingWebVitals, + supportsSoftNavigations, trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan, @@ -18,6 +21,26 @@ export interface WebVitalsOptions { * Web vitals to skip. */ ignore?: WebVitalName[]; + + /** + * Also report LCP, CLS and INP for soft navigations, using the browser's + * [Soft Navigations API](https://developer.chrome.com/docs/web-platform/soft-navigations-experiment) + * (Chromium 151+). + * + * Each soft navigation gets its own set of vitals, reported against the navigation span it + * belongs to. Turning this on 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. + * + * 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: `false` + */ + reportSoftNavs?: boolean; } /** @@ -35,6 +58,17 @@ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions 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 = !!options.reportSoftNavs && 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 +101,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 dc174f7f21b5..86381c5d906a 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -146,6 +146,15 @@ export interface BrowserTracingOptions { */ enableInp: boolean; + /** + * If true, Sentry will also report LCP, CLS and INP for soft navigations, using the browser's + * [Soft Navigations API](https://developer.chrome.com/docs/web-platform/soft-navigations-experiment). + * Forwarded to the auto-registered `webVitalsIntegration` as `reportSoftNavs`. + * + * Default: false + */ + enableSoftNavWebVitals: boolean; + /** * @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. @@ -291,6 +300,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, + enableSoftNavWebVitals: false, ignoreResourceSpans: [], detectRedirects: true, linkPreviousTrace: 'in-memory', @@ -335,6 +345,7 @@ 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, @@ -81,8 +87,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 +101,47 @@ 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 when enabled and supported', () => { + mockSupportsSoftNavigations.mockReturnValue(true); + const client = getMockClient({ traceLifecycle: 'stream' }); + const integration = webVitalsIntegration({ reportSoftNavs: true }); + + 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 without span streaming', () => { + mockSupportsSoftNavigations.mockReturnValue(true); + const client = getMockClient(); + const integration = webVitalsIntegration({ reportSoftNavs: true }); + + 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', () => { + mockSupportsSoftNavigations.mockReturnValue(false); + const client = getMockClient({ traceLifecycle: 'stream' }); + const integration = webVitalsIntegration({ reportSoftNavs: true }); + + 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 c21c478340d1..ec693fc92b95 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -32,6 +32,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 { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; @@ -204,6 +205,23 @@ describe('browserTracingIntegration', () => { expect(client.getIntegrationByName('WebVitals')).toBeDefined(); }); + it.each([ + ['defaults to off', {}, false], + ['is forwarded when enabled', { enableSoftNavWebVitals: true }, true], + ])('enableSoftNavWebVitals %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({ reportSoftNavs: expected })); + }); + it('works with tracing disabled', () => { const client = new BrowserClient( getDefaultBrowserClientOptions({