diff --git a/.size-limit.js b/.size-limit.js index d589cfccdc65..cd42c1d077dd 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -302,7 +302,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '225 KB', + limit: '228 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index f1e28a7d50b5..8b2860350e42 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -5,10 +5,16 @@ import { debug, getActiveSpan, getComponentName, + getCurrentScope, parseUrl, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, spanToJSON, + startInactiveSpan, } from '@sentry/core'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; @@ -73,9 +79,59 @@ let _measurements: Measurements = {}; let _lcpEntry: LargestContentfulPaint | undefined; let _clsEntry: LayoutShift | undefined; +/** + * Routes a web vital measurement to the correct destination. + * For hard navigations, stores in `_measurements` to be flushed onto the pageload span. + * For soft navigations, emits a web vital span. With span streaming (v2) enabled, + * the span is buffered by trace ID and sent alongside the navigation span. + */ +function _emitMeasurement( + metric: { navigationType: string; navigationId: string }, + name: string, + value: number, + unit: string, +): void { + if (metric.navigationType !== 'soft-navigation') { + _measurements[name] = { value, unit }; + return; + } + + _emitSoftNavWebVitalSpan(name, value, unit); +} + +/** + * Creates a v2 web vital span for a soft navigation metric. + * This is a regular (non-standalone) span so it flows through the span streaming + * pipeline (afterSpanEnd -> captureSpan -> SpanBuffer) and gets grouped with + * the navigation span by trace ID. + */ +function _emitSoftNavWebVitalSpan(name: string, value: number, unit: string): void { + const startTime = msToSec(browserPerformanceTimeOrigin() || 0); + const routeName = getCurrentScope().getScopeData().transactionName; + + const span = startInactiveSpan({ + name: routeName || '', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.softnavigation', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.webvital.${name}`, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + }, + startTime, + }); + + if (span) { + span.addEvent(name, { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit, + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value, + }); + span.end(startTime); + } +} + interface StartTrackingWebVitalsOptions { trackCls: boolean; trackLcp: boolean; + reportSoftNavs?: boolean; client: Client; } @@ -85,19 +141,27 @@ interface StartTrackingWebVitalsOptions { * * @returns A function that forces web vitals collection */ -export function startTrackingWebVitals({ trackCls, trackLcp }: StartTrackingWebVitalsOptions): () => void { +export function startTrackingWebVitals({ + trackCls, + trackLcp, + reportSoftNavs, +}: StartTrackingWebVitalsOptions): () => void { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin()) { - const lcpCleanupCallback = trackLcp ? _trackLCP() : undefined; - const clsCleanupCallback = trackCls ? _trackCLS() : undefined; - const ttfbCleanupCallback = _trackTtfb(); + const lcpCleanupCallback = trackLcp ? _trackLCP(reportSoftNavs) : undefined; + const clsCleanupCallback = trackCls ? _trackCLS(reportSoftNavs) : undefined; + const ttfbCleanupCallback = _trackTtfb(reportSoftNavs); const fpFcpCleanupCallback = _trackFpFcp(); return (): void => { ttfbCleanupCallback(); fpFcpCleanupCallback(); - lcpCleanupCallback?.(); - clsCleanupCallback?.(); + // When soft navs are enabled, keep the LCP and CLS observers alive across the page + // lifetime so vitals for subsequent soft navigations are still captured. + if (!reportSoftNavs) { + lcpCleanupCallback?.(); + clsCleanupCallback?.(); + } }; } @@ -238,39 +302,46 @@ export { registerInpInteractionListener } from './inp'; * Starts tracking the Cumulative Layout Shift on the current page and collects the value and last entry * to the `_measurements` object which ultimately is applied to the pageload span's measurements. */ -function _trackCLS(): () => void { - return addClsInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; - if (!entry) { - return; - } - _measurements['cls'] = { value: metric.value, unit: '' }; - _clsEntry = entry; - }, true); +function _trackCLS(reportSoftNavs?: boolean): () => void { + return addClsInstrumentationHandler( + ({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + if (!entry) { + return; + } + _emitMeasurement(metric, 'cls', metric.value, ''); + _clsEntry = entry; + }, + !reportSoftNavs, + reportSoftNavs, + ); } /** Starts tracking the Largest Contentful Paint on the current page. */ -function _trackLCP(): () => void { - return addLcpInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1]; - if (!entry || !isValidLcpMetric(metric.value)) { - return; - } - - _measurements['lcp'] = { value: metric.value, unit: 'millisecond' }; - _lcpEntry = entry as LargestContentfulPaint; - }, true); +function _trackLCP(reportSoftNavs?: boolean): () => void { + return addLcpInstrumentationHandler( + ({ metric }) => { + const entry = metric.entries[metric.entries.length - 1]; + if (!entry || !isValidLcpMetric(metric.value)) { + return; + } + _emitMeasurement(metric, 'lcp', metric.value, 'millisecond'); + _lcpEntry = entry as LargestContentfulPaint; + }, + !reportSoftNavs, + reportSoftNavs, + ); } -function _trackTtfb(): () => void { +function _trackTtfb(reportSoftNavs?: boolean): () => void { return addTtfbInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1]; if (!entry) { return; } - _measurements['ttfb'] = { value: metric.value, unit: 'millisecond' }; - }); + _emitMeasurement(metric, 'ttfb', metric.value, 'millisecond'); + }, reportSoftNavs); } /** Starts tracking First Paint and First Contentful Paint on the current page. */ diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 608a5fd11511..56cc02882474 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -95,7 +95,20 @@ 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'; + navigationType: + | 'navigate' + | 'reload' + | 'back-forward' + | 'back-forward-cache' + | 'prerender' + | 'restore' + | 'soft-navigation'; + + /** + * The navigationId the metric belongs to. Relevant for soft navigations + * where multiple navigations can occur within a single page lifecycle. + */ + navigationId: string; } type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver; @@ -125,8 +138,9 @@ let _previousInp: Metric | undefined; export function addClsInstrumentationHandler( callback: (data: { metric: Metric }) => void, stopOnCallback = false, + reportSoftNavs?: boolean, ): CleanupHandlerCallback { - return addMetricObserver('cls', callback, instrumentCls, _previousCls, stopOnCallback); + return addMetricObserver('cls', callback, () => instrumentCls(reportSoftNavs), _previousCls, stopOnCallback); } /** @@ -139,15 +153,19 @@ export function addClsInstrumentationHandler( export function addLcpInstrumentationHandler( callback: (data: { metric: Metric }) => void, stopOnCallback = false, + reportSoftNavs?: boolean, ): CleanupHandlerCallback { - return addMetricObserver('lcp', callback, instrumentLcp, _previousLcp, stopOnCallback); + return addMetricObserver('lcp', callback, () => instrumentLcp(reportSoftNavs), _previousLcp, stopOnCallback); } /** * Add a callback that will be triggered when a TTFD metric is available. */ -export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { - return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb); +export function addTtfbInstrumentationHandler( + callback: (data: { metric: Metric }) => void, + reportSoftNavs?: boolean, +): CleanupHandlerCallback { + return addMetricObserver('ttfb', callback, () => instrumentTtfb(reportSoftNavs), _previousTtfb); } export type InstrumentationHandlerCallback = (data: { @@ -160,8 +178,11 @@ export type InstrumentationHandlerCallback = (data: { * Add a callback that will be triggered when a INP metric is available. * Returns a cleanup callback which can be called to remove the instrumentation handler. */ -export function addInpInstrumentationHandler(callback: InstrumentationHandlerCallback): CleanupHandlerCallback { - return addMetricObserver('inp', callback, instrumentInp, _previousInp); +export function addInpInstrumentationHandler( + callback: InstrumentationHandlerCallback, + reportSoftNavs?: boolean, +): CleanupHandlerCallback { + return addMetricObserver('inp', callback, () => instrumentInp(reportSoftNavs), _previousInp); } export function addPerformanceInstrumentationHandler( @@ -213,7 +234,7 @@ function triggerHandlers(type: InstrumentHandlerType, data: unknown): void { } } -function instrumentCls(): StopListening { +function instrumentCls(reportSoftNavs?: boolean): StopListening { return onCLS( metric => { triggerHandlers('cls', { @@ -223,11 +244,11 @@ 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: true, reportSoftNavs }, ); } -function instrumentLcp(): StopListening { +function instrumentLcp(reportSoftNavs?: boolean): StopListening { return onLCP( metric => { triggerHandlers('lcp', { @@ -237,26 +258,32 @@ 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: true, reportSoftNavs }, ); } -function instrumentTtfb(): StopListening { - return onTTFB(metric => { - triggerHandlers('ttfb', { - metric, - }); - _previousTtfb = metric; - }); +function instrumentTtfb(reportSoftNavs?: boolean): StopListening { + return onTTFB( + metric => { + triggerHandlers('ttfb', { + metric, + }); + _previousTtfb = metric; + }, + { reportSoftNavs }, + ); } -function instrumentInp(): void { - return onINP(metric => { - triggerHandlers('inp', { - metric, - }); - _previousInp = metric; - }); +function instrumentInp(reportSoftNavs?: boolean): void { + return onINP( + metric => { + triggerHandlers('inp', { + metric, + }); + _previousInp = metric; + }, + { reportSoftNavs }, + ); } function addMetricObserver( diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index 1eeaf4df2420..09ce4a850ed1 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -21,12 +21,23 @@ This vendored web-vitals library is meant to be used in conjunction with the `@s `browserTracingIntegration`. As such, logic around `BFCache` and multiple reports were removed from the library as our web-vitals only report once per pageload. +Experimental soft navigation support (from upstream [#308](https://github.com/GoogleChrome/web-vitals/pull/308)) was +ported on top of our BFCache-free v5.1.0 base. It is gated behind the `reportSoftNavs` report option and, when enabled, +re-initializes each metric on a new `navigationId` so vitals are also reported for Chrome soft navigations. It requires +the Soft Navigation API (origin trial or the `#soft-navigation-heuristics` flag). + ## License [Apache 2.0](https://github.com/GoogleChrome/web-vitals/blob/master/LICENSE) ## CHANGELOG +- Ported experimental soft navigation support from upstream [#308](https://github.com/GoogleChrome/web-vitals/pull/308) + - Upstream shipped it in v6.0.0, entangled with BFCache handling we intentionally removed, so it was adapted onto our + BFCache-free v5.1.0 base rather than taken verbatim + - Gated behind the `reportSoftNavs` report option; adds `navigationId` tracking, `soft-navigation` / + `interaction-contentful-paint` observers, and per-navigation metric re-initialization + - Bumped from Web Vitals 5.0.2 to 5.1.0 - Remove `visibilitychange` event listeners when no longer required [#627](https://github.com/GoogleChrome/web-vitals/pull/627) - Register visibility-change early [#637](https://github.com/GoogleChrome/web-vitals/pull/637) diff --git a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts index 2e3f98c599e4..8ffa68316e7c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts @@ -22,8 +22,9 @@ import { initUnique } from './lib/initUnique'; import { LayoutShiftManager } from './lib/LayoutShiftManager'; import { observe } from './lib/observe'; import { runOnce } from './lib/runOnce'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { onFCP } from './onFCP'; -import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types'; +import type { CLSMetric, Metric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */ export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25]; @@ -50,18 +51,42 @@ export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25]; * during the same page load._ */ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = {}) => { + const softNavsEnabled = softNavs(opts); + let reportedMetric = false; + let metricNavStartTime = 0; + + const visibilityWatcher = getVisibilityWatcher(); + // Start monitoring FCP so we can only report CLS if FCP is also reported. // Note: this is done to match the current behavior of CrUX. onFCP( runOnce(() => { - const metric = initMetric('CLS', 0); + let metric = initMetric('CLS', 0); let report: ReturnType; - const visibilityWatcher = getVisibilityWatcher(); const layoutShiftManager = initUnique(opts, LayoutShiftManager); + const initNewCLSMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + metric = initMetric('CLS', 0, navigation, navigationId); + layoutShiftManager._sessionValue = 0; + report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); + reportedMetric = false; + if (navigation === 'soft-navigation') { + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = softNavEntry?.startTime ?? 0; + } + }; + const handleEntries = (entries: LayoutShift[]) => { for (const entry of entries) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so emit the final CLS and reinitialize the + // metric. + if (softNavsEnabled && entry.navigationId && entry.navigationId !== metric.navigationId) { + report(true); + initNewCLSMetric('soft-navigation', entry.navigationId); + } + layoutShiftManager._processEntry(entry); } @@ -74,15 +99,48 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = } }; - const po = observe('layout-shift', handleEntries); + const po = observe('layout-shift', handleEntries, opts); if (po) { report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); visibilityWatcher.onHidden(() => { handleEntries(po.takeRecords() as CLSMetric['entries']); report(true); + reportedMetric = true; }); + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + for (const entry of entries) { + const navId = entry.navigationId; + const softNavEntry = navId ? getSoftNavigationEntry(navId) : null; + if ( + navId && + navId !== metric.navigationId && + softNavEntry && + (softNavEntry.startTime || 0) > metricNavStartTime + ) { + handleEntries(po.takeRecords() as CLSMetric['entries']); + if (!reportedMetric) report(true); + initNewCLSMetric('soft-navigation', entry.navigationId); + report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); + } + } + }; + + if (softNavsEnabled) { + observe('soft-navigation', handleSoftNavEntries, opts); + } + // Queue a task to report (if nothing else triggers a report first). // This allows CLS to be reported as soon as FCP fires when // `reportAllChanges` is true. diff --git a/packages/browser-utils/src/metrics/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts index df8ac5e1c804..fa74205bde9e 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getINP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getINP.ts @@ -21,9 +21,10 @@ import { initUnique } from './lib/initUnique'; import { InteractionManager } from './lib/InteractionManager'; import { observe } from './lib/observe'; import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; -import type { INPMetric, INPReportOpts, MetricRatingThresholds } from './types'; +import type { INPMetric, INPReportOpts, Metric, MetricRatingThresholds } from './types'; /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; @@ -67,19 +68,47 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts return; } + let reportedMetric = false; + let metricNavStartTime = 0; + const softNavsEnabled = softNavs(opts); const visibilityWatcher = getVisibilityWatcher(); whenActivated(() => { // TODO(philipwalton): remove once the polyfill is no longer needed. - initInteractionCountPolyfill(); + initInteractionCountPolyfill(softNavsEnabled); - const metric = initMetric('INP'); - // eslint-disable-next-line prefer-const + let metric = initMetric('INP'); let report: ReturnType; const interactionManager = initUnique(opts, InteractionManager); + const initNewINPMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + interactionManager._resetInteractions(); + metric = initMetric('INP', -1, navigation, navigationId); + report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges); + reportedMetric = false; + if (navigation === 'soft-navigation') { + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = softNavEntry?.startTime ?? 0; + } + }; + + const updateINPMetric = () => { + const inp = interactionManager._estimateP98LongestInteraction(metric.navigationType); + + if (inp && (inp._latency !== metric.value || opts.reportAllChanges)) { + metric.value = inp._latency; + metric.entries = inp.entries; + } + }; + const handleEntries = (entries: INPMetric['entries']) => { + // Only process entries, if at least some of them have interaction ids + // (otherwise run into lots of errors later for empty INP entries) + if (entries.filter(entry => entry.interactionId).length === 0) { + return; + } + // Queue the `handleEntries()` callback in the next idle task. // This is needed to increase the chances that all event entries that // occurred between the user interaction and the next paint @@ -91,13 +120,8 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts interactionManager._processEntry(entry); } - const inp = interactionManager._estimateP98LongestInteraction(); - - if (inp && inp._latency !== metric.value) { - metric.value = inp._latency; - metric.entries = inp.entries; - report(); - } + updateINPMetric(); + report(); }); }; @@ -109,19 +133,61 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts // just one or two frames is likely not worth the insight that could be // gained. durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, - }); + opts, + } as PerformanceObserverInit); report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges); if (po) { // Also observe entries of type `first-input`. This is useful in cases // where the first interaction is less than the `durationThreshold`. - po.observe({ type: 'first-input', buffered: true }); + po.observe({ + type: 'first-input', + buffered: true, + includeSoftNavigationObservations: softNavsEnabled, + }); visibilityWatcher.onHidden(() => { handleEntries(po.takeRecords() as INPMetric['entries']); + if (metric.navigationType === 'soft-navigation') { + updateINPMetric(); + } report(true); }); + + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach(entry => { + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavEntryStartTime = softNavEntry?.startTime ?? 0; + if ( + entry.navigationId && + entry.navigationId !== metric.navigationId && + softNavEntryStartTime > metricNavStartTime + ) { + // Queue in whenIdleOrHidden in case entry processing for previous + // metric are queued. + whenIdleOrHidden(() => { + handleEntries(po.takeRecords() as INPMetric['entries']); + if (!reportedMetric && metric.value > 0) report(true); + initNewINPMetric('soft-navigation', entry.navigationId); + }); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', handleSoftNavEntries, opts); + } } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts index 9de413c745c0..9a458b344591 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts @@ -16,16 +16,17 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; +import { getNavigationEntry } from './lib/getNavigationEntry'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; -import { addPageListener, removePageListener } from './lib/globalListeners'; +import { addPageListener } from './lib/globalListeners'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { LCPEntryManager } from './lib/LCPEntryManager'; import { observe } from './lib/observe'; -import { runOnce } from './lib/runOnce'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; -import type { LCPMetric, MetricRatingThresholds, ReportOpts } from './types'; +import type { LCPMetric, Metric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */ export const LCPThresholds: MetricRatingThresholds = [2500, 4000]; @@ -42,22 +43,63 @@ export const LCPThresholds: MetricRatingThresholds = [2500, 4000]; * been determined. */ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = {}) => { + let reportedMetric = false; + const softNavsEnabled = softNavs(opts); + let metricNavStartTime = 0; + const hardNavId = getNavigationEntry()?.navigationId || '1'; + let finalizeNavId = ''; + whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); - const metric = initMetric('LCP'); + let visibilityWatcher = getVisibilityWatcher(); + let metric = initMetric('LCP'); let report: ReturnType; const lcpEntryManager = initUnique(opts, LCPEntryManager); + const initNewLCPMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + metric = initMetric('LCP', 0, navigation, navigationId); + report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges); + reportedMetric = false; + if (navigation === 'soft-navigation') { + visibilityWatcher = getVisibilityWatcher(true); + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = softNavEntry?.startTime ?? 0; + } + }; + const handleEntries = (entries: LCPMetric['entries']) => { // If reportAllChanges is set then call this function for each entry, - // otherwise only consider the last one. - if (!opts.reportAllChanges) { + // otherwise only consider the last one, unless soft navs are enabled. + if (!opts.reportAllChanges && !softNavsEnabled) { // eslint-disable-next-line no-param-reassign entries = entries.slice(-1); } for (const entry of entries) { + if (softNavsEnabled && entry?.navigationId !== metric.navigationId) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so emit the final LCP and reinitialize the + // metric. + if (!reportedMetric) report(true); + initNewLCPMetric('soft-navigation', entry.navigationId); + } + let value = 0; + if (!entry.navigationId || entry.navigationId === hardNavId) { + // The startTime attribute returns the value of the renderTime if it is + // not 0, and the value of the loadTime otherwise. The activationStart + // reference is used because LCP should be relative to page activation + // rather than navigation start if the page was prerendered. But in cases + // where `activationStart` occurs after the LCP, this time should be + // clamped at 0. + value = Math.max(entry.startTime - getActivationStart(), 0); + } else { + // As a soft nav needs an interaction, it should never be before + // getActivationStart so can just cap to 0 + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavEntryStartTime = softNavEntry?.startTime ?? 0; + value = Math.max(entry.startTime - softNavEntryStartTime, 0); + } + lcpEntryManager._processEntry(entry); // Only report if the page wasn't hidden prior to LCP. @@ -68,36 +110,37 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = // rather than navigation start if the page was prerendered. But in cases // where `activationStart` occurs after the LCP, this time should be // clamped at 0. - metric.value = Math.max(entry.startTime - getActivationStart(), 0); + metric.value = value; metric.entries = [entry]; report(); } } }; - const po = observe('largest-contentful-paint', handleEntries); + const po = observe('largest-contentful-paint', handleEntries, opts); if (po) { report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges); - // Ensure this logic only runs once, since it can be triggered from - // any of three different event listeners below. - const stopListening = runOnce(() => { - handleEntries(po.takeRecords() as LCPMetric['entries']); - po.disconnect(); - report(true); - }); - - // Need a separate wrapper to ensure the `runOnce` function above is - // common for all three functions - const stopListeningWrapper = (event: Event) => { - if (event.isTrusted) { + const finalizeLCP = (event: Event) => { + if (event.isTrusted && !reportedMetric) { + // Finalize the current navigationId metric. + finalizeNavId = metric.navigationId; // Wrap the listener in an idle callback so it's run in a separate // task to reduce potential INP impact. // https://github.com/GoogleChrome/web-vitals/issues/383 - whenIdleOrHidden(stopListening); - removePageListener(event.type, stopListeningWrapper, { - capture: true, + whenIdleOrHidden(() => { + if (!reportedMetric) { + handleEntries(po.takeRecords() as LCPMetric['entries']); + if (!softNavsEnabled) { + po.disconnect(); + removeEventListener(event.type, finalizeLCP); + } + if (metric.navigationId === finalizeNavId) { + reportedMetric = true; + report(true); + } + } }); } }; @@ -107,10 +150,40 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = // unreliable since it can be programmatically generated. // See: https://github.com/GoogleChrome/web-vitals/issues/75 for (const type of ['keydown', 'click', 'visibilitychange']) { - addPageListener(type, stopListeningWrapper, { + addPageListener(type, finalizeLCP, { capture: true, }); } + + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach(entry => { + const softNavEntry = entry.navigationId ? getSoftNavigationEntry(entry.navigationId) : null; + if ( + entry?.navigationId !== metric.navigationId && + softNavEntry?.startTime && + softNavEntry.startTime > metricNavStartTime + ) { + handleEntries(po.takeRecords() as LCPMetric['entries']); + if (!reportedMetric) report(true); + initNewLCPMetric('soft-navigation', entry.navigationId); + } + }); + }; + + if (softNavsEnabled) { + observe('interaction-contentful-paint', handleEntries, opts); + observe('soft-navigation', handleSoftNavEntries, opts); + } } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts index 726699bc2010..da48e0384b61 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/InteractionManager.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { Metric } from '../types'; import { getInteractionCount } from './polyfills/interactionCountPolyfill.js'; export interface Interaction { @@ -79,12 +80,21 @@ export class InteractionManager { * interaction candidates and the interaction count for the current page. */ // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility - _estimateP98LongestInteraction() { + _estimateP98LongestInteraction(navigationType?: Metric['navigationType']) { + const interactionCountForNavigation = getInteractionCountForNavigation(); const candidateInteractionIndex = Math.min( this._longestInteractionList.length - 1, - Math.floor(getInteractionCountForNavigation() / 50), + Math.floor(interactionCountForNavigation / 50), ); + if (interactionCountForNavigation && candidateInteractionIndex === -1 && navigationType === 'soft-navigation') { + return { + _latency: 8, + id: -1, + entries: [] as PerformanceEventTiming[], + }; + } + return this._longestInteractionList[candidateInteractionIndex]; } diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts index 752c6c41469b..8fe346384706 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts @@ -17,10 +17,10 @@ // eslint-disable-next-line jsdoc/require-jsdoc export class LCPEntryManager { // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility - _onBeforeProcessingEntry?: (entry: LargestContentfulPaint) => void; + _onBeforeProcessingEntry?: (entry: LargestContentfulPaint | InteractionContentfulPaint) => void; // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, jsdoc/require-jsdoc - _processEntry(entry: LargestContentfulPaint) { + _processEntry(entry: LargestContentfulPaint | InteractionContentfulPaint) { this._onBeforeProcessingEntry?.(entry); } } diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts index 33677466faf9..6866ec306689 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts @@ -17,6 +17,7 @@ import { getNavigationEntry } from './getNavigationEntry'; export const getActivationStart = (): number => { - const navEntry = getNavigationEntry(); - return navEntry?.activationStart ?? 0; + const hardNavEntry = getNavigationEntry(); + + return hardNavEntry?.activationStart ?? 0; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts index 3eaea296a655..cdaff99de6d5 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts @@ -61,7 +61,11 @@ const onVisibilityUpdate = (event: Event) => { } }; -export const getVisibilityWatcher = () => { +export const getVisibilityWatcher = (reset = false) => { + if (reset) { + firstHiddenTime = Infinity; + } + if (WINDOW.document && firstHiddenTime < 0) { // Check if we have a previous hidden `visibility-state` performance entry. const activationStart = getActivationStart(); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts index 8771a5966c9f..7bdf0a4d1408 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts @@ -19,24 +19,35 @@ import type { MetricType } from '../types'; import { generateUniqueID } from './generateUniqueID'; import { getActivationStart } from './getActivationStart'; import { getNavigationEntry } from './getNavigationEntry'; +import { getSoftNavigationEntry } from './softNavs'; -export const initMetric = (name: MetricName, value: number = -1) => { - const navEntry = getNavigationEntry(); +export const initMetric = ( + name: MetricName, + value: number = -1, + navigation?: MetricType['navigationType'], + navigationId?: string, + interactionId?: number, +) => { + const hardNavId = getNavigationEntry()?.navigationId || '1'; + const hardNavEntry = getNavigationEntry(); let navigationType: MetricType['navigationType'] = 'navigate'; - if (navEntry) { + if (navigation) { + // If it was passed in, then use that + navigationType = navigation; + } else if (hardNavEntry) { if (WINDOW.document?.prerendering || getActivationStart() > 0) { navigationType = 'prerender'; } else if (WINDOW.document?.wasDiscarded) { navigationType = 'restore'; - } else if (navEntry.type) { - navigationType = navEntry.type.replace(/_/g, '-') as MetricType['navigationType']; + } else if (hardNavEntry.type) { + navigationType = hardNavEntry.type.replace(/_/g, '-') as MetricType['navigationType']; } } // Use `entries` type specific for the metric. const entries: Extract['entries'] = []; - + const softNavEntry = getSoftNavigationEntry(navigationId); return { name, value, @@ -45,5 +56,9 @@ export const initMetric = (name: MetricNa entries, id: generateUniqueID(), navigationType, + navigationId: navigationId || hardNavId, + interactionId: interactionId ?? softNavEntry?.interactionId, + navigationURL: softNavEntry?.name || getNavigationEntry()?.name, + navigationStartTime: softNavEntry?.startTime || 0, }; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts index 6071893dfa8e..4e8470247f58 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts @@ -13,16 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { softNavs } from './softNavs'; interface PerformanceEntryMap { event: PerformanceEventTiming[]; 'first-input': PerformanceEventTiming[]; + 'interaction-contentful-paint': InteractionContentfulPaint[]; 'layout-shift': LayoutShift[]; 'largest-contentful-paint': LargestContentfulPaint[]; 'long-animation-frame': PerformanceLongAnimationFrameTiming[]; paint: PerformancePaintTiming[]; navigation: PerformanceNavigationTiming[]; resource: PerformanceResourceTiming[]; + 'soft-navigation': SoftNavigationEntry[]; // Sentry-specific change: // We add longtask as a supported entry type as we use this in // our `instrumentPerformanceObserver` function also observes 'longtask' @@ -46,6 +49,8 @@ export const observe = ( callback: (entries: PerformanceEntryMap[K]) => void, opts: PerformanceObserverInit = {}, ): PerformanceObserver | undefined => { + const includeSoftNavigationObservations = softNavs(opts); + try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { const po = new PerformanceObserver(list => { @@ -54,10 +59,20 @@ export const observe = ( // See: https://github.com/GoogleChrome/web-vitals/issues/277 // eslint-disable-next-line @typescript-eslint/no-floating-promises Promise.resolve().then(() => { - callback(list.getEntries() as PerformanceEntryMap[K]); + const entries = list.getEntries(); + entries.sort((a, b) => { + return a.startTime + a.duration - (b.startTime + b.duration); + }); + callback(entries as PerformanceEntryMap[K]); }); }); - po.observe({ type, buffered: true, ...opts }); + po.observe({ + type, + buffered: true, + includeSoftNavigationObservations, + ...opts, + }); + return po; } } catch { diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts b/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts index 89d5ac59f722..6d7866dc173c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { getNavigationEntry } from '../getNavigationEntry'; import { observe } from '../observe'; declare global { @@ -25,10 +25,23 @@ declare global { let interactionCountEstimate = 0; let minKnownInteractionId = Infinity; let maxKnownInteractionId = 0; +let currentNavId = ''; +let softNavsEnabled = false; const updateEstimate = (entries: PerformanceEventTiming[]) => { + if (!currentNavId) { + currentNavId = getNavigationEntry()?.navigationId || '1'; + } + entries.forEach(e => { if (e.interactionId) { + if (softNavsEnabled && e.navigationId && e.navigationId !== currentNavId) { + currentNavId = e.navigationId; + interactionCountEstimate = 0; + minKnownInteractionId = Infinity; + maxKnownInteractionId = 0; + } + minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId); maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId); @@ -50,12 +63,15 @@ export const getInteractionCount = (): number => { /** * Feature detects native support or initializes the polyfill if needed. */ -export const initInteractionCountPolyfill = (): void => { +export const initInteractionCountPolyfill = (softNavs?: boolean) => { if ('interactionCount' in performance || po) return; + softNavsEnabled = softNavs || false; + po = observe('event', updateEstimate, { type: 'event', buffered: true, durationThreshold: 0, + includeSoftNavigationObservations: softNavsEnabled, }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/softNavs.ts b/packages/browser-utils/src/metrics/web-vitals/lib/softNavs.ts new file mode 100644 index 000000000000..b8aa4753b74f --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/softNavs.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ReportOpts } from '../types'; + +export const softNavs = (opts?: ReportOpts) => { + return PerformanceObserver.supportedEntryTypes.includes('soft-navigation') && opts?.reportSoftNavs; +}; + +export const getSoftNavigationEntry = (navigationId?: string): SoftNavigationEntry | undefined => { + if (!navigationId) return; + + const softNavEntry = globalThis.performance + .getEntriesByType('soft-navigation') + .filter(entry => entry.navigationId === navigationId); + if (softNavEntry) return softNavEntry[0]; + + return; +}; diff --git a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts index 12fd51e29ef7..e9f7df2acb41 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts @@ -16,11 +16,13 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; +import { getNavigationEntry } from './lib/getNavigationEntry'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { observe } from './lib/observe'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; -import type { FCPMetric, MetricRatingThresholds, ReportOpts } from './types'; +import type { FCPMetric, Metric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for FCP. See https://web.dev/articles/fcp#what_is_a_good_fcp_score */ export const FCPThresholds: MetricRatingThresholds = [1800, 3000]; @@ -32,34 +34,100 @@ export const FCPThresholds: MetricRatingThresholds = [1800, 3000]; * value is a `DOMHighResTimeStamp`. */ export const onFCP = (onReport: (metric: FCPMetric) => void, opts: ReportOpts = {}) => { + // Set defaults + const softNavsEnabled = softNavs(opts); + let metricNavStartTime = 0; + const hardNavId = getNavigationEntry()?.navigationId || '1'; + whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); - const metric = initMetric('FCP'); + let visibilityWatcher = getVisibilityWatcher(); + let metric = initMetric('FCP'); let report: ReturnType; + const initNewFCPMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + metric = initMetric('FCP', 0, navigation, navigationId); + report = bindReporter(onReport, metric, FCPThresholds, opts.reportAllChanges); + if (navigation === 'soft-navigation') { + visibilityWatcher = getVisibilityWatcher(true); + const softNavEntry = navigationId ? getSoftNavigationEntry(navigationId) : null; + metricNavStartTime = softNavEntry ? softNavEntry.startTime || 0 : 0; + } + }; + const handleEntries = (entries: FCPMetric['entries']) => { for (const entry of entries) { if (entry.name === 'first-contentful-paint') { - po!.disconnect(); + if (!softNavsEnabled) { + // If we're not using soft navs monitoring, we should not see + // any more FCPs so can disconnect the performance observer + po!.disconnect(); + } else if (entry.navigationId && entry.navigationId !== metric.navigationId) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so reinitialize the metric. + initNewFCPMetric('soft-navigation', entry.navigationId); + } + + let value = 0; - // Only report if the page wasn't hidden prior to the first paint. - if (entry.startTime < visibilityWatcher.firstHiddenTime) { + if (!entry.navigationId || entry.navigationId === hardNavId) { + // Only report if the page wasn't hidden prior to the first paint. // The activationStart reference is used because FCP should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the FCP, this time should be clamped at 0. - metric.value = Math.max(entry.startTime - getActivationStart(), 0); + value = Math.max(entry.startTime - getActivationStart(), 0); + } else { + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavStartTime = softNavEntry?.startTime ?? 0; + // As a soft nav needs an interaction, it should never be before + // getActivationStart so can just cap to 0 + value = Math.max(entry.startTime - softNavStartTime, 0); + } + + // Only report if the page wasn't hidden prior to FCP. + // Or it's a soft nav FCP + const softNavEntry = + softNavsEnabled && entry.navigationId ? getSoftNavigationEntry(entry.navigationId) : null; + const softNavEntryStartTime = softNavEntry?.startTime ?? 0; + if ( + entry.startTime < visibilityWatcher.firstHiddenTime || + (softNavsEnabled && + entry.navigationId && + entry.navigationId !== metric.navigationId && + entry.navigationId !== hardNavId && + softNavEntryStartTime > metricNavStartTime) + ) { + metric.value = value; metric.entries.push(entry); + metric.navigationId = entry.navigationId || '1'; + // FCP should only be reported once so can report right report(true); } } } }; - const po = observe('paint', handleEntries); + const po = observe('paint', handleEntries, opts); if (po) { report = bindReporter(onReport, metric, FCPThresholds, opts.reportAllChanges); } + + if (softNavsEnabled) { + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach(entry => { + handleEntries(po!.takeRecords() as FCPMetric['entries']); + const fcpTime = (entry.presentationTime || entry.paintTime || 0) - entry.startTime; + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavEntryStartTime = softNavEntry?.startTime ?? 0; + if (softNavEntryStartTime > metricNavStartTime) { + initNewFCPMetric('soft-navigation', entry.navigationId); + metric.value = Math.max(fcpTime, 0); + report(true); + } + }); + }; + observe('soft-navigation', handleSoftNavEntries, opts); + } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts index 4633b3cd83cb..7dfb9ee57aa9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts @@ -19,6 +19,8 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; import { getNavigationEntry } from './lib/getNavigationEntry'; import { initMetric } from './lib/initMetric'; +import { observe } from './lib/observe'; +import { softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; import type { MetricRatingThresholds, ReportOpts, TTFBMetric } from './types'; @@ -56,21 +58,40 @@ const whenReady = (callback: () => void) => { * and server processing time. */ export const onTTFB = (onReport: (metric: TTFBMetric) => void, opts: ReportOpts = {}) => { - const metric = initMetric('TTFB'); - const report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); + // Set defaults + const softNavsEnabled = softNavs(opts); - whenReady(() => { - const navigationEntry = getNavigationEntry(); + let metric = initMetric('TTFB'); + let report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); - if (navigationEntry) { + whenReady(() => { + const hardNavEntry = getNavigationEntry(); + if (hardNavEntry) { + const responseStart = hardNavEntry.responseStart; // The activationStart reference is used because TTFB should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the first byte is received, this time should be clamped at 0. - metric.value = Math.max(navigationEntry.responseStart - getActivationStart(), 0); + metric.value = Math.max(responseStart - getActivationStart(), 0); - metric.entries = [navigationEntry]; + metric.entries = [hardNavEntry]; report(true); + + // Listen for soft-navigation entries and emit a dummy 0 TTFB entry + const reportSoftNavTTFBs = (entries: SoftNavigationEntry[]) => { + entries.forEach(entry => { + if (entry.navigationId) { + metric = initMetric('TTFB', 0, 'soft-navigation', entry.navigationId); + metric.entries = [entry]; + report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); + report(true); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', reportSoftNavTTFBs, opts); + } } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/types.ts b/packages/browser-utils/src/metrics/web-vitals/types.ts index 826cc8a5face..4ca88dacff4b 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types.ts @@ -30,6 +30,8 @@ interface PerformanceEntryMap { navigation: PerformanceNavigationTiming; resource: PerformanceResourceTiming; paint: PerformancePaintTiming; + 'interaction-contentful-paint': InteractionContentfulPaint; + 'soft-navigation': SoftNavigationEntry; } // Update built-in types to be more accurate. @@ -45,20 +47,28 @@ declare global { getEntriesByType(type: K): PerformanceEntryMap[K][]; } + // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline + interface PerformancePaintTiming extends PerformanceEntry { + navigationId?: string; + } + // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline interface PerformanceObserverInit { durationThreshold?: number; + includeSoftNavigationObservations?: boolean; } // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension interface PerformanceNavigationTiming { activationStart?: number; + navigationId?: string; } // https://wicg.github.io/event-timing/#sec-performance-event-timing interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; readonly interactionId: number; + navigationId?: string; } // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution @@ -66,6 +76,7 @@ declare global { node: Node | null; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly; + navigationId?: string; } // https://wicg.github.io/layout-instability/#sec-layout-shift @@ -73,6 +84,7 @@ declare global { value: number; sources: LayoutShiftAttribution[]; hadRecentInput: boolean; + navigationId?: string; } // https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface @@ -83,6 +95,28 @@ declare global { readonly id: string; readonly url: string; readonly element: Element | null; + navigationId?: string; + } + + // https://github.com/WICG/soft-navigations + interface SoftNavigationEntry extends PerformanceEntry { + readonly interactionId: number; + readonly navigationType?: NavigationType; + readonly paintTime?: number; + readonly presentationTime?: number; + readonly largestInteractionContentfulPaint: InteractionContentfulPaint; + readonly getLargestInteractionContentfulPaint?: () => InteractionContentfulPaint | null; + navigationId?: string; + } + + interface InteractionContentfulPaint extends PerformanceEntry { + readonly renderTime: DOMHighResTimeStamp; + readonly loadTime: DOMHighResTimeStamp; + readonly size: number; + readonly id: string; + readonly url: string; + readonly element: Element | null; + navigationId?: string; } // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming diff --git a/packages/browser-utils/src/metrics/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts index cac7fdac1d11..50362885ffc9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -54,6 +54,11 @@ export interface Metric { */ id: string; + /** + * The interactionId that started this interaction for soft navigations + */ + interactionId?: number; + /** * Any performance entries relevant to the metric value calculation. * The array may also be empty if the metric value was not based on any @@ -72,8 +77,36 @@ export interface Metric { * - 'prerender': for pages that were prerendered. * - 'restore': for pages that were discarded by the browser and then * restored by the user. + * - 'soft-navigation': for soft navigations. + */ + navigationType: + | 'navigate' + | 'reload' + | 'back-forward' + | 'back-forward-cache' + | 'prerender' + | 'restore' + | 'soft-navigation'; + + /** + * The navigationId the metric happened for. This is particularly relevant for soft navigations where + * the metric may be reported for a previous URL. + * + * navigationIds are UUID strings. + */ + navigationId: string; + + /** + * The navigation URL the metric happened for. This is particularly relevant for soft navigations where + * the metric may be reported for a previous URL. + */ + navigationURL?: string; + + /** + * The navigation startTime the metric is based from. This is particularly + * relevant for soft navigations where time origin is not 0. */ - navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore'; + navigationStartTime?: number; } /** The union of supported metric types. */ @@ -113,6 +146,8 @@ export interface ReportCallback { export interface ReportOpts { reportAllChanges?: boolean; + durationThreshold?: number; + reportSoftNavs?: boolean; } export interface AttributionReportOpts extends ReportOpts { diff --git a/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts index ce668192766f..f93f627d3b51 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts @@ -52,9 +52,9 @@ export interface FCPAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for example: - * navigationEntry?.serverTiming + * navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts index 9de6b32a5f94..1e9bac10044c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts @@ -21,7 +21,7 @@ import type { Metric } from './base.js'; */ export interface LCPMetric extends Metric { name: 'LCP'; - entries: LargestContentfulPaint[]; + entries: (LargestContentfulPaint | InteractionContentfulPaint)[]; } /** @@ -70,18 +70,19 @@ export interface LCPAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for example: - * navigationEntry?.serverTiming + * navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; /** * The `resource` entry for the LCP resource (if applicable), which is useful * for diagnosing resource load issues. */ lcpResourceEntry?: PerformanceResourceTiming; /** - * The `LargestContentfulPaint` entry corresponding to LCP. + * The `LargestContentfulPaint` entry corresponding to LCP + * (or `InteractionContentfulPaint` for soft navigations). */ - lcpEntry?: LargestContentfulPaint; + lcpEntry?: LargestContentfulPaint | InteractionContentfulPaint; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts b/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts index 2a43668d7d8f..d9d12a65bc16 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts @@ -21,7 +21,7 @@ import type { Metric } from './base'; */ export interface TTFBMetric extends Metric { name: 'TTFB'; - entries: PerformanceNavigationTiming[]; + entries: PerformanceNavigationTiming[] | SoftNavigationEntry[]; } /** @@ -65,9 +65,9 @@ export interface TTFBAttribution { /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for - * example: navigationEntry?.serverTiming + * example: navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; } /** diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 85f09b801ef2..b1a7af31becf 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -125,7 +125,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { /** * Tracks LCP as a streamed span. */ -export function trackLcpAsSpan(client: Client): void { +export function trackLcpAsSpan(client: Client, reportSoftNavs?: boolean): void { let lcpValue = 0; let lcpEntry: LargestContentfulPaint | undefined; @@ -133,14 +133,18 @@ export function trackLcpAsSpan(client: Client): void { return; } - const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; - if (!entry || !isValidLcpMetric(metric.value)) { - return; - } - lcpValue = metric.value; - lcpEntry = entry; - }, true); + const cleanupLcpHandler = addLcpInstrumentationHandler( + ({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; + if (!entry || !isValidLcpMetric(metric.value)) { + return; + } + lcpValue = metric.value; + lcpEntry = entry; + }, + true, + reportSoftNavs, + ); listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent); @@ -194,7 +198,7 @@ export function _sendLcpSpan( /** * Tracks CLS as a streamed span. */ -export function trackClsAsSpan(client: Client): void { +export function trackClsAsSpan(client: Client, reportSoftNavs?: boolean): void { let clsValue = 0; let clsEntry: LayoutShift | undefined; @@ -202,14 +206,18 @@ export function trackClsAsSpan(client: Client): void { return; } - const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; - if (!entry) { - return; - } - clsValue = metric.value; - clsEntry = entry; - }, true); + const cleanupClsHandler = addClsInstrumentationHandler( + ({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + if (!entry) { + return; + } + clsValue = metric.value; + clsEntry = entry; + }, + true, + reportSoftNavs, + ); listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { _sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent); @@ -257,7 +265,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?: boolean): void { const performance = getBrowserPerformanceAPI(); if (!performance || !browserPerformanceTimeOrigin()) { return; @@ -290,7 +298,7 @@ export function trackInpAsSpan(client: Client): void { _sendInpSpan(metric.value, entry, standalone); }; - addInpInstrumentationHandler(onInp); + addInpInstrumentationHandler(onInp, reportSoftNavs); } /** diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts index a8b969a0ff6e..b6bcf9a755aa 100644 --- a/packages/browser/src/integrations/webVitals.ts +++ b/packages/browser/src/integrations/webVitals.ts @@ -18,6 +18,12 @@ export interface WebVitalsOptions { * Web vitals to skip. */ ignore?: WebVitalName[]; + + /** + * Experimental: report web vitals for Chrome soft navigations in addition to the initial pageload. + * Requires the Soft Navigation API (origin trial or `#soft-navigation-heuristics` flag). + */ + reportSoftNavs?: boolean; } /** @@ -29,6 +35,7 @@ export interface WebVitalsOptions { */ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions = {}) => { const ignored = new Set(options.ignore ?? []); + const reportSoftNavs = options.reportSoftNavs; return { name: WEB_VITALS_INTEGRATION_NAME, @@ -43,6 +50,7 @@ export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions const finalizeWebVitals = startTrackingWebVitals({ trackCls: trackClsOnPageloadSpan, trackLcp: trackLcpOnPageloadSpan, + reportSoftNavs, client, }); @@ -67,17 +75,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 cb1be728f26f..b424972f1666 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -266,6 +266,7 @@ export interface BrowserTracingOptions { */ _experiments: Partial<{ enableInteractions: boolean; + enableSoftNavWebVitals: boolean; }>; /** @@ -346,7 +347,7 @@ export const browserTracingIntegration = ((options: Partial { expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ trackCls: true, trackLcp: true, + reportSoftNavs: undefined, client, }); - expect(mockTrackInpAsSpan).toHaveBeenCalledTimes(1); + expect(mockTrackInpAsSpan).toHaveBeenCalledWith(client, undefined); expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); expect(mockTrackLcpAsSpan).not.toHaveBeenCalled(); expect(mockTrackClsAsSpan).not.toHaveBeenCalled(); @@ -79,11 +80,12 @@ describe('webVitalsIntegration', () => { expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ trackCls: false, trackLcp: false, + reportSoftNavs: undefined, client, }); - expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client); - expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client); - expect(mockTrackInpAsSpan).toHaveBeenCalledTimes(1); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client, undefined); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client, undefined); + expect(mockTrackInpAsSpan).toHaveBeenCalledWith(client, undefined); expect(mockRegisterInpInteractionListener).toHaveBeenCalledTimes(1); }); @@ -95,8 +97,8 @@ describe('webVitalsIntegration', () => { integration.afterAllSetup?.(client as never); expect(mockTrackLcpAsSpan).not.toHaveBeenCalled(); - expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client); - expect(mockTrackInpAsSpan).toHaveBeenCalledTimes(1); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client, undefined); + expect(mockTrackInpAsSpan).toHaveBeenCalledWith(client, undefined); }); it('supports ignoring selected web vitals', () => { @@ -109,12 +111,31 @@ describe('webVitalsIntegration', () => { expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ trackCls: false, trackLcp: false, + reportSoftNavs: undefined, client, }); expect(mockTrackInpAsSpan).not.toHaveBeenCalled(); expect(mockRegisterInpInteractionListener).not.toHaveBeenCalled(); }); + it('threads reportSoftNavs through to the tracking functions', () => { + const client = getMockClient({ traceLifecycle: 'stream' }); + const integration = webVitalsIntegration({ reportSoftNavs: true }); + + integration.setup?.(client as never); + integration.afterAllSetup?.(client as never); + + expect(mockStartTrackingWebVitals).toHaveBeenCalledWith({ + trackCls: false, + trackLcp: false, + reportSoftNavs: true, + client, + }); + expect(mockTrackLcpAsSpan).toHaveBeenCalledWith(client, true); + expect(mockTrackClsAsSpan).toHaveBeenCalledWith(client, true); + expect(mockTrackInpAsSpan).toHaveBeenCalledWith(client, true); + }); + it('finalizes web vitals and writes them onto the pageload span when it ends', () => { const finalizeWebVitals = vi.fn(); const client = getMockClient();