diff --git a/.size-limit.js b/.size-limit.js index 32483540f9..d172b6da9a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -2,9 +2,10 @@ const limits = [ { // analytics-browser bundle // Bumped 65kb → 66kb for shadow DOM support in plugin-autocapture-browser - // (SR-4788). Current actual: ~65.0kb gzipped. + // (SR-4788), then 66kb → 68kb for soft navigation support in + // plugin-web-vitals-browser (web-vitals v6). Current actual: ~65.7kb gzipped. path: './packages/analytics-browser/lib/scripts/amplitude-min.js.gz', - limit: '66kb', + limit: '68kb', brotli: false, }, { @@ -15,8 +16,10 @@ const limits = [ }, { // unified SDK bundle + // Bumped 225kb → 228kb for soft navigation support in + // plugin-web-vitals-browser (web-vitals v6). Current actual: ~220.1kb gzipped. path: './packages/unified/lib/scripts/amplitude-min.umd.js.gz', - limit: '225kb', + limit: '228kb', brotli: false, }, { diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index ed1ab5eb50..9ecac1a582 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -46,6 +46,7 @@ import { isPageViewTrackingEnabled, isNetworkTrackingEnabled, isWebVitalsEnabled, + getWebVitalsConfig, isFrustrationInteractionsEnabled, getFrustrationInteractionsConfig, isPerformanceTrackingEnabled, @@ -374,7 +375,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An if (isWebVitalsEnabled(this.config.autocapture)) { this.config.loggerProvider.debug('Adding web vitals plugin'); - await this.add(webVitalsPlugin()).promise; + await this.add(webVitalsPlugin(getWebVitalsConfig(this.config))).promise; } if (isPerformanceTrackingEnabled(this.config.autocapture)) { diff --git a/packages/analytics-browser/src/default-tracking.ts b/packages/analytics-browser/src/default-tracking.ts index 2f287c3d41..363e2b3c30 100644 --- a/packages/analytics-browser/src/default-tracking.ts +++ b/packages/analytics-browser/src/default-tracking.ts @@ -11,6 +11,7 @@ import { FrustrationInteractionsOptions, CustomEnrichmentOptions, PerformanceTrackingOptions, + WebVitalsOptions, isChromeExtension, normalizeNetworkCaptureRules, } from '@amplitude/analytics-core'; @@ -111,7 +112,7 @@ export const isElementInteractionsEnabled = (autocapture: AutocaptureOptions | b /** * Returns true if * 1. autocapture === true - * 2. if autocapture.webVitals === true + * 2. if autocapture.webVitals === true or is an options object * otherwise returns false */ export const isWebVitalsEnabled = (autocapture: AutocaptureOptions | boolean | undefined): boolean => { @@ -119,13 +120,34 @@ export const isWebVitalsEnabled = (autocapture: AutocaptureOptions | boolean | u return autocapture; } - if (typeof autocapture === 'object' && autocapture.webVitals === true) { + if ( + typeof autocapture === 'object' && + (autocapture.webVitals === true || (typeof autocapture.webVitals === 'object' && autocapture.webVitals !== null)) + ) { return true; } return false; }; +/** + * Returns the web vitals options when autocapture.webVitals is configured with an options object, + * otherwise returns undefined so the plugin falls back to its defaults. + */ +export const getWebVitalsConfig = (config: BrowserOptions): WebVitalsOptions | undefined => { + if (typeof config.autocapture !== 'object') { + return undefined; + } + + const webVitals = config.autocapture.webVitals; + + if (typeof webVitals === 'object' && webVitals !== null) { + return webVitals; + } + + return undefined; +}; + export const isFrustrationInteractionsEnabled = (autocapture: AutocaptureOptions | boolean | undefined): boolean => { if (typeof autocapture === 'boolean') { return autocapture; diff --git a/packages/analytics-browser/test/browser-client.test.ts b/packages/analytics-browser/test/browser-client.test.ts index 5aca152ef0..88c7419da3 100644 --- a/packages/analytics-browser/test/browser-client.test.ts +++ b/packages/analytics-browser/test/browser-client.test.ts @@ -876,6 +876,18 @@ describe('browser-client', () => { }, }).promise; expect(webVitalsPlugin).toHaveBeenCalledTimes(1); + expect(webVitalsPlugin).toHaveBeenCalledWith(undefined); + }); + + test('should pass web vitals options to the plugin when autocapture.webVitals is an object', async () => { + const webVitalsPlugin = jest.spyOn(webVitals, 'webVitalsPlugin'); + await client.init(apiKey, userId, { + autocapture: { + webVitals: { reportSoftNav: true }, + }, + }).promise; + expect(webVitalsPlugin).toHaveBeenCalledTimes(1); + expect(webVitalsPlugin).toHaveBeenCalledWith({ reportSoftNav: true }); }); test('should listen for network change to online', async () => { diff --git a/packages/analytics-browser/test/default-tracking.test.ts b/packages/analytics-browser/test/default-tracking.test.ts index d5a0fbe186..c8c3c7de3c 100644 --- a/packages/analytics-browser/test/default-tracking.test.ts +++ b/packages/analytics-browser/test/default-tracking.test.ts @@ -7,6 +7,7 @@ import { getNetworkTrackingConfig, getPageViewTrackingConfig, getPerformanceTrackingConfig, + getWebVitalsConfig, isAttributionTrackingEnabled, isCustomEnrichmentEnabled, isElementInteractionsEnabled, @@ -60,6 +61,11 @@ describe('isWebVitalsEnabled', () => { test('autocapture.webVitals=true', () => { expect(isWebVitalsEnabled({ webVitals: true })).toBe(true); }); + + test('autocapture.webVitals is an options object', () => { + expect(isWebVitalsEnabled({ webVitals: { reportSoftNav: true } })).toBe(true); + expect(isWebVitalsEnabled({ webVitals: {} })).toBe(true); + }); }); describe('is false when', () => { @@ -74,6 +80,32 @@ describe('isWebVitalsEnabled', () => { test('autocapture.webVitals is undefined', () => { expect(isWebVitalsEnabled({ networkTracking: true })).toBe(false); }); + + test('autocapture.webVitals is null', () => { + expect(isWebVitalsEnabled({ webVitals: null as unknown as undefined })).toBe(false); + }); + }); +}); + +describe('getWebVitalsConfig', () => { + test('should return the options when autocapture.webVitals is an options object', () => { + expect(getWebVitalsConfig({ autocapture: { webVitals: { reportSoftNav: true } } })).toEqual({ + reportSoftNav: true, + }); + }); + + test('should return undefined when autocapture.webVitals is a boolean', () => { + expect(getWebVitalsConfig({ autocapture: { webVitals: true } })).toBeUndefined(); + expect(getWebVitalsConfig({ autocapture: { webVitals: false } })).toBeUndefined(); + }); + + test('should return undefined when autocapture.webVitals is null', () => { + expect(getWebVitalsConfig({ autocapture: { webVitals: null as unknown as undefined } })).toBeUndefined(); + }); + + test('should return undefined when autocapture is not an object', () => { + expect(getWebVitalsConfig({ autocapture: true })).toBeUndefined(); + expect(getWebVitalsConfig({})).toBeUndefined(); }); }); diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index bc0d9b410b..7556dbd88b 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -124,6 +124,7 @@ export { SAFE_HEADERS, FORBIDDEN_HEADERS } from './types/constants'; export { PageUrlEnrichmentOptions } from './types/page-url-enrichment'; export { CustomEnrichmentOptions } from './types/custom-enrichment'; export { PerformanceTrackingOptions, MainThreadBlockOptions } from './types/performance-tracking'; +export { WebVitalsOptions } from './types/web-vitals'; // Campaign export { Campaign, UTMParameters, ReferrerParameters, ClickIdParameters, ICampaignParser } from './types/campaign'; diff --git a/packages/analytics-core/src/types/config/browser-config.ts b/packages/analytics-core/src/types/config/browser-config.ts index 6b6d1ce68c..3c2064451c 100644 --- a/packages/analytics-core/src/types/config/browser-config.ts +++ b/packages/analytics-core/src/types/config/browser-config.ts @@ -8,6 +8,7 @@ import { PageTrackingOptions } from '../page-view-tracking'; import { NetworkTrackingOptions } from '../network-tracking'; import { FrustrationInteractionsOptions } from '../frustration-interactions'; import { PerformanceTrackingOptions } from '../performance-tracking'; +import { WebVitalsOptions } from '../web-vitals'; import { IDiagnosticsClient } from '../../diagnostics/diagnostics-client'; import { IRemoteConfigClient } from '../../remote-config/remote-config'; import { CustomEnrichmentOptions } from '../custom-enrichment'; @@ -198,10 +199,10 @@ export interface AutocaptureOptions { */ networkTracking?: boolean | NetworkTrackingOptions; /** - * Enables/disables web vitals tracking. + * Enables/disables web vitals tracking or config with detailed web vitals options. * @defaultValue `false` */ - webVitals?: boolean; + webVitals?: boolean | WebVitalsOptions; /** * Enables/disables performance tracking. * @defaultValue `false` diff --git a/packages/analytics-core/src/types/web-vitals.ts b/packages/analytics-core/src/types/web-vitals.ts new file mode 100644 index 0000000000..1d21d54ef1 --- /dev/null +++ b/packages/analytics-core/src/types/web-vitals.ts @@ -0,0 +1,21 @@ +/** + * Configuration options for web vitals tracking. + */ +export interface WebVitalsOptions { + /** + * Enables/disables reporting web vitals for soft navigations, in addition to the initial page load. + * + * Single page applications update the URL and history without a full page navigation, so by default + * Core Web Vitals are only measured once, for the initial page load. When this is enabled, LCP, FCP, + * INP, CLS and TTFB are also measured per soft navigation, and one `[Amplitude] Web Vitals` event is + * sent per navigation with the page properties of the URL the metrics belong to. + * + * Requires browser support for the Soft Navigations API (Chromium 151+). In browsers without it, + * reporting is unchanged from the default behavior. + * + * See {@link https://github.com/WICG/soft-navigations}. + * + * @defaultValue `false` + */ + reportSoftNav?: boolean; +} diff --git a/packages/plugin-web-vitals-browser/README.md b/packages/plugin-web-vitals-browser/README.md index 31e57e0c88..e7f9e22a2e 100644 --- a/packages/plugin-web-vitals-browser/README.md +++ b/packages/plugin-web-vitals-browser/README.md @@ -35,6 +35,42 @@ import { webVitalsPlugin } from '@amplitude/plugin-web-vitals-browser'; const plugin = webVitalsPlugin(); ``` +#### Options + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `reportSoftNav` | `boolean` | `false` | Also report web vitals for soft navigations, not only for the initial page load. | + +##### `reportSoftNav` + +Single page applications update the URL and history without a full page navigation, so by default +Core Web Vitals are only measured once, for the initial page load. With `reportSoftNav` enabled, +LCP, FCP, INP, CLS and TTFB are also measured per +[soft navigation](https://github.com/WICG/soft-navigations), and one `[Amplitude] Web Vitals` event +is sent per navigation, with the page properties of the URL the metrics belong to. Metrics measured +for a soft navigation have a `navigationType` of `soft-navigation`. + +```typescript +const plugin = webVitalsPlugin({ reportSoftNav: true }); +``` + +This requires browser support for the Soft Navigations API (Chromium 151+). In browsers without it, +reporting is unchanged from the default behavior. + +Note that enabling this also changes how the initial page load is measured: its metrics are +finalized once the first soft navigation occurs, rather than when the page is hidden. + +When using the Browser SDK's autocapture, the same option can be set through +`autocapture.webVitals`: + +```typescript +amplitude.init('API_KEY', { + autocapture: { + webVitals: { reportSoftNav: true }, + }, +}); +``` + ### 3. Install plugin to Amplitude SDK ```typescript diff --git a/packages/plugin-web-vitals-browser/package.json b/packages/plugin-web-vitals-browser/package.json index 5608df5ba9..29e50f2eb6 100644 --- a/packages/plugin-web-vitals-browser/package.json +++ b/packages/plugin-web-vitals-browser/package.json @@ -41,7 +41,7 @@ "dependencies": { "@amplitude/analytics-core": "workspace:*", "tslib": "^2.4.1", - "web-vitals": "5.1.0" + "web-vitals": "6.2.1" }, "devDependencies": { "@rollup/plugin-commonjs": "^23.0.4", diff --git a/packages/plugin-web-vitals-browser/src/constants.ts b/packages/plugin-web-vitals-browser/src/constants.ts index 0bd6d2f39e..33cf0f1cf8 100644 --- a/packages/plugin-web-vitals-browser/src/constants.ts +++ b/packages/plugin-web-vitals-browser/src/constants.ts @@ -1,2 +1,9 @@ export const PLUGIN_NAME = 'web-vitals-browser'; export const WEB_VITALS_EVENT_NAME = '[Amplitude] Web Vitals'; + +/** + * How long to wait, after a newer navigation starts reporting metrics, before sending the event for + * a superseded navigation. Metrics for a navigation can be reported slightly after the next soft + * navigation begins, so the event is deferred to give those late metrics a chance to land. + */ +export const SOFT_NAV_FLUSH_DELAY_MS = 1000; diff --git a/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts b/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts index 36d52f4781..bd382f8cb1 100644 --- a/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts +++ b/packages/plugin-web-vitals-browser/src/web-vitals-plugin.ts @@ -3,11 +3,13 @@ import { BrowserClient, BrowserConfig, EnrichmentPlugin, + ILogger, + WebVitalsOptions, getGlobalScope, getDecodeURI, } from '@amplitude/analytics-core'; -import { PLUGIN_NAME, WEB_VITALS_EVENT_NAME } from './constants'; -import { onLCP, onINP, onCLS, onFCP, onTTFB, Metric } from 'web-vitals'; +import { PLUGIN_NAME, SOFT_NAV_FLUSH_DELAY_MS, WEB_VITALS_EVENT_NAME } from './constants'; +import { onLCP, onINP, onCLS, onFCP, onTTFB, Metric, ReportOpts } from 'web-vitals'; export type BrowserEnrichmentPlugin = EnrichmentPlugin; @@ -21,12 +23,16 @@ type WebVitalsMetricPayload = { navigationStart: number; }; +type WebVitalsMetricProperty = + | '[Amplitude] LCP' + | '[Amplitude] FCP' + | '[Amplitude] INP' + | '[Amplitude] CLS' + | '[Amplitude] TTFB'; + type WebVitalsEventPayload = { - '[Amplitude] LCP'?: WebVitalsMetricPayload; - '[Amplitude] FCP'?: WebVitalsMetricPayload; - '[Amplitude] INP'?: WebVitalsMetricPayload; - '[Amplitude] CLS'?: WebVitalsMetricPayload; - '[Amplitude] TTFB'?: WebVitalsMetricPayload; + [property in WebVitalsMetricProperty]?: WebVitalsMetricPayload; +} & { '[Amplitude] Page Domain'?: string; '[Amplitude] Page Location'?: string; '[Amplitude] Page Path'?: string; @@ -34,13 +40,27 @@ type WebVitalsEventPayload = { '[Amplitude] Page URL'?: string; }; +const METRIC_PROPERTIES: WebVitalsMetricProperty[] = [ + '[Amplitude] LCP', + '[Amplitude] FCP', + '[Amplitude] INP', + '[Amplitude] CLS', + '[Amplitude] TTFB', +]; + +/** + * Bucket key used when soft navigation reporting is off. All metrics belong to the initial page + * load, so they are collected into a single event. + */ +const INITIAL_NAVIGATION_KEY = 0; + function getMetricStartTime(metric: Metric) { /* istanbul ignore next */ const startTime = metric.entries[0]?.startTime || 0; return performance.timeOrigin + startTime; } -function processMetric(metric: Metric) { +function processMetric(metric: Metric): WebVitalsMetricPayload { return { value: metric.value, rating: metric.rating, @@ -48,53 +68,152 @@ function processMetric(metric: Metric) { navigationType: metric.navigationType, id: metric.id, timestamp: Math.floor(getMetricStartTime(metric)), - navigationStart: Math.floor(performance.timeOrigin), + // A soft navigation's metrics are measured from the start of that navigation rather than from + // the document's time origin. `navigationStartTime` is 0 for the initial page load. + navigationStart: Math.floor(performance.timeOrigin + /* istanbul ignore next */ (metric.navigationStartTime || 0)), + }; +} + +/** + * Builds the page properties for the URL the metrics belong to. That is the URL of the navigation + * being reported on, which is not necessarily the current URL: when reporting soft navigations, a + * navigation's metrics can be reported after the next navigation has already started. + */ +function getPageProperties(url: string, title: string, loggerProvider: ILogger): WebVitalsEventPayload { + let hostname = ''; + let pathname = ''; + try { + const parsedUrl = new URL(url); + hostname = parsedUrl.hostname; + pathname = parsedUrl.pathname; + } catch (e) { + loggerProvider.debug('Web vitals plugin is unable to parse page URL: ', e); + } + + const locationHref = getDecodeURI(url, loggerProvider); + + return { + '[Amplitude] Page Domain': hostname, + '[Amplitude] Page Location': locationHref, + '[Amplitude] Page Path': getDecodeURI(pathname, loggerProvider), + '[Amplitude] Page Title': title, + '[Amplitude] Page URL': getDecodeURI(locationHref.split('?')[0], loggerProvider), }; } -export const webVitalsPlugin = (): BrowserEnrichmentPlugin => { +function hasMetrics(payload: WebVitalsEventPayload): boolean { + return METRIC_PROPERTIES.some((property) => property in payload); +} + +export const webVitalsPlugin = (options: WebVitalsOptions = {}): BrowserEnrichmentPlugin => { + const reportSoftNavs = options.reportSoftNav === true; let visibilityListener: ((this: Document, ev: Event) => void) | null = null; + let flushTimeout: ReturnType | undefined; const globalScope = getGlobalScope(); const doc = globalScope?.document; const location = globalScope?.location; + const setup: BrowserEnrichmentPlugin['setup'] = async (config, amplitude) => { if (doc === undefined) { return; } - const locationHref = getDecodeURI(/* istanbul ignore next */ location?.href || '', config.loggerProvider); - const webVitalsPayload: WebVitalsEventPayload = { - '[Amplitude] Page Domain': /* istanbul ignore next */ location?.hostname || '', - '[Amplitude] Page Location': locationHref, - '[Amplitude] Page Path': getDecodeURI(/* istanbul ignore next */ location?.pathname || '', config.loggerProvider), - '[Amplitude] Page Title': /* istanbul ignore next */ (typeof document !== 'undefined' && document.title) || '', - '[Amplitude] Page URL': getDecodeURI(locationHref.split('?')[0], config.loggerProvider), + + // One payload per navigation, keyed by the navigation its metrics belong to. When soft + // navigation reporting is off there is only ever the initial page load's payload. + const payloads = new Map(); + let latestNavigationId = -1; + + if (!reportSoftNavs) { + payloads.set( + INITIAL_NAVIGATION_KEY, + getPageProperties( + /* istanbul ignore next */ location?.href || '', + /* istanbul ignore next */ doc.title || '', + config.loggerProvider, + ), + ); + } + + const getPayload = (metric: Metric): WebVitalsEventPayload => { + const key = reportSoftNavs ? metric.navigationId : INITIAL_NAVIGATION_KEY; + let payload = payloads.get(key); + if (!payload) { + payload = getPageProperties( + /* istanbul ignore next */ metric.navigationURL || location?.href || '', + /* istanbul ignore next */ doc.title || '', + config.loggerProvider, + ); + payloads.set(key, payload); + } + return payload; }; - onLCP((metric) => { - webVitalsPayload['[Amplitude] LCP'] = processMetric(metric); - }); + const flush = (key: number) => { + const payload = payloads.get(key); + /* istanbul ignore if */ + if (!payload) { + return; + } + payloads.delete(key); - onFCP((metric) => { - webVitalsPayload['[Amplitude] FCP'] = processMetric(metric); - }); + // An event with no metrics is only possible when reporting soft navigations, where the page + // can be hidden repeatedly without a new navigation reporting anything. + if (!reportSoftNavs || hasMetrics(payload)) { + amplitude.track(WEB_VITALS_EVENT_NAME, payload); + } + }; - onINP((metric) => { - webVitalsPayload['[Amplitude] INP'] = processMetric(metric); - }); + const flushAll = () => { + for (const key of Array.from(payloads.keys())) { + flush(key); + } + }; - onCLS((metric) => { - webVitalsPayload['[Amplitude] CLS'] = processMetric(metric); - }); + // Once a newer navigation starts reporting metrics, the navigations before it are final. Sending + // them is deferred briefly so metrics reported late still make it into their event. + const scheduleFlushOfPreviousNavigations = (currentNavigationId: number) => { + if (flushTimeout !== undefined) { + clearTimeout(flushTimeout); + } + flushTimeout = setTimeout(() => { + flushTimeout = undefined; + for (const key of Array.from(payloads.keys())) { + if (key < currentNavigationId) { + flush(key); + } + } + }, SOFT_NAV_FLUSH_DELAY_MS); + }; - onTTFB((metric) => { - webVitalsPayload['[Amplitude] TTFB'] = processMetric(metric); - }); + const recordMetric = (property: WebVitalsMetricProperty) => (metric: Metric) => { + getPayload(metric)[property] = processMetric(metric); + + if (reportSoftNavs && metric.navigationId > latestNavigationId) { + if (latestNavigationId !== -1) { + scheduleFlushOfPreviousNavigations(metric.navigationId); + } + latestNavigationId = metric.navigationId; + } + }; + + const reportOpts: ReportOpts | undefined = reportSoftNavs ? { reportSoftNavs: true } : undefined; + + onLCP(recordMetric('[Amplitude] LCP'), reportOpts); + onFCP(recordMetric('[Amplitude] FCP'), reportOpts); + onINP(recordMetric('[Amplitude] INP'), reportOpts); + onCLS(recordMetric('[Amplitude] CLS'), reportOpts); + onTTFB(recordMetric('[Amplitude] TTFB'), reportOpts); visibilityListener = () => { if (doc.visibilityState === 'hidden' && visibilityListener) { - amplitude.track(WEB_VITALS_EVENT_NAME, webVitalsPayload); - doc.removeEventListener('visibilitychange', visibilityListener); - visibilityListener = null; + flushAll(); + + // When reporting soft navigations, keep listening: the page can become visible again and + // report metrics for further navigations. + if (!reportSoftNavs) { + doc.removeEventListener('visibilitychange', visibilityListener); + visibilityListener = null; + } } }; doc.addEventListener('visibilitychange', visibilityListener); @@ -105,9 +224,14 @@ export const webVitalsPlugin = (): BrowserEnrichmentPlugin => { }; const teardown = async () => { + if (flushTimeout !== undefined) { + clearTimeout(flushTimeout); + flushTimeout = undefined; + } if (visibilityListener) { /* istanbul ignore next */ doc?.removeEventListener('visibilitychange', visibilityListener); + visibilityListener = null; } }; diff --git a/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts b/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts index 5e620564c6..336d82d55a 100644 --- a/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts +++ b/packages/plugin-web-vitals-browser/test/web-vitals-plugin.test.ts @@ -1,7 +1,7 @@ import { BrowserClient, getGlobalScope } from '@amplitude/analytics-core'; import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'; import { webVitalsPlugin } from '../src'; -import { PLUGIN_NAME, WEB_VITALS_EVENT_NAME } from '../src/constants'; +import { PLUGIN_NAME, SOFT_NAV_FLUSH_DELAY_MS, WEB_VITALS_EVENT_NAME } from '../src/constants'; /* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ @@ -20,6 +20,14 @@ jest.mock('@amplitude/analytics-core', () => ({ getGlobalScope: jest.fn(), })); +/** + * The plugin reads the global `performance`, so pin its time origin to keep timestamps stable. + * Fake timers swap out the global `performance`, so this needs to be re-applied after enabling them. + */ +const pinTimeOrigin = () => { + Object.defineProperty(globalThis.performance, 'timeOrigin', { value: 1000, configurable: true }); +}; + describe('webVitalsPlugin', () => { let amplitude: BrowserClient; let config: any; @@ -36,12 +44,15 @@ describe('webVitalsPlugin', () => { addEventListener: jest.fn(), removeEventListener: jest.fn(), visibilityState: 'visible', + title: 'Example Page', } as unknown as Document; mockPerformance = { timeOrigin: 1000, } as unknown as Performance; + pinTimeOrigin(); + // Mock global scope with document mockGlobalScope = { document: mockDocument, @@ -62,10 +73,31 @@ describe('webVitalsPlugin', () => { config = { loggerProvider: { log: jest.fn(), + debug: jest.fn(), }, }; }); + /** + * Calls each of the five web vitals callbacks registered by the plugin with `metric`. + */ + const reportAllMetrics = (metric: Record) => { + for (const onMetric of [onLCP, onFCP, onINP, onCLS, onTTFB]) { + const callback = (onMetric as jest.Mock).mock.calls[0][0]; + /* istanbul ignore else */ + if (callback) { + callback(metric); + } + } + }; + + const getVisibilityListener = () => (mockDocument.addEventListener as jest.Mock).mock.calls[0][1]; + + const hideDocument = () => { + Object.defineProperty(mockDocument, 'visibilityState', { value: 'hidden' }); + getVisibilityListener()(); + }; + it('should be defined', () => { expect(webVitalsPlugin).toBeDefined(); }); @@ -123,12 +155,18 @@ describe('webVitalsPlugin', () => { expect(mockDocument.addEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); }); - it('should track web vitals when visibility changes to hidden', async () => { + it('should not opt in to soft navigation reporting by default', async () => { const plugin = webVitalsPlugin(); await plugin?.setup?.(config, amplitude); - // Get the visibility change listener - const visibilityListener = (mockDocument.addEventListener as jest.Mock).mock.calls[0][1]; + for (const onMetric of [onLCP, onFCP, onINP, onCLS, onTTFB]) { + expect((onMetric as jest.Mock).mock.calls[0][1]).toBeUndefined(); + } + }); + + it('should track web vitals when visibility changes to hidden', async () => { + const plugin = webVitalsPlugin(); + await plugin?.setup?.(config, amplitude); // Mock web vitals callbacks const mockMetric = { @@ -137,27 +175,15 @@ describe('webVitalsPlugin', () => { delta: 0, navigationType: 'navigate', id: 'test-id', + navigationId: 1, entries: [{ startTime: 0 }], }; // Simulate web vitals being collected - const lcpCallback = (onLCP as jest.Mock).mock.calls[0][0]; - const fcpCallback = (onFCP as jest.Mock).mock.calls[0][0]; - const inpCallback = (onINP as jest.Mock).mock.calls[0][0]; - const clsCallback = (onCLS as jest.Mock).mock.calls[0][0]; - const ttfbCallback = (onTTFB as jest.Mock).mock.calls[0][0]; - - if (lcpCallback && fcpCallback && inpCallback && clsCallback && ttfbCallback) { - lcpCallback(mockMetric); - fcpCallback(mockMetric); - inpCallback(mockMetric); - clsCallback(mockMetric); - ttfbCallback(mockMetric); - } + reportAllMetrics(mockMetric); // Change visibility to hidden - Object.defineProperty(mockDocument, 'visibilityState', { value: 'hidden' }); - visibilityListener(); + hideDocument(); // Verify track was called with correct payload const [eventName, eventObject] = (amplitude.track as jest.Mock).mock.calls[0]; @@ -176,6 +202,35 @@ describe('webVitalsPlugin', () => { expect(eventObject['[Amplitude] INP']).toMatchObject(expectedMetric); expect(eventObject['[Amplitude] CLS']).toMatchObject(expectedMetric); expect(eventObject['[Amplitude] TTFB']).toMatchObject(expectedMetric); + + expect(eventObject).toMatchObject({ + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/path/to?query=value#hash', + '[Amplitude] Page Path': '/path/to', + '[Amplitude] Page Title': 'Example Page', + '[Amplitude] Page URL': 'https://www.example.com/path/to', + }); + }); + + it('should stop listening for visibility changes after tracking', async () => { + const plugin = webVitalsPlugin(); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics({ + value: 100, + rating: 'good', + delta: 0, + navigationType: 'navigate', + id: 'test-id', + navigationId: 1, + entries: [{ startTime: 0 }], + }); + + hideDocument(); + hideDocument(); + + expect((amplitude.track as jest.Mock).mock.calls).toHaveLength(1); + expect(mockDocument.removeEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); }); it('should cleanup event listeners on teardown', async () => { @@ -192,4 +247,248 @@ describe('webVitalsPlugin', () => { const result = await plugin?.execute?.(event); expect(result).toBe(event); }); + + describe('with reportSoftNav enabled', () => { + const makeMetric = (overrides: Record = {}) => ({ + value: 100, + rating: 'good', + delta: 0, + navigationType: 'navigate', + id: 'test-id', + navigationId: 1, + entries: [{ startTime: 0 }], + ...overrides, + }); + + beforeEach(() => { + jest.useFakeTimers(); + pinTimeOrigin(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should opt in to soft navigation reporting for every metric', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + for (const onMetric of [onLCP, onFCP, onINP, onCLS, onTTFB]) { + expect((onMetric as jest.Mock).mock.calls[0][1]).toEqual({ reportSoftNavs: true }); + } + }); + + it('should include the navigation start of the reported navigation', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics( + makeMetric({ + navigationId: 4, + navigationType: 'soft-navigation', + navigationStartTime: 500, + navigationURL: 'https://www.example.com/soft-nav', + }), + ); + hideDocument(); + + const [, eventObject] = (amplitude.track as jest.Mock).mock.calls[0]; + expect(eventObject['[Amplitude] LCP']).toMatchObject({ + navigationType: 'soft-navigation', + // performance.timeOrigin (1000) + navigationStartTime (500) + navigationStart: 1500, + }); + }); + + it('should use the page properties of the navigation the metrics belong to', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics( + makeMetric({ + navigationId: 2, + navigationType: 'soft-navigation', + navigationURL: 'https://www.example.com/products/1?ref=home', + }), + ); + hideDocument(); + + const [, eventObject] = (amplitude.track as jest.Mock).mock.calls[0]; + expect(eventObject).toMatchObject({ + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/products/1?ref=home', + '[Amplitude] Page Path': '/products/1', + '[Amplitude] Page URL': 'https://www.example.com/products/1', + }); + }); + + it('should send one event per navigation once a later navigation reports metrics', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics(makeMetric({ navigationId: 1, navigationURL: 'https://www.example.com/' })); + + // The first navigation is not reported while it is still the current one. + jest.advanceTimersByTime(SOFT_NAV_FLUSH_DELAY_MS * 2); + expect(amplitude.track as jest.Mock).not.toHaveBeenCalled(); + + reportAllMetrics( + makeMetric({ + navigationId: 2, + navigationType: 'soft-navigation', + navigationURL: 'https://www.example.com/products/1', + }), + ); + + // The superseded navigation is sent after the grace period, the current one is not. + expect(amplitude.track as jest.Mock).not.toHaveBeenCalled(); + jest.advanceTimersByTime(SOFT_NAV_FLUSH_DELAY_MS); + expect((amplitude.track as jest.Mock).mock.calls).toHaveLength(1); + expect((amplitude.track as jest.Mock).mock.calls[0][1]).toMatchObject({ + '[Amplitude] Page Location': 'https://www.example.com/', + }); + + // The current navigation is sent when the page is hidden. + hideDocument(); + expect((amplitude.track as jest.Mock).mock.calls).toHaveLength(2); + expect((amplitude.track as jest.Mock).mock.calls[1][1]).toMatchObject({ + '[Amplitude] Page Location': 'https://www.example.com/products/1', + }); + }); + + it('should send every navigation when several soft navigations happen in quick succession', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics(makeMetric({ navigationId: 1, navigationURL: 'https://www.example.com/' })); + reportAllMetrics( + makeMetric({ + navigationId: 2, + navigationType: 'soft-navigation', + navigationURL: 'https://www.example.com/products/1', + }), + ); + + // A third navigation starts before the pending flush fires, restarting the grace period. + jest.advanceTimersByTime(SOFT_NAV_FLUSH_DELAY_MS / 2); + reportAllMetrics( + makeMetric({ + navigationId: 3, + navigationType: 'soft-navigation', + navigationURL: 'https://www.example.com/products/2', + }), + ); + expect(amplitude.track as jest.Mock).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(SOFT_NAV_FLUSH_DELAY_MS); + expect((amplitude.track as jest.Mock).mock.calls.map((call) => call[1]['[Amplitude] Page Location'])).toEqual([ + 'https://www.example.com/', + 'https://www.example.com/products/1', + ]); + + hideDocument(); + expect((amplitude.track as jest.Mock).mock.calls[2][1]).toMatchObject({ + '[Amplitude] Page Location': 'https://www.example.com/products/2', + }); + }); + + it('should include metrics reported late for a superseded navigation', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + const lcpCallback = (onLCP as jest.Mock).mock.calls[0][0]; + const clsCallback = (onCLS as jest.Mock).mock.calls[0][0]; + const inpCallback = (onINP as jest.Mock).mock.calls[0][0]; + + lcpCallback(makeMetric({ navigationId: 1, navigationURL: 'https://www.example.com/' })); + inpCallback( + makeMetric({ + navigationId: 2, + navigationType: 'soft-navigation', + navigationURL: 'https://www.example.com/products/1', + }), + ); + // CLS for the first navigation is finalized just after the soft navigation starts. + clsCallback(makeMetric({ navigationId: 1, navigationURL: 'https://www.example.com/', value: 0.25 })); + + jest.advanceTimersByTime(SOFT_NAV_FLUSH_DELAY_MS); + + expect((amplitude.track as jest.Mock).mock.calls).toHaveLength(1); + const [, eventObject] = (amplitude.track as jest.Mock).mock.calls[0]; + expect(eventObject['[Amplitude] LCP']).toBeDefined(); + expect(eventObject['[Amplitude] CLS']).toMatchObject({ value: 0.25 }); + expect(eventObject['[Amplitude] INP']).toBeUndefined(); + }); + + it('should keep listening for visibility changes and not send empty events', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics(makeMetric({ navigationId: 1, navigationURL: 'https://www.example.com/' })); + hideDocument(); + expect((amplitude.track as jest.Mock).mock.calls).toHaveLength(1); + expect(mockDocument.removeEventListener).not.toHaveBeenCalled(); + + // Nothing new to report: no event is sent. + hideDocument(); + expect((amplitude.track as jest.Mock).mock.calls).toHaveLength(1); + + // A later navigation is still reported after the page becomes visible again. + reportAllMetrics( + makeMetric({ + navigationId: 2, + navigationType: 'soft-navigation', + navigationURL: 'https://www.example.com/products/1', + }), + ); + hideDocument(); + expect((amplitude.track as jest.Mock).mock.calls).toHaveLength(2); + }); + + it('should clear a pending flush on teardown', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics(makeMetric({ navigationId: 1, navigationURL: 'https://www.example.com/' })); + reportAllMetrics( + makeMetric({ + navigationId: 2, + navigationType: 'soft-navigation', + navigationURL: 'https://www.example.com/products/1', + }), + ); + + await plugin?.teardown?.(); + jest.advanceTimersByTime(SOFT_NAV_FLUSH_DELAY_MS * 2); + + expect(amplitude.track as jest.Mock).not.toHaveBeenCalled(); + expect(mockDocument.removeEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + }); + + it('should fall back to the current URL when the metric has no navigation URL', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics(makeMetric({ navigationId: 1, navigationURL: undefined })); + hideDocument(); + + const [, eventObject] = (amplitude.track as jest.Mock).mock.calls[0]; + expect(eventObject).toMatchObject({ + '[Amplitude] Page Location': 'https://www.example.com/path/to?query=value#hash', + }); + }); + + it('should not throw when the navigation URL cannot be parsed', async () => { + const plugin = webVitalsPlugin({ reportSoftNav: true }); + await plugin?.setup?.(config, amplitude); + + reportAllMetrics(makeMetric({ navigationId: 1, navigationURL: 'not a url' })); + hideDocument(); + + const [, eventObject] = (amplitude.track as jest.Mock).mock.calls[0]; + expect(eventObject['[Amplitude] Page Domain']).toBe(''); + expect(eventObject['[Amplitude] Page Location']).toBe('not a url'); + expect(config.loggerProvider.debug).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/plugin-web-vitals-browser/tsconfig.json b/packages/plugin-web-vitals-browser/tsconfig.json index 68713e6436..dcd9ea7f35 100644 --- a/packages/plugin-web-vitals-browser/tsconfig.json +++ b/packages/plugin-web-vitals-browser/tsconfig.json @@ -6,6 +6,9 @@ "esModuleInterop": true, "lib": ["dom"], "noEmit": true, + // web-vitals@6 declares Soft Navigations API types that reference DOM types not present in the + // TypeScript 4.9 DOM lib (e.g. `NavigationType`). + "skipLibCheck": true, "rootDir": "." } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8adf22e0f4..7ed7c4ee7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -925,8 +925,8 @@ importers: specifier: ^2.4.1 version: 2.8.1 web-vitals: - specifier: 5.1.0 - version: 5.1.0 + specifier: 6.2.1 + version: 6.2.1 devDependencies: '@rollup/plugin-commonjs': specifier: ^23.0.4 @@ -12396,6 +12396,10 @@ packages: resolution: { integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg== } + web-vitals@6.2.1: + resolution: + { integrity: sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw== } + webidl-conversions@3.0.1: resolution: { integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== } @@ -25213,6 +25217,8 @@ snapshots: web-vitals@5.1.0: {} + web-vitals@6.2.1: {} + webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} diff --git a/test-server/browser-sdk/web-vitals-soft-nav.html b/test-server/browser-sdk/web-vitals-soft-nav.html new file mode 100644 index 0000000000..2e3d10bf9f --- /dev/null +++ b/test-server/browser-sdk/web-vitals-soft-nav.html @@ -0,0 +1,71 @@ + + + + + + + + Web Vitals - Soft Navigations + + +

Web Vitals for soft navigations

+

+ Requires a browser that supports the + Soft Navigations API (Chromium 151+). A soft navigation is + only detected when a user interaction is followed by a history change and a paint, so use the button below rather + than the address bar. +

+

+ Each click pushes a new URL and paints new content, which should produce one + [Amplitude] Web Vitals event per navigation, each with the page properties of its own URL. +

+ + + + +

Page 1

+
+ + + + + +