diff --git a/README.md b/README.md index 9ea15ce9..96ee8a40 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - [Send the results to an analytics endpoint](#send-the-results-to-an-analytics-endpoint) - [Send the results to Google Analytics](#send-the-results-to-google-analytics) - [Send the results to Google Tag Manager](#send-the-results-to-google-tag-manager) + - [Send attribution data](#send-attribution-data) - [Batch multiple reports together](#batch-multiple-reports-together) - [Bundle versions](#bundle-versions) - [Which bundle is right for you?](#which-bundle-is-right-for-you) @@ -18,6 +19,7 @@ - [API](#api) - [Types](#types) - [Functions](#functions) + - [Attribution](#attribution) - [Browser Support](#browser-support) - [Limitations](#limitations) - [Development](#development) @@ -38,7 +40,7 @@ The library supports all of the [Core Web Vitals](https://web.dev/vitals/#core-w ### Other metrics -- [Interaction to next Paint (INP)](https://web.dev/responsiveness/) _(experimental)_ +- [Interaction to next Paint (INP)](https://web.dev/inp/) _(experimental)_ - [First Contentful Paint (FCP)](https://web.dev/fcp/) - [Time to First Byte (TTFB)](https://web.dev/ttfb/) @@ -114,13 +116,34 @@ Note that the code _must_ go in the `` of your pages in order to work. See _**Tip:** while it's certainly possible to inline the code in `dist/polyfill.js` by copy and pasting it directly into your templates, it's better to automate this process in a build step—otherwise you risk the "base" and the "polyfill" scripts getting out of sync when new versions are released._ + + +**3. The "attribution" build** + +Measuring the Web Vitals scores for your real users is a great first step toward optimizing the user experience. But if your scores aren't _good_, the next step is to understand why they're not good and work to improve them. + +The "attribution" build helps you do that by including additional diagnostic information with each metric to help you identify the root cause of poor performance as well as prioritize the most important things to fix. + +The "attribution" build is slightly larger than the "standard" build (by about 500 bytes, brotli'd), so while the code size is still small, it's only recommended if you're actually using these features. + +To load the "attribution" build, change any `import` statements that reference `web-vitals` to `web-vitals/attribution`: + +```diff +- import {onLCP, onFID, onCLS} from 'web-vitals'; ++ import {onLCP, onFID, onCLS} from 'web-vitals/attribution'; +``` + +Usage for each of the imported function is identical to the standard build, but when importing from the attribution build, the [`Metric`](#metric) object will contain an additional [`attribution`](#metricwithattribution) property. + +See [Send attribution data](#send-attribution-data) for usage examples, and the [`attribution` reference](#attribution) for details on what values are added for each metric. + ### From a CDN The recommended way to use the `web-vitals` package is to install it from npm and integrate it into your build process. However, if you're not using npm, it's still possible to use `web-vitals` by requesting it from a CDN that serves npm package files. -The following examples show how to load `web-vitals` from [unpkg.com](https://unpkg.com), whether your targeting just Chromium-based browsers (using the "standard" version) or additional browsers (using the "base+polyfill" version): +The following examples show how to load `web-vitals` from [unpkg.com](https://unpkg.com), whether you're targeting just Chromium-based browsers (using the "standard" version) or additional browsers (using the "base+polyfill" version): _**Important!** users who want to load version 3 beta from the unpkg CDN should specify a version number or link to the [web-vitals@next](https://unpkg.com/web-vitals@next?module) tag._ @@ -214,7 +237,7 @@ Also, in some cases a metric callback may never be called: In other cases, a metric callback may be called more than once: -- CLS should be reported any time the [page's `visibilityState` changes to hidden](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#advice-hidden). +- CLS should be reported any time the [page's `visibilityState` changes to hidden](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden). - All metrics are reported again (with the above exceptions) after a page is restored from the [back/forward cache](https://web.dev/bfcache/). _**Warning:** do not call any of the Web Vitals functions (e.g. `onCLS()`, `onFID()`, `onLCP()`) more than once per page load. Each of these functions creates a `PerformanceObserver` instance and registers event listeners for the lifetime of the page. While the overhead of calling these functions once is negligible, calling them repeatedly on the same page may eventually result in a memory leak._ @@ -315,7 +338,7 @@ function sendToGoogleAnalytics({name, delta, id}) { // Use `sendBeacon()` if the browser supports it. transport: 'beacon', - // OPTIONAL: any additional params or debug info here. + // OPTIONAL: any additional attribution params here. // See: https://web.dev/debug-web-vitals-in-the-field/ // dimension1: '...', // dimension2: '...', @@ -350,10 +373,10 @@ function sendToGoogleAnalytics({name, delta, id}) { // Use a non-interaction event to avoid affecting bounce rate. non_interaction: true, - // OPTIONAL: any additional params or debug info here. + // OPTIONAL: any additional attribution params here. // See: https://web.dev/debug-web-vitals-in-the-field/ - // metric_rating: 'good' | 'ni' | 'poor', - // debug_info: '...', + // dimension1: '...', + // dimension2: '...', // ... }); } @@ -400,6 +423,48 @@ The recommended way to measure Web Vitals metrics with Google Tag Manager is usi For full installation and usage instructions, see Simo's post: [Track Core Web Vitals in GA4 with Google Tag Manager](https://www.simoahava.com/analytics/track-core-web-vitals-in-ga4-with-google-tag-manager/). +### Send attribution data + +When using the [attribution build](#attribution-build), you can send additional data to help you debug _why_ the metric values are they way they are. + +This example sends an additional `debug_target` param to Google Analytics, corresponding to the element most associated with each metric. + +```js +import {onCLS, onFID, onLCP} from 'web-vitals/attribution'; + +function sendToGoogleAnalytics({name, delta, value, id, attribution}) { + const eventParams = { + // Built-in params: + value: delta, // Use `delta` so the value can be summed. + // Custom params: + metric_id: id, // Needed to aggregate events. + metric_value: value, // Optional. + metric_delta: delta, // Optional. + } + + switch (name) { + case 'CLS': + eventParams.debug_target = attribution.largestShiftTarget; + case 'FID': + eventParams.debug_target = attribution.eventTarget; + case 'LCP': + eventParams.debug_target = attribution.element; + } + + // Assumes the global `gtag()` function exists, see: + // https://developers.google.com/analytics/devguides/collection/ga4 + gtag('event', name, eventParams); +} + +onCLS(sendToGoogleAnalytics); +onFID(sendToGoogleAnalytics); +onLCP(sendToGoogleAnalytics); +``` + +_**Note:** this example relies on custom [event parameters](https://support.google.com/analytics/answer/11396839) in Google Analytics 4. For Universal Analytics the attribution data should be set using a [custom dimension](https://support.google.com/analytics/answer/2709828) rather than `debug_target` as shown above. + +See [Debug Web Vitals in the field](https://web.dev/debug-web-vitals-in-the-field/) for more information and examples. + ### Batch multiple reports together Rather than reporting each individual Web Vitals metric separately, you can minimize your network usage by batching multiple metric reports together in a single network request. @@ -467,7 +532,7 @@ The following table lists all the bundles distributed with the `web-vitals` pack web-vitals.js pkg.module -

An ES module bundle of all metric functions, without any extra polyfills to expand browser support.

+

An ES module bundle of all metric functions, without any attribution features.

This is the "standard" version and is the simplest way to consume this library out of the box. @@ -485,6 +550,28 @@ The following table lists all the bundles distributed with the `web-vitals` pack An IIFE version of the web-vitals.js bundle (exposed on the window.webVitals.* namespace). + + web-vitals.attribution.js + -- + + An ES module bundle of all metric functions that includes
attribution features. + + + + web-vitals.attribution.umd.js + -- + + A UMD version of the web-vitals.attribution.js bundle (exposed on the window.webVitals.* namespace). + + + + + web-vitals.attribution.iife.js + -- + + An IIFE version of the web-vitals.attribution.js bundle (exposed on the window.webVitals.* namespace). + + web-vitals.base.js -- @@ -520,7 +607,7 @@ The following table lists all the bundles distributed with the `web-vitals` pack ### Which bundle is right for you? -Most developers will generally want to use the "standard" bundle (either the ES module or UMD version, depending on your build system), as it's the easiest to use out of the box and integrate into existing build tools. +Most developers will generally want to use either the "standard" bundle or the "attribution" bundle (via either the ES module or UMD version, depending on your build system), as they're the easiest to use out of the box and integrate into existing build tools. However, there are a few good reasons to consider using the "base+polyfill" version, for example: @@ -568,10 +655,43 @@ interface Metric { // by the Navigation Timing API (or `undefined` if the browser doesn't // support that API). For pages that are restored from the bfcache, this // value will be 'back_forward_cache'. - navigationType: NavigationType | 'back_forward_cache' | undefined; + navigationType: NavigationType | 'back_forward_cache' | 'prerender' | undefined; +} +``` + +Metric-specific subclasses: + +- [`CLSMetric`](/src/types/cls.ts#:~:text=interface%20CLSMetric) +- [`FCPMetric`](/src/types/cls.ts#:~:text=interface%20FCPMetric) +- [`FIDMetric`](/src/types/cls.ts#:~:text=interface%20FIDMetric) +- [`INPMetric`](/src/types/cls.ts#:~:text=interface%20INPMetric) +- [`LCPMetric`](/src/types/cls.ts#:~:text=interface%20LCPMetric) +- [`TTFBMetric`](/src/types/cls.ts#:~:text=interface%20TTFBMetric) + +#### `MetricWithAttribution` + +See the [attribution build](#attribution-build) section for details on how to use this feature. + +```ts +interface MetricWithAttribution extends Metric { + /** + * An object containing potentially-helpful debugging information that + * can be sent along with the metric value for the current page visit in + * order to help identify issues happening to real-users in the field. + */ + attribution: {[key: string]: unknown}; } ``` +Metric-specific subclasses: + +- [`CLSMetricWithAttribution`](/src/types/cls.ts#:~:text=interface%20CLSMetricWithAttribution) +- [`FCPMetricWithAttribution`](/src/types/cls.ts#:~:text=interface%20FCPMetricWithAttribution) +- [`FIDMetricWithAttribution`](/src/types/cls.ts#:~:text=interface%20FIDMetricWithAttribution) +- [`INPMetricWithAttribution`](/src/types/cls.ts#:~:text=interface%20INPMetricWithAttribution) +- [`LCPMetricWithAttribution`](/src/types/cls.ts#:~:text=interface%20LCPMetricWithAttribution) +- [`TTFBMetricWithAttribution`](/src/types/cls.ts#:~:text=interface%20TTFBMetricWithAttribution) + #### `ReportCallback` ```ts @@ -580,6 +700,15 @@ interface ReportCallback { } ``` +Metric-specific subclasses: + +- [`CLSReportCallback`](/src/types/cls.ts#:~:text=interface%20CLSReportCallback) +- [`FCPReportCallback`](/src/types/cls.ts#:~:text=interface%20FCPReportCallback) +- [`FIDReportCallback`](/src/types/cls.ts#:~:text=interface%20FIDReportCallback) +- [`INPReportCallback`](/src/types/cls.ts#:~:text=interface%20INPReportCallback) +- [`LCPReportCallback`](/src/types/cls.ts#:~:text=interface%20LCPReportCallback) +- [`TTFBReportCallback`](/src/types/cls.ts#:~:text=interface%20TTFBReportCallback) + #### `ReportOpts` ```ts @@ -632,19 +761,19 @@ interface WebVitalsGlobal { #### `onCLS()` ```ts -type onCLS = (callback: ReportCallback, opts?: ReportOpts) => void +type onCLS = (callback: CLSReportCallback, opts?: ReportOpts) => void ``` Calculates the [CLS](https://web.dev/cls/) value for the current page and calls the `callback` function once the value is ready to be reported, along with all `layout-shift` performance entries that were used in the metric value calculation. The reported value is a [double](https://heycam.github.io/webidl/#idl-double) (corresponding to a [layout shift score](https://web.dev/cls/#layout-shift-score)). If the `reportAllChanges` [configuration option](#reportopts) is set to `true`, the `callback` function will be called as soon as the value is initially determined as well as any time the value changes throughout the page lifespan. -_**Important:** CLS should be continually monitored for changes throughout the entire lifespan of a page—including if the user returns to the page after it's been hidden/backgrounded. However, since browsers often [will not fire additional callbacks once the user has backgrounded a page](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#advice-hidden), `callback` is always called when the page's visibility state changes to hidden. As a result, the `callback` function might be called multiple times during the same page load (see [Reporting only the delta of changes](#report-only-the-delta-of-changes) for how to manage this)._ +_**Important:** CLS should be continually monitored for changes throughout the entire lifespan of a page—including if the user returns to the page after it's been hidden/backgrounded. However, since browsers often [will not fire additional callbacks once the user has backgrounded a page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), `callback` is always called when the page's visibility state changes to hidden. As a result, the `callback` function might be called multiple times during the same page load (see [Reporting only the delta of changes](#report-only-the-delta-of-changes) for how to manage this)._ #### `onFCP()` ```ts -type onFCP = (callback: ReportCallback, opts?: ReportOpts) => void +type onFCP = (callback: FCPReportCallback, opts?: ReportOpts) => void ``` Calculates the [FCP](https://web.dev/fcp/) value for the current page and calls the `callback` function once the value is ready, along with the relevant `paint` performance entry used to determine the value. The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp). @@ -652,17 +781,17 @@ Calculates the [FCP](https://web.dev/fcp/) value for the current page and calls #### `onFID()` ```ts -type onFID = (callback: ReportCallback, opts?: ReportOpts) => void +type onFID = (callback: FIDReportCallback, opts?: ReportOpts) => void ``` -Calculates the [FID](https://web.dev/fid/) value for the current page and calls the `callback` function once the value is ready, along with the relevant `first-input` performance entry used to determine the value (and optionally the input event if using the [FID polyfill](#fid-polyfill)). The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp). +Calculates the [FID](https://web.dev/fid/) value for the current page and calls the `callback` function once the value is ready, along with the relevant `first-input` performance entry used to determine the value. The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp). _**Important:** since FID is only reported after the user interacts with the page, it's possible that it will not be reported for some page loads._ #### `onINP()` ```ts -type onINP = (callback: ReportCallback, opts?: ReportOpts) => void +type onINP = (callback: INPReportCallback, opts?: ReportOpts) => void ``` Calculates the [INP](https://web.dev/responsiveness/) value for the current page and calls the `callback` function once the value is ready, along with the `event` performance entries reported for that interaction. The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp). @@ -671,12 +800,12 @@ A custom `durationThreshold` [configuration option](#reportopts) can optionally If the `reportAllChanges` [configuration option](#reportopts) is set to `true`, the `callback` function will be called as soon as the value is initially determined as well as any time the value changes throughout the page lifespan. -_**Important:** INP should be continually monitored for changes throughout the entire lifespan of a page—including if the user returns to the page after it's been hidden/backgrounded. However, since browsers often [will not fire additional callbacks once the user has backgrounded a page](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#advice-hidden), `callback` is always called when the page's visibility state changes to hidden. As a result, the `callback` function might be called multiple times during the same page load (see [Reporting only the delta of changes](#report-only-the-delta-of-changes) for how to manage this)._ +_**Important:** INP should be continually monitored for changes throughout the entire lifespan of a page—including if the user returns to the page after it's been hidden/backgrounded. However, since browsers often [will not fire additional callbacks once the user has backgrounded a page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), `callback` is always called when the page's visibility state changes to hidden. As a result, the `callback` function might be called multiple times during the same page load (see [Reporting only the delta of changes](#report-only-the-delta-of-changes) for how to manage this)._ #### `onLCP()` ```ts -type onLCP = (callback: ReportCallback, opts?: ReportOpts) => void +type onLCP = (callback: LCPReportCallback, opts?: ReportOpts) => void ``` Calculates the [LCP](https://web.dev/lcp/) value for the current page and calls the `callback` function once the value is ready (along with the relevant `largest-contentful-paint` performance entry used to determine the value). The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp). @@ -686,14 +815,14 @@ If the `reportAllChanges` [configuration option](#reportopts) is set to `true`, #### `onTTFB()` ```ts -type onTTFB = (callback: ReportCallback, opts?: ReportOpts) => void +type onTTFB = (callback: TTFBReportCallback, opts?: ReportOpts) => void ``` Calculates the [TTFB](https://web.dev/time-to-first-byte/) value for the current page and calls the `callback` function once the page has loaded, along with the relevant `navigation` performance entry used to determine the value. The reported value is a [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp). Note, this function waits until after the page is loaded to call `callback` in order to ensure all properties of the `navigation` entry are populated. This is useful if you want to report on other metrics exposed by the [Navigation Timing API](https://w3c.github.io/navigation-timing/). -For example, the TTFB metric starts from the page's [time origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it [includes](https://developers.google.com/web/fundamentals/performance/navigation-and-resource-timing#the_life_and_timings_of_a_network_request) time spent on DNS lookup, connection negotiation, network latency, and unloading the previous document. If, in addition to TTFB, you want a metric that excludes these timings and _just_ captures the time spent making the request and receiving the first byte of the response, you could compute that from data found on the performance entry: +For example, the TTFB metric starts from the page's [time origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it includes time spent on DNS lookup, connection negotiation, network latency, and server processing time. ```js import {onTTFB} from 'web-vitals'; @@ -709,6 +838,231 @@ onTTFB((metric) => { _**Note:** browsers that do not support `navigation` entries will fall back to using `performance.timing` (with the timestamps converted from epoch time to [`DOMHighResTimeStamp`](https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp)). This ensures code referencing these values (like in the example above) will work the same in all browsers._ +### Attribution: + +The following objects contain potentially-helpful debugging information that can be sent along with the metric values for the current page visit in order to help identify issues happening to real-users in the field. + +See the [attribution build](#attribution-build) section for details on how to use this feature. + +#### CLS `attribution`: + +```ts +interface CLSAttribution { + /** + * A selector identifying the first element (in document order) that + * shifted when the single largest layout shift contributing to the page's + * CLS score occurred. + */ + largestShiftTarget?: string; + /** + * The time when the single largest layout shift contributing to the page's + * CLS score occurred. + */ + largestShiftTime?: DOMHighResTimeStamp; + /** + * The layout shift score of the single largest layout shift contributing to + * the page's CLS score. + */ + largestShiftValue?: number; + /** + * The `LayoutShiftEntry` representing the single largest layout shift + * contributing to the page's CLS score. (Useful when you need more than just + * `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`). + */ + largestShiftEntry?: LayoutShift; + /** + * The first element source (in document order) among the `sources` list + * of the `largestShiftEntry` object. (Also useful when you need more than + * just `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`). + */ + largestShiftSource?: LayoutShiftAttribution; + /** + * The loading state of the document at the time when the largest layout + * shift contribution to the page's CLS score occurred (see `LoadState` + * for details). + */ + loadState?: LoadState; +} +``` + +#### FCP `attribution`: + +```ts +interface FCPAttribution { + /** + * The time from when the user initiates loading the page until when the + * browser receives the first byte of the response (a.k.a. TTFB). + */ + timeToFirstByte: number; + /** + * The delta between TTFB and the first contentful paint (FCP). + */ + firstByteToFCP: number; + /** + * The loading state of the document at the time when FCP `occurred (see + * `LoadState` for details). Ideally, documents can paint before they finish + * loading (e.g. the `loading` or `domInteractive` phases). + */ + loadState: LoadState, + /** + * The `PerformancePaintTiming` entry corresponding to FCP. + */ + fcpEntry?: PerformancePaintTiming, + /** + * The `navigation` entry of the current page, which is useful for diagnosing + * general page load issues. + */ + navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; +} +``` + +#### FID `attribution`: + +```ts +interface FIDAttribution { + /** + * A selector identifying the element that the user interacted with. This + * element will be the `target` of the `event` dispatched. + */ + eventTarget: string; + /** + * The time when the user interacted. This time will match the `timeStamp` + * value of the `event` dispatched. + */ + eventTime: number; + /** + * The `type` of the `event` dispatched from the user interaction. + */ + eventType: string; + /** + * The `PerformanceEventTiming` entry corresponding to FID (or the + * polyfill entry in browsers that don't support Event Timing). + */ + eventEntry: PerformanceEventTiming | FirstInputPolyfillEntry, + /** + * The loading state of the document at the time when the first interaction + * occurred (see `LoadState` for details). If the first interaction occurred + * while the document was loading and executing script (e.g. usually in the + * `domInteractive` phase) it can result in long input delays. + */ + loadState: LoadState; +} +``` + +#### INP `attribution`: + +```ts +interface INPAttribution { + /** + * A selector identifying the element that the user interacted with for + * the event corresponding to INP. This element will be the `target` of the + * `event` dispatched. + */ + eventTarget?: string; + /** + * The time when the user interacted for the event corresponding to INP. + * This time will match the `timeStamp` value of the `event` dispatched. + */ + eventTime?: number; + /** + * The `type` of the `event` dispatched corresponding to INP. + */ + eventType?: string; + /** + * The `PerformanceEventTiming` entry corresponding to INP. + */ + eventEntry?: PerformanceEventTiming; + /** + * The loading state of the document at the time when the even corresponding + * to INP occurred (see `LoadState` for details). If the interaction occurred + * while the document was loading and executing script (e.g. usually in the + * `domInteractive` phase) it can result in long delays. + */ + loadState?: LoadState; +} +``` + +#### LCP `attribution`: + +```ts +interface LCPAttribution { + /** + * The element corresponding to the largest contentful paint for the page. + */ + element?: string, + /** + * The time from when the user initiates loading the page until when the + * browser receives the first byte of the response (a.k.a. TTFB). See + * [Optimize LCP](https://web.dev/optimize-lcp/) for details. + */ + timeToFirstByte: number; + /** + * The delta between TTFB and when the browser starts loading the LCP + * resource (if there is one, otherwise 0). See [Optimize + * LCP](https://web.dev/optimize-lcp/) for details. + */ + resourceLoadDelay: number; + /** + * The total time it takes to load the LCP resource itself (if there is one, + * otherwise 0). See [Optimize LCP](https://web.dev/optimize-lcp/) for + * details. + */ + resourceLoadTime: number; + /** + * The delta between when the LCP resource finishes loading until the LCP + * element is fully rendered. See [Optimize + * LCP](https://web.dev/optimize-lcp/) for details. + */ + elementRenderDelay: number; + /** + * The `navigation` entry of the current page, which is useful for diagnosing + * general page load issues. + */ + navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + /** + * 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. + */ + lcpEntry?: LargestContentfulPaint; +} +``` + +#### TTFB `attribution`: + +```ts +interface TTFBAttribution { + /** + * The total time from when the user initiates loading the page to when the + * DNS lookup begins. This includes redirects, service worker startup, and + * HTTP cache lookup times. + */ + waitingTime: number; + /** + * The total time to resolve the DNS for the current request. + */ + dnsTime: number; + /** + * The total time to create the connection to the requested domain. + */ + connectionTime: number; + /** + * The time time from when the request was sent until the first byte of the + * response was received. This includes network time as well as server + * processing time. + */ + requestTime: number; + /** + * The `PerformanceNavigationTiming` entry used to determine TTFB (or the + * polyfill entry in browsers that don't support Navigation Timing). + */ + navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; +} +``` + ## Browser Support The `web-vitals` code has been tested and will run without error in all major browsers as well as Internet Explorer back to version 9. However, some of the APIs required to capture these metrics are currently only available in Chromium-based browsers (e.g. Chrome, Edge, Opera, Samsung Internet). diff --git a/attribution.d.ts b/attribution.d.ts new file mode 100644 index 00000000..1cdb5204 --- /dev/null +++ b/attribution.d.ts @@ -0,0 +1,16 @@ +/* + Copyright 2022 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. +*/ + +export * from './dist/modules/attribution.js'; diff --git a/attribution.js b/attribution.js new file mode 100644 index 00000000..9162af8a --- /dev/null +++ b/attribution.js @@ -0,0 +1,18 @@ +/* + Copyright 2022 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. +*/ + +// Creates the `web-vitals/attribution` import in node-based bundlers. +// This will not be needed when export maps are widely supported. +export * from './dist/web-vitals.attribution.js'; diff --git a/base.d.ts b/base.d.ts index aecd53be..691314c7 100644 --- a/base.d.ts +++ b/base.d.ts @@ -1 +1,16 @@ +/* + Copyright 2022 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. +*/ + export * from './dist/modules/index.js'; diff --git a/base.js b/base.js index 0b7aacbb..ae7bfd33 100644 --- a/base.js +++ b/base.js @@ -1,3 +1,18 @@ +/* + Copyright 2022 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. +*/ + // Creates the `web-vitals/base` import in node-based bundlers. // This will not be needed when export maps are widely supported. export * from './dist/web-vitals.base.js'; diff --git a/package.json b/package.json index a78c2211..4771dc65 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,88 @@ "name": "web-vitals", "version": "3.0.0-beta.2", "description": "Easily measure performance metrics in JavaScript", + "type": "module", + "typings": "dist/modules/index.d.ts", "main": "dist/web-vitals.umd.js", "module": "dist/web-vitals.js", - "typings": "dist/modules/index.d.ts", + "exports": { + ".": { + "types": "./dist/modules/index.d.ts", + "require": "./dist/web-vitals.umd.js", + "default": "./dist/web-vitals.js" + }, + "./base": { + "types": "./dist/modules/index.d.ts", + "require": "./dist/web-vitals.base.umd.js", + "default": "./dist/web-vitals.base.js" + }, + "./base.js": { + "types": "./dist/modules/index.d.ts", + "require": "./dist/web-vitals.base.umd.js", + "default": "./dist/web-vitals.base.js" + }, + "./attribution": { + "types": "./dist/modules/attribution.d.ts", + "require": "./dist/web-vitals.attribution.umd.js", + "default": "./dist/web-vitals.attribution.js" + }, + "./attribution.js": { + "types": "./dist/modules/attribution.d.ts", + "require": "./dist/web-vitals.attribution.umd.js", + "default": "./dist/web-vitals.attribution.js" + }, + "./onCLS.js": { + "types": "./dist/modules/onCLS.d.ts", + "default": "./dist/modules/onCLS.js" + }, + "./onFCP.js": { + "types": "./dist/modules/onFCP.d.ts", + "default": "./dist/modules/onFCP.js" + }, + "./onFID.js": { + "types": "./dist/modules/onFID.d.ts", + "default": "./dist/modules/onFID.js" + }, + "./onINP.js": { + "types": "./dist/modules/onINP.d.ts", + "default": "./dist/modules/onINP.js" + }, + "./onLCP.js": { + "types": "./dist/modules/onLCP.d.ts", + "default": "./dist/modules/onLCP.js" + }, + "./onTTFB.js": { + "types": "./dist/modules/onTTFB.d.ts", + "default": "./dist/modules/onTTFB.js" + }, + "./attribution/onCLS.js": { + "types": "./dist/modules/attribution/onCLS.d.ts", + "default": "./dist/modules/attribution/onCLS.js" + }, + "./attribution/onFCP.js": { + "types": "./dist/modules/attribution/onFCP.d.ts", + "default": "./dist/modules/attribution/onFCP.js" + }, + "./attribution/onFID.js": { + "types": "./dist/modules/attribution/onFID.d.ts", + "default": "./dist/modules/attribution/onFID.js" + }, + "./attribution/onINP.js": { + "types": "./dist/modules/attribution/onINP.d.ts", + "default": "./dist/modules/attribution/onINP.js" + }, + "./attribution/onLCP.js": { + "types": "./dist/modules/attribution/onLCP.d.ts", + "default": "./dist/modules/attribution/onLCP.js" + }, + "./attribution/onTTFB.js": { + "types": "./dist/modules/attribution/onTTFB.d.ts", + "default": "./dist/modules/attribution/onTTFB.js" + } + }, "files": [ + "attribution.js", + "attribution.d.ts", "base.js", "base.d.ts", "dist", @@ -24,7 +102,7 @@ "release:minor": "npm version minor -m 'Release v%s' && npm publish", "release:patch": "npm version patch -m 'Release v%s' && npm publish", "test": "npm-run-all build -p -r test:*", - "test:e2e": "wdio wdio.conf.js", + "test:e2e": "wdio wdio.conf.cjs", "test:server": "node test/server.js", "start": "run-s build:ts test:server watch", "watch": "run-p watch:*", @@ -37,9 +115,11 @@ "crux", "performance", "metrics", + "Core Web Vitals", "CLS", "FCP", "FID", + "INP", "LCP", "TTFB" ], diff --git a/rollup.config.js b/rollup.config.js index ae4aef06..7964a78a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -105,6 +105,32 @@ const configs = [ }, plugins: configurePlugins({module: false}), }, + { + input: 'dist/modules/attribution.js', + output: { + format: 'esm', + file: './dist/web-vitals.attribution.js', + }, + plugins: configurePlugins({module: true, polyfill: false}), + }, + { + input: 'dist/modules/attribution.js', + output: { + format: 'umd', + file: `./dist/web-vitals.attribution.umd.js`, + name: 'webVitals', + }, + plugins: configurePlugins({module: false, polyfill: false}), + }, + { + input: 'dist/modules/attribution.js', + output: { + format: 'iife', + file: './dist/web-vitals.attribution.iife.js', + name: 'webVitals', + }, + plugins: configurePlugins({module: false, polyfill: false}), + }, ]; export default configs; diff --git a/src/attribution.ts b/src/attribution.ts new file mode 100644 index 00000000..ce911d3d --- /dev/null +++ b/src/attribution.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2022 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. + */ + +export {onCLS} from './attribution/onCLS.js'; +export {onFCP} from './attribution/onFCP.js'; +export {onFID} from './attribution/onFID.js'; +export {onINP} from './attribution/onINP.js'; +export {onLCP} from './attribution/onLCP.js'; +export {onTTFB} from './attribution/onTTFB.js'; + +export * from './types.js'; diff --git a/src/attribution/onCLS.ts b/src/attribution/onCLS.ts new file mode 100644 index 00000000..08af9c72 --- /dev/null +++ b/src/attribution/onCLS.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2022 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 {getLoadState} from '../lib/getLoadState.js'; +import {getSelector} from '../lib/getSelector.js'; +import {onCLS as unattributedOnCLS} from '../onCLS.js'; +import {CLSAttribution, CLSReportCallback, CLSReportCallbackWithAttribution, CLSMetric, CLSMetricWithAttribution, ReportOpts} from '../types.js'; + + +const getLargestLayoutShiftEntry = (entries: LayoutShift[]) => { + return entries.reduce((a, b) => a && a.value > b.value ? a : b); +} + +const getLargestLayoutShiftSource = (sources: LayoutShiftAttribution[]) => { + return sources.find((s) => s.node && s.node.nodeType === 1) || sources[0]; +} + +const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => { + let attribution: CLSAttribution = {}; + if (metric.entries.length) { + const largestEntry = getLargestLayoutShiftEntry(metric.entries); + if (largestEntry && largestEntry.sources && largestEntry.sources.length) { + const largestSource = getLargestLayoutShiftSource(largestEntry.sources); + if (largestSource) { + attribution = { + largestShiftTarget: getSelector(largestSource.node), + largestShiftTime: largestEntry.startTime, + largestShiftValue: largestEntry.value, + largestShiftSource: largestSource, + largestShiftEntry: largestEntry, + loadState: getLoadState(largestEntry.startTime), + }; + } + } + } + return Object.assign(metric, {attribution}); +} + +/** + * Calculates the [CLS](https://web.dev/cls/) value for the current page and + * calls the `callback` function once the value is ready to be reported, along + * with all `layout-shift` performance entries that were used in the metric + * value calculation. The reported value is a `double` (corresponding to a + * [layout shift score](https://web.dev/cls/#layout-shift-score)). + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called as soon as the value is initially + * determined as well as any time the value changes throughout the page + * lifespan. + * + * _**Important:** CLS should be continually monitored for changes throughout + * the entire lifespan of a page—including if the user returns to the page after + * it's been hidden/backgrounded. However, since browsers often [will not fire + * additional callbacks once the user has backgrounded a + * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), + * `callback` is always called when the page's visibility state changes to + * hidden. As a result, the `callback` function might be called multiple times + * during the same page load._ + */ +export const onCLS = (onReport: CLSReportCallbackWithAttribution, opts?: ReportOpts) => { + unattributedOnCLS(((metric: CLSMetric) => { + onReport(attributeCLS(metric)); + }) as CLSReportCallback, opts); +}; diff --git a/src/attribution/onFCP.ts b/src/attribution/onFCP.ts new file mode 100644 index 00000000..d00ad120 --- /dev/null +++ b/src/attribution/onFCP.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2022 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 {getBFCacheRestoreTime} from '../lib/bfcache.js'; +import {getLoadState} from '../lib/getLoadState.js'; +import {getNavigationEntry} from '../lib/getNavigationEntry.js'; +import {onFCP as unattributedOnFCP} from '../onFCP.js'; +import {FCPMetricWithAttribution, FCPReportCallback, FCPReportCallbackWithAttribution, ReportOpts} from '../types.js'; + + +const attributeFCP = (metric: FCPMetricWithAttribution): void => { + if (metric.entries.length) { + const navigationEntry = getNavigationEntry(); + const fcpEntry = metric.entries[metric.entries.length - 1]; + + if (navigationEntry) { + const activationStart = navigationEntry.activationStart || 0; + const ttfb = Math.max(0, navigationEntry.responseStart - activationStart); + + metric.attribution = { + timeToFirstByte: ttfb, + firstByteToFCP: metric.value - ttfb, + loadState: getLoadState(metric.entries[0].startTime), + navigationEntry, + fcpEntry, + }; + } + } else { + // There are no entries when restored from bfcache. + metric.attribution = { + timeToFirstByte: 0, + firstByteToFCP: metric.value, + loadState: getLoadState(getBFCacheRestoreTime()), + }; + } +}; + +/** + * Calculates the [FCP](https://web.dev/fcp/) value for the current page and + * calls the `callback` function once the value is ready, along with the + * relevant `paint` performance entry used to determine the value. The reported + * value is a `DOMHighResTimeStamp`. + */ +export const onFCP = (onReport: FCPReportCallbackWithAttribution, opts?: ReportOpts) => { + unattributedOnFCP(((metric: FCPMetricWithAttribution) => { + attributeFCP(metric); + onReport(metric); + }) as FCPReportCallback, opts); +}; diff --git a/src/attribution/onFID.ts b/src/attribution/onFID.ts new file mode 100644 index 00000000..63144706 --- /dev/null +++ b/src/attribution/onFID.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2022 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 {getLoadState} from '../lib/getLoadState.js'; +import {getSelector} from '../lib/getSelector.js'; +import {onFID as unattributedOnFID} from '../onFID.js'; +import {FIDAttribution, FIDMetricWithAttribution, FIDReportCallback, FIDReportCallbackWithAttribution, ReportOpts} from '../types.js'; + + +const attributeFID = (metric: FIDMetricWithAttribution): void => { + const fidEntry = metric.entries[0]; + metric.attribution = { + eventTarget: getSelector(fidEntry.target), + eventType: fidEntry.name, + eventTime: fidEntry.startTime, + eventEntry: fidEntry, + loadState: getLoadState(fidEntry.startTime), + } as FIDAttribution; +}; + +/** + * Calculates the [FID](https://web.dev/fid/) value for the current page and + * calls the `callback` function once the value is ready, along with the + * relevant `first-input` performance entry used to determine the value. The + * reported value is a `DOMHighResTimeStamp`. + * + * _**Important:** since FID is only reported after the user interacts with the + * page, it's possible that it will not be reported for some page loads._ + */ +export const onFID = (onReport: FIDReportCallbackWithAttribution, opts?: ReportOpts) => { + unattributedOnFID(((metric: FIDMetricWithAttribution) => { + attributeFID(metric); + onReport(metric); + }) as FIDReportCallback, opts); +}; diff --git a/src/attribution/onINP.ts b/src/attribution/onINP.ts new file mode 100644 index 00000000..a47e5abe --- /dev/null +++ b/src/attribution/onINP.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2022 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 {getLoadState} from '../lib/getLoadState.js'; +import {getSelector} from '../lib/getSelector.js'; +import {onINP as unattributedOnINP} from '../onINP.js'; +import {INPMetricWithAttribution, INPReportCallback, INPReportCallbackWithAttribution, ReportOpts} from '../types.js'; + + +const attributeINP = (metric: INPMetricWithAttribution): void => { + if (metric.entries.length) { + const longestEntry = metric.entries.sort((a, b) => { + // Sort by: 1) duration (DESC), then 2) processing time (DESC) + return b.duration - a.duration || (b.processingEnd - b.processingStart) - + (a.processingEnd - a.processingStart); + })[0]; + + metric.attribution = { + eventTarget: getSelector(longestEntry.target), + eventType: longestEntry.name, + eventTime: longestEntry.startTime, + eventEntry: longestEntry, + loadState: getLoadState(longestEntry.startTime), + }; + } else { + metric.attribution = {}; + } +}; + +/** + * Calculates the [INP](https://web.dev/responsiveness/) value for the current + * page and calls the `callback` function once the value is ready, along with + * the `event` performance entries reported for that interaction. The reported + * value is a `DOMHighResTimeStamp`. + * + * A custom `durationThreshold` configuration option can optionally be passed to + * control what `event-timing` entries are considered for INP reporting. The + * default threshold is `40`, which means INP scores of less than 40 are + * reported as 0. Note that this will not affect your 75th percentile INP value + * unless that value is also less than 40 (well below the recommended + * [good](https://web.dev/inp/#what-is-a-good-inp-score) threshold). + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called as soon as the value is initially + * determined as well as any time the value changes throughout the page + * lifespan. + * + * _**Important:** INP should be continually monitored for changes throughout + * the entire lifespan of a page—including if the user returns to the page after + * it's been hidden/backgrounded. However, since browsers often [will not fire + * additional callbacks once the user has backgrounded a + * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), + * `callback` is always called when the page's visibility state changes to + * hidden. As a result, the `callback` function might be called multiple times + * during the same page load._ + */ +export const onINP = (onReport: INPReportCallbackWithAttribution, opts?: ReportOpts) => { + unattributedOnINP(((metric: INPMetricWithAttribution) => { + attributeINP(metric); + onReport(metric); + }) as INPReportCallback, opts); +}; diff --git a/src/attribution/onLCP.ts b/src/attribution/onLCP.ts new file mode 100644 index 00000000..b697aa79 --- /dev/null +++ b/src/attribution/onLCP.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2022 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 {getNavigationEntry} from '../lib/getNavigationEntry.js'; +import {getSelector} from '../lib/getSelector.js'; +import {onLCP as unattributedOnLCP} from '../onLCP.js'; +import {LCPMetricWithAttribution, LCPReportCallback, LCPReportCallbackWithAttribution, ReportOpts} from '../types.js'; + + +const attributeLCP = (metric: LCPMetricWithAttribution): void => { + if (metric.entries.length) { + const navigationEntry = getNavigationEntry(); + + if (navigationEntry) { + const activationStart = navigationEntry.activationStart || 0; + const lcpEntry = metric.entries[metric.entries.length - 1]; + const lcpResourceEntry = performance + .getEntriesByType('resource') + .filter((e) => e.name === lcpEntry.url)[0]; + + const ttfb = Math.max(0, navigationEntry.responseStart - activationStart); + + const lcpRequestStart = Math.max( + ttfb, + // Prefer `requestStart` (if TOA is set), otherwise use `startTime`. + lcpResourceEntry ? (lcpResourceEntry.requestStart || + lcpResourceEntry.startTime) - activationStart : 0 + ); + const lcpResponseEnd = Math.max( + lcpRequestStart, + lcpResourceEntry ? lcpResourceEntry.responseEnd - activationStart : 0 + ); + const lcpRenderTime = Math.max( + lcpResponseEnd, + lcpEntry ? lcpEntry.startTime - activationStart : 0 + ); + + metric.attribution = { + element: getSelector(lcpEntry.element), + timeToFirstByte: ttfb, + resourceLoadDelay: lcpRequestStart - ttfb, + resourceLoadTime: lcpResponseEnd - lcpRequestStart, + elementRenderDelay: lcpRenderTime - lcpResponseEnd, + navigationEntry, + lcpResourceEntry, + lcpEntry, + }; + } + } else { + // There are no entries when restored from bfcache. + metric.attribution = { + timeToFirstByte: 0, + resourceLoadDelay: 0, + resourceLoadTime: 0, + elementRenderDelay: metric.value, + }; + } +}; + +/** + * Calculates the [LCP](https://web.dev/lcp/) value for the current page and + * calls the `callback` function once the value is ready (along with the + * relevant `largest-contentful-paint` performance entry used to determine the + * value). The reported value is a `DOMHighResTimeStamp`. + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called any time a new `largest-contentful-paint` + * performance entry is dispatched, or once the final value of the metric has + * been determined. + */ +export const onLCP = (onReport: LCPReportCallbackWithAttribution, opts?: ReportOpts) => { + unattributedOnLCP(((metric: LCPMetricWithAttribution) => { + attributeLCP(metric); + onReport(metric); + }) as LCPReportCallback, opts); +}; diff --git a/src/attribution/onTTFB.ts b/src/attribution/onTTFB.ts new file mode 100644 index 00000000..fda25bd6 --- /dev/null +++ b/src/attribution/onTTFB.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2022 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 {onTTFB as unattributedOnTTFB} from '../onTTFB.js'; +import {TTFBMetricWithAttribution, TTFBReportCallback, TTFBReportCallbackWithAttribution, ReportOpts} from '../types.js'; + + +const attributeTTFB = (metric: TTFBMetricWithAttribution): void => { + if (metric.entries.length) { + const navigationEntry = metric.entries[0]; + const activationStart = navigationEntry.activationStart || 0; + + const dnsStart = Math.max( + navigationEntry.domainLookupStart - activationStart, 0); + const connectStart = Math.max( + navigationEntry.connectStart - activationStart, 0); + const requestStart = Math.max( + navigationEntry.requestStart - activationStart, 0); + + metric.attribution = { + waitingTime: dnsStart, + dnsTime: connectStart - dnsStart, + connectionTime: requestStart - connectStart, + requestTime: metric.value - requestStart, + navigationEntry: navigationEntry, + }; + } else { + metric.attribution = { + waitingTime: 0, + dnsTime: 0, + connectionTime: 0, + requestTime: 0, + }; + } +}; + +/** + * Calculates the [TTFB](https://web.dev/time-to-first-byte/) value for the + * current page and calls the `callback` function once the page has loaded, + * along with the relevant `navigation` performance entry used to determine the + * value. The reported value is a `DOMHighResTimeStamp`. + * + * Note, this function waits until after the page is loaded to call `callback` + * in order to ensure all properties of the `navigation` entry are populated. + * This is useful if you want to report on other metrics exposed by the + * [Navigation Timing API](https://w3c.github.io/navigation-timing/). For + * example, the TTFB metric starts from the page's [time + * origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it + * includes time spent on DNS lookup, connection negotiation, network latency, + * and server processing time. + */ +export const onTTFB = (onReport: TTFBReportCallbackWithAttribution, opts?: ReportOpts) => { + unattributedOnTTFB(((metric: TTFBMetricWithAttribution) => { + attributeTTFB(metric); + onReport(metric); + }) as TTFBReportCallback, opts); +}; diff --git a/src/lib/bfcache.ts b/src/lib/bfcache.ts index 86daf7a1..5908ff48 100644 --- a/src/lib/bfcache.ts +++ b/src/lib/bfcache.ts @@ -18,14 +18,14 @@ interface onBFCacheRestoreCallback { (event: PageTransitionEvent): void; } -let isPersisted = false; +let bfcacheRestoreTime = -1; -export const isBFCacheRestore = () => isPersisted; +export const getBFCacheRestoreTime = () => bfcacheRestoreTime; export const onBFCacheRestore = (cb: onBFCacheRestoreCallback) => { addEventListener('pageshow', (event) => { if (event.persisted) { - isPersisted = true; + bfcacheRestoreTime = event.timeStamp; cb(event); } }, true); diff --git a/src/lib/getLoadState.ts b/src/lib/getLoadState.ts new file mode 100644 index 00000000..fda98289 --- /dev/null +++ b/src/lib/getLoadState.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2022 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 {getNavigationEntry} from './getNavigationEntry.js'; +import {LoadState} from '../types.js'; + + +export const getLoadState = (timestamp: number): LoadState => { + if (document.readyState === 'loading') { + // If the `readyState` is 'loading' there's no need to look at timestamps + // since the timestamp has to be the current time or earlier. + return 'loading'; + } else { + const navigationEntry = getNavigationEntry(); + if (navigationEntry) { + + if (timestamp < navigationEntry.domInteractive) { + return 'loading'; + } else if (navigationEntry.domContentLoadedEventStart === 0 || + timestamp < navigationEntry.domContentLoadedEventStart) { + // If the `domContentLoadedEventStart` timestamp has not yet been + // set, or if the given timestamp is less than that value. + return 'domInteractive'; + } else if (navigationEntry.domComplete === 0 || + timestamp < navigationEntry.domComplete) { + // If the `domComplete` timestamp has not yet been + // set, or if the given timestamp is less than that value. + return 'domContentloaded'; + } + } + } + // If any of the above fail, default to loaded. This could really only + // happy if the browser doesn't support the performance timeline, which + // most likely means this code would never run anyway. + return 'loaded'; +} diff --git a/src/lib/getSelector.ts b/src/lib/getSelector.ts new file mode 100644 index 00000000..3c48a760 --- /dev/null +++ b/src/lib/getSelector.ts @@ -0,0 +1,42 @@ + +/* + * Copyright 2022 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. + */ + +const getName = (node: Node) => { + const name = node.nodeName; + return node.nodeType === 1 ? + name.toLowerCase() : name.toUpperCase().replace(/^#/, ''); +} + +export const getSelector = (node: Node | null | undefined, maxLen?: number) => { + let sel = ''; + + try { + while (node && node.nodeType !== 9) { + const el: Element = (node as Element); + const part = el.id ? '#' + el.id : getName(el) + ( + (el.className && el.className.length) ? + '.' + el.className.replace(/\s+/g, '.') : ''); + if (sel.length + part.length > (maxLen || 100) - 1) return sel || part; + sel = sel ? part + '>' + sel : part; + if (el.id) break; + node = el.parentNode; + } + } catch (err) { + // Do nothing... + } + return sel; +}; diff --git a/src/lib/initMetric.ts b/src/lib/initMetric.ts index 5e65fa26..893188a3 100644 --- a/src/lib/initMetric.ts +++ b/src/lib/initMetric.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import {isBFCacheRestore} from './bfcache.js'; +import {getBFCacheRestoreTime} from './bfcache.js'; import {generateUniqueID} from './generateUniqueID.js'; import {getActivationStart} from './getActivationStart.js'; import {getNavigationEntry} from './getNavigationEntry.js'; @@ -25,7 +25,7 @@ export const initMetric = (name: Metric['name'], value?: number): Metric => { const navEntry = getNavigationEntry(); let navigationType: Metric['navigationType']; - if (isBFCacheRestore()) { + if (getBFCacheRestoreTime() >= 0) { navigationType = 'back_forward_cache'; } else if (navEntry) { if (document.prerendering || getActivationStart() > 0) { diff --git a/src/lib/observe.ts b/src/lib/observe.ts index ce160dc0..d5270a40 100644 --- a/src/lib/observe.ts +++ b/src/lib/observe.ts @@ -14,11 +14,17 @@ * limitations under the License. */ -import {Metric} from '../types.js'; +import {FirstInputPolyfillEntry, NavigationTimingPolyfillEntry} from '../types.js'; -interface PerformanceEntriesHandler { - (entries: Metric['entries']): void; +interface PerformanceEntryMap { + 'event': PerformanceEventTiming[]; + 'paint': PerformancePaintTiming[]; + 'layout-shift': LayoutShift[]; + 'largest-contentful-paint': LargestContentfulPaint[]; + 'first-input': PerformanceEventTiming[] | FirstInputPolyfillEntry[]; + 'navigation': PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[]; + 'resource': PerformanceResourceTiming[]; } /** @@ -29,15 +35,15 @@ interface PerformanceEntriesHandler { * This function also feature-detects entry support and wraps the logic in a * try/catch to avoid errors in unsupporting browsers. */ -export const observe = ( - type: string, - callback: PerformanceEntriesHandler, +export const observe = ( + type: K, + callback: (entries: PerformanceEntryMap[K]) => void, opts?: PerformanceObserverInit, ): PerformanceObserver | undefined => { try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { - const po: PerformanceObserver = new PerformanceObserver((list) => { - callback(list.getEntries()); + const po = new PerformanceObserver((list) => { + callback(list.getEntries() as PerformanceEntryMap[K]); }); po.observe(Object.assign({ type, diff --git a/src/lib/polyfills/interactionCountPolyfill.ts b/src/lib/polyfills/interactionCountPolyfill.ts index 24280db5..18e994c1 100644 --- a/src/lib/polyfills/interactionCountPolyfill.ts +++ b/src/lib/polyfills/interactionCountPolyfill.ts @@ -15,7 +15,7 @@ */ import {observe} from '../observe.js'; -import {Metric, PerformanceEventTiming} from '../../types.js'; +import {Metric} from '../../types.js'; declare global { diff --git a/src/onCLS.ts b/src/onCLS.ts index a4a8e82d..c423f669 100644 --- a/src/onCLS.ts +++ b/src/onCLS.ts @@ -20,13 +20,34 @@ import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; import {bindReporter} from './lib/bindReporter.js'; import {onFCP} from './onFCP.js'; -import {LayoutShift, Metric, ReportCallback, ReportOpts} from './types.js'; +import {CLSMetric, CLSReportCallback, ReportOpts} from './types.js'; let isMonitoringFCP = false; let fcpValue = -1; -export const onCLS = (onReport: ReportCallback, opts?: ReportOpts) => { +/** + * Calculates the [CLS](https://web.dev/cls/) value for the current page and + * calls the `callback` function once the value is ready to be reported, along + * with all `layout-shift` performance entries that were used in the metric + * value calculation. The reported value is a `double` (corresponding to a + * [layout shift score](https://web.dev/cls/#layout-shift-score)). + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called as soon as the value is initially + * determined as well as any time the value changes throughout the page + * lifespan. + * + * _**Important:** CLS should be continually monitored for changes throughout + * the entire lifespan of a page—including if the user returns to the page after + * it's been hidden/backgrounded. However, since browsers often [will not fire + * additional callbacks once the user has backgrounded a + * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), + * `callback` is always called when the page's visibility state changes to + * hidden. As a result, the `callback` function might be called multiple times + * during the same page load._ + */ +export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => { // Set defaults opts = opts || {}; @@ -39,7 +60,7 @@ export const onCLS = (onReport: ReportCallback, opts?: ReportOpts) => { isMonitoringFCP = true; } - const onReportWrapped: ReportCallback = (arg) => { + const onReportWrapped: CLSReportCallback = (arg) => { if (fcpValue > -1) { onReport(arg); } @@ -51,8 +72,9 @@ export const onCLS = (onReport: ReportCallback, opts?: ReportOpts) => { let sessionValue = 0; let sessionEntries: PerformanceEntry[] = []; - const handleEntries = (entries: Metric['entries']) => { - (entries as LayoutShift[]).forEach((entry) => { + // const handleEntries = (entries: Metric['entries']) => { + const handleEntries = (entries: LayoutShift[]) => { + entries.forEach((entry) => { // Only count layout shifts without recent user input. if (!entry.hadRecentInput) { const firstSessionEntry = sessionEntries[0]; @@ -87,7 +109,7 @@ export const onCLS = (onReport: ReportCallback, opts?: ReportOpts) => { report = bindReporter(onReportWrapped, metric, opts.reportAllChanges); onHidden(() => { - handleEntries(po.takeRecords()); + handleEntries(po.takeRecords() as CLSMetric['entries']); report(true); }); diff --git a/src/onFCP.ts b/src/onFCP.ts index 95eab27d..372b7672 100644 --- a/src/onFCP.ts +++ b/src/onFCP.ts @@ -20,10 +20,15 @@ import {getActivationStart} from './lib/getActivationStart.js'; import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js'; import {initMetric} from './lib/initMetric.js'; import {observe} from './lib/observe.js'; -import {Metric, ReportCallback, ReportOpts} from './types.js'; +import {FCPMetric, FCPReportCallback, ReportOpts} from './types.js'; - -export const onFCP = (onReport: ReportCallback, opts?: ReportOpts) => { +/** + * Calculates the [FCP](https://web.dev/fcp/) value for the current page and + * calls the `callback` function once the value is ready, along with the + * relevant `paint` performance entry used to determine the value. The reported + * value is a `DOMHighResTimeStamp`. + */ +export const onFCP = (onReport: FCPReportCallback, opts?: ReportOpts) => { // Set defaults opts = opts || {}; @@ -31,7 +36,7 @@ export const onFCP = (onReport: ReportCallback, opts?: ReportOpts) => { let metric = initMetric('FCP'); let report: ReturnType; - const handleEntries = (entries: Metric['entries']) => { + const handleEntries = (entries: FCPMetric['entries']) => { (entries as PerformancePaintTiming[]).forEach((entry) => { if (entry.name === 'first-contentful-paint') { if (po) { diff --git a/src/onFID.ts b/src/onFID.ts index b6f42a67..4ddbf144 100644 --- a/src/onFID.ts +++ b/src/onFID.ts @@ -21,9 +21,17 @@ import {initMetric} from './lib/initMetric.js'; import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; import {firstInputPolyfill, resetFirstInputPolyfill} from './lib/polyfills/firstInputPolyfill.js'; -import {FirstInputPolyfillCallback, Metric, PerformanceEventTiming, ReportCallback, ReportOpts} from './types.js'; - +import {FIDMetric, FirstInputPolyfillCallback, ReportCallback, ReportOpts} from './types.js'; +/** + * Calculates the [FID](https://web.dev/fid/) value for the current page and + * calls the `callback` function once the value is ready, along with the + * relevant `first-input` performance entry used to determine the value. The + * reported value is a `DOMHighResTimeStamp`. + * + * _**Important:** since FID is only reported after the user interacts with the + * page, it's possible that it will not be reported for some page loads._ + */ export const onFID = (onReport: ReportCallback, opts?: ReportOpts) => { // Set defaults opts = opts || {}; @@ -41,7 +49,7 @@ export const onFID = (onReport: ReportCallback, opts?: ReportOpts) => { } } - const handleEntries = (entries: Metric['entries']) => { + const handleEntries = (entries: FIDMetric['entries']) => { (entries as PerformanceEventTiming[]).forEach(handleEntry); } @@ -50,7 +58,7 @@ export const onFID = (onReport: ReportCallback, opts?: ReportOpts) => { if (po) { onHidden(() => { - handleEntries(po.takeRecords()); + handleEntries(po.takeRecords() as FIDMetric['entries']); po.disconnect(); }, true); } diff --git a/src/onINP.ts b/src/onINP.ts index 929cafba..c1f57fab 100644 --- a/src/onINP.ts +++ b/src/onINP.ts @@ -20,7 +20,7 @@ import {initMetric} from './lib/initMetric.js'; import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; import {getInteractionCount, initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js'; -import {Metric, PerformanceEventTiming, ReportCallback, ReportOpts} from './types.js'; +import {INPMetric, ReportCallback, ReportOpts} from './types.js'; interface Interaction { id: number; @@ -104,6 +104,33 @@ const estimateP98LongestInteraction = () => { return longestInteractionList[candidateInteractionIndex]; } +/** + * Calculates the [INP](https://web.dev/responsiveness/) value for the current + * page and calls the `callback` function once the value is ready, along with + * the `event` performance entries reported for that interaction. The reported + * value is a `DOMHighResTimeStamp`. + * + * A custom `durationThreshold` configuration option can optionally be passed to + * control what `event-timing` entries are considered for INP reporting. The + * default threshold is `40`, which means INP scores of less than 40 are + * reported as 0. Note that this will not affect your 75th percentile INP value + * unless that value is also less than 40 (well below the recommended + * [good](https://web.dev/inp/#what-is-a-good-inp-score) threshold). + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called as soon as the value is initially + * determined as well as any time the value changes throughout the page + * lifespan. + * + * _**Important:** INP should be continually monitored for changes throughout + * the entire lifespan of a page—including if the user returns to the page after + * it's been hidden/backgrounded. However, since browsers often [will not fire + * additional callbacks once the user has backgrounded a + * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), + * `callback` is always called when the page's visibility state changes to + * hidden. As a result, the `callback` function might be called multiple times + * during the same page load._ + */ export const onINP = (onReport: ReportCallback, opts?: ReportOpts) => { // Set defaults opts = opts || {}; @@ -114,8 +141,8 @@ export const onINP = (onReport: ReportCallback, opts?: ReportOpts) => { let metric = initMetric('INP'); let report: ReturnType; - const handleEntries = (entries: Metric['entries']) => { - (entries as PerformanceEventTiming[]).forEach((entry) => { + const handleEntries = (entries: INPMetric['entries']) => { + entries.forEach((entry) => { if (entry.interactionId) { processEntry(entry); } @@ -168,7 +195,7 @@ export const onINP = (onReport: ReportCallback, opts?: ReportOpts) => { po.observe({type: 'first-input', buffered: true}); onHidden(() => { - handleEntries(po.takeRecords()); + handleEntries(po.takeRecords() as INPMetric['entries']); // If the interaction count shows that there were interactions but // none were captured by the PerformanceObserver, report a latency of 0. diff --git a/src/onLCP.ts b/src/onLCP.ts index 23ea0d3e..f174f4bc 100644 --- a/src/onLCP.ts +++ b/src/onLCP.ts @@ -21,11 +21,22 @@ import {getVisibilityWatcher} from './lib/getVisibilityWatcher.js'; import {initMetric} from './lib/initMetric.js'; import {observe} from './lib/observe.js'; import {onHidden} from './lib/onHidden.js'; -import {Metric, LargestContentfulPaint, ReportCallback, ReportOpts} from './types.js'; +import {LCPMetric, ReportCallback, ReportOpts} from './types.js'; const reportedMetricIDs: Record = {}; +/** + * Calculates the [LCP](https://web.dev/lcp/) value for the current page and + * calls the `callback` function once the value is ready (along with the + * relevant `largest-contentful-paint` performance entry used to determine the + * value). The reported value is a `DOMHighResTimeStamp`. + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called any time a new `largest-contentful-paint` + * performance entry is dispatched, or once the final value of the metric has + * been determined. + */ export const onLCP = (onReport: ReportCallback, opts?: ReportOpts) => { // Set defaults opts = opts || {}; @@ -34,7 +45,7 @@ export const onLCP = (onReport: ReportCallback, opts?: ReportOpts) => { let metric = initMetric('LCP'); let report: ReturnType; - const handleEntries = (entries: Metric['entries']) => { + const handleEntries = (entries: LCPMetric['entries']) => { const lastEntry = (entries[entries.length - 1] as LargestContentfulPaint); if (lastEntry) { // The startTime attribute returns the value of the renderTime if it is @@ -59,7 +70,7 @@ export const onLCP = (onReport: ReportCallback, opts?: ReportOpts) => { const stopListening = () => { if (!reportedMetricIDs[metric.id]) { - handleEntries(po.takeRecords()); + handleEntries(po.takeRecords() as LCPMetric['entries']); po.disconnect(); reportedMetricIDs[metric.id] = true; report(true); diff --git a/src/onTTFB.ts b/src/onTTFB.ts index aaf113c3..3c6b4fcf 100644 --- a/src/onTTFB.ts +++ b/src/onTTFB.ts @@ -19,7 +19,7 @@ import {initMetric} from './lib/initMetric.js'; import {onBFCacheRestore} from './lib/bfcache.js'; import {getNavigationEntry} from './lib/getNavigationEntry.js'; import {ReportCallback, ReportOpts} from './types.js'; -import { getActivationStart } from './lib/getActivationStart.js'; +import {getActivationStart} from './lib/getActivationStart.js'; /** @@ -37,6 +37,21 @@ const whenReady = (callback: () => void) => { } } +/** + * Calculates the [TTFB](https://web.dev/time-to-first-byte/) value for the + * current page and calls the `callback` function once the page has loaded, + * along with the relevant `navigation` performance entry used to determine the + * value. The reported value is a `DOMHighResTimeStamp`. + * + * Note, this function waits until after the page is loaded to call `callback` + * in order to ensure all properties of the `navigation` entry are populated. + * This is useful if you want to report on other metrics exposed by the + * [Navigation Timing API](https://w3c.github.io/navigation-timing/). For + * example, the TTFB metric starts from the page's [time + * origin](https://www.w3.org/TR/hr-time-2/#sec-time-origin), which means it + * includes time spent on DNS lookup, connection negotiation, network latency, + * and server processing time. + */ export const onTTFB = (onReport: ReportCallback, opts?: ReportOpts) => { // Set defaults opts = opts || {}; @@ -66,10 +81,9 @@ export const onTTFB = (onReport: ReportCallback, opts?: ReportOpts) => { } }); - onBFCacheRestore((event) => { - metric = initMetric('TTFB'); + onBFCacheRestore(() => { + metric = initMetric('TTFB', 0); report = bindReporter(onReport, metric, opts!.reportAllChanges); - metric.value = performance.now() - event.timeStamp; report(true); }); }; diff --git a/src/types.ts b/src/types.ts index e01d79b9..26beaba2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,47 +14,42 @@ * limitations under the License. */ -export interface Metric { - // The name of the metric (in acronym form). - name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; - - // The current value of the metric. - value: number; - - // The delta between the current value and the last-reported value. - // On the first report, `delta` and `value` will always be the same. - delta: number; - - // A unique ID representing this particular metric instance. This ID can - // be used by an analytics tool to dedupe multiple values sent for the same - // metric instance, or to group multiple deltas together and calculate a - // total. It can also be used to differentiate multiple different metric - // instances sent from the same page, which can happen if the page is - // restored from the back/forward cache (in that case new metrics object - // get created). - id: string; - - // Any performance entries relevant to the metric value calculation. - // The array may also be empty if the metric value was not based on any - // entries (e.g. a CLS value of 0 given no layout shifts). - entries: (PerformanceEntry | LayoutShift | FirstInputPolyfillEntry | NavigationTimingPolyfillEntry)[]; - - // For regular navigations, the type will be the same as the type indicated - // by the Navigation Timing API (or `undefined` if the browser doesn't - // support that API). For pages that are restored from the bfcache, this - // value will be 'back_forward_cache'. - navigationType: NavigationTimingType | 'back_forward_cache' | 'prerender' | undefined; -} +import {FirstInputPolyfillCallback} from './types/polyfills.js'; + +export * from './types/base.js'; +export * from './types/polyfills.js'; + +export * from './types/cls.js'; +export * from './types/fcp.js'; +export * from './types/fid.js'; +export * from './types/inp.js'; +export * from './types/lcp.js'; +export * from './types/ttfb.js'; -export interface ReportCallback { - (metric: Metric): void; + +// -------------------------------------------------------------------------- +// Web Vitals package globals +// -------------------------------------------------------------------------- + +export interface WebVitalsGlobal { + firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void; + resetFirstInputPolyfill: () => void; + firstHiddenTime: number; } -export interface ReportOpts { - reportAllChanges?: boolean; - durationThreshold?: number; +declare global { + interface Window { + webVitals: WebVitalsGlobal; + + // Build flags: + __WEB_VITALS_POLYFILL__: boolean; + } } +// -------------------------------------------------------------------------- +// Everything below is modifications to built-in modules. +// -------------------------------------------------------------------------- + interface PerformanceEntryMap { 'navigation': PerformanceNavigationTiming; 'resource': PerformanceResourceTiming; @@ -67,69 +62,48 @@ declare global { interface Document { prerendering?: boolean } + interface Performance { getEntriesByType(type: K): PerformanceEntryMap[K][] } + // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline interface PerformanceObserverInit { durationThreshold?: number; } + // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension interface PerformanceNavigationTiming { activationStart?: number; } -} - -// https://wicg.github.io/event-timing/#sec-performance-event-timing -export interface PerformanceEventTiming extends PerformanceEntry { - processingStart: DOMHighResTimeStamp; - processingEnd: DOMHighResTimeStamp; - duration: DOMHighResTimeStamp; - cancelable?: boolean; - target?: Element; - interactionId?: number; -} - -// https://wicg.github.io/layout-instability/#sec-layout-shift -export interface LayoutShift extends PerformanceEntry { - value: number; - hadRecentInput: boolean; -} - -// https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface -export interface LargestContentfulPaint extends PerformanceEntry { - renderTime: DOMHighResTimeStamp; - loadTime: DOMHighResTimeStamp; - size: number; - id: string; - url: string; - element?: Element; -} - -export type FirstInputPolyfillEntry = - Omit -export interface FirstInputPolyfillCallback { - (entry: FirstInputPolyfillEntry): void; -} - -export type NavigationTimingPolyfillEntry = Omit & { - type?: PerformanceNavigationTiming['type']; -} + // https://wicg.github.io/event-timing/#sec-performance-event-timing + interface PerformanceEventTiming extends PerformanceEntry { + duration: DOMHighResTimeStamp; + interactionId?: number; + } -export interface WebVitalsGlobal { - firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void; - resetFirstInputPolyfill: () => void; - firstHiddenTime: number; -} + // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution + interface LayoutShiftAttribution { + node?: Node; + previousRect: DOMRectReadOnly; + currentRect: DOMRectReadOnly; + } -declare global { - interface Window { - webVitals: WebVitalsGlobal; + // https://wicg.github.io/layout-instability/#sec-layout-shift + interface LayoutShift extends PerformanceEntry { + value: number; + sources: LayoutShiftAttribution[]; + hadRecentInput: boolean; + } - // Build flags: - __WEB_VITALS_POLYFILL__: boolean; + // https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface + interface LargestContentfulPaint extends PerformanceEntry { + renderTime: DOMHighResTimeStamp; + loadTime: DOMHighResTimeStamp; + size: number; + id: string; + url: string; + element?: Element; } } diff --git a/src/types/base.ts b/src/types/base.ts new file mode 100644 index 00000000..ef51add7 --- /dev/null +++ b/src/types/base.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2022 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 {FirstInputPolyfillEntry, NavigationTimingPolyfillEntry} from './polyfills.js'; + + +export interface Metric { + /** + * The name of the metric (in acronym form). + */ + name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; + + /** + * The current value of the metric. + */ + value: number; + + /** + * The delta between the current value and the last-reported value. + * On the first report, `delta` and `value` will always be the same. + */ + delta: number; + + /** + * A unique ID representing this particular metric instance. This ID can + * be used by an analytics tool to dedupe multiple values sent for the same + * metric instance, or to group multiple deltas together and calculate a + * total. It can also be used to differentiate multiple different metric + * instances sent from the same page, which can happen if the page is + * restored from the back/forward cache (in that case new metrics object + * get created). + */ + id: string; + + /** + * Any performance entries relevant to the metric value calculation. + * The array may also be empty if the metric value was not based on any + * entries (e.g. a CLS value of 0 given no layout shifts). + */ + entries: (PerformanceEntry | LayoutShift | FirstInputPolyfillEntry | NavigationTimingPolyfillEntry)[]; + + /** + * For regular navigations, the type will be the same as the type indicated + * by the Navigation Timing API (or `undefined` if the browser doesn't + * support that API). For pages that are restored from the bfcache, this + * value will be 'back_forward_cache'. + */ + navigationType: NavigationTimingType | 'back_forward_cache' | 'prerender' | undefined; +} + +/** + * A version of the `Metric` that is used with the attribution build. + */ +export interface MetricWithAttribution extends Metric { + /** + * An object containing potentially-helpful debugging information that + * can be sent along with the metric value for the current page visit in + * order to help identify issues happening to real-users in the field. + */ + attribution: {[key: string]: unknown}; +} + +export interface ReportCallback { + (metric: Metric): void; +} + +export interface ReportOpts { + reportAllChanges?: boolean; + durationThreshold?: number; +} + +/** + * The loading state of the document. Note: this value is similar to + * `document.readyState` but it subdivides the "interactive" state into the + * time before and after the DOMContentLoaded event fires. + * + * State descriptions: + * - `loading`: the initial document response has not yet been fully downloaded + * and parsed. This is equivalent to the corresponding `readyState` value. + * - `domInteractive`: the document has been fully loaded and parsed, but + * scripts may not have yet finished loading and executing. + * - `domContentLoaded`: the document is fully loaded and parsed, and all + * scripts (except `async` scripts) have loaded and finished executing. + * - `loaded`: the document and all of its sub-resources have finished loading. + * This is equivalent to a document `readyState` of "complete". + */ +export type LoadState = 'loading' | 'domInteractive' | 'domContentloaded' | 'loaded'; diff --git a/src/types/cls.ts b/src/types/cls.ts new file mode 100644 index 00000000..055e1f94 --- /dev/null +++ b/src/types/cls.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2022 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 {LoadState, Metric, ReportCallback} from './base.js'; + + +/** + * A CLS-specific version of the Metric object. + */ +export interface CLSMetric extends Metric { + name: 'CLS'; + entries: LayoutShift[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the CLS value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ + export interface CLSAttribution { + /** + * A selector identifying the first element (in document order) that + * shifted when the single largest layout shift contributing to the page's + * CLS score occurred. + */ + largestShiftTarget?: string; + /** + * The time when the single largest layout shift contributing to the page's + * CLS score occurred. + */ + largestShiftTime?: DOMHighResTimeStamp; + /** + * The layout shift score of the single largest layout shift contributing to + * the page's CLS score. + */ + largestShiftValue?: number; + /** + * The `LayoutShiftEntry` representing the single largest layout shift + * contributing to the page's CLS score. (Useful when you need more than just + * `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`). + */ + largestShiftEntry?: LayoutShift; + /** + * The first element source (in document order) among the `sources` list + * of the `largestShiftEntry` object. (Also useful when you need more than + * just `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`). + */ + largestShiftSource?: LayoutShiftAttribution; + /** + * The loading state of the document at the time when the largest layout + * shift contribution to the page's CLS score occurred (see `LoadState` + * for details). + */ + loadState?: LoadState; +} + +/** + * A CLS-specific version of the Metric object with attribution. + */ +export interface CLSMetricWithAttribution extends CLSMetric { + attribution: CLSAttribution; +} + +/** + * A CLS-specific version of the ReportCallback function. + */ +export interface CLSReportCallback extends ReportCallback { + (metric: CLSMetric): void; +} + +/** + * A CLS-specific version of the ReportCallback function with attribution. + */ +export interface CLSReportCallbackWithAttribution extends CLSReportCallback { + (metric: CLSMetricWithAttribution): void; +} diff --git a/src/types/fcp.ts b/src/types/fcp.ts new file mode 100644 index 00000000..95abf4fc --- /dev/null +++ b/src/types/fcp.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2022 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 {LoadState, Metric, ReportCallback} from './base.js'; +import {NavigationTimingPolyfillEntry} from './polyfills.js'; + + +/** + * An FCP-specific version of the Metric object. + */ +export interface FCPMetric extends Metric { + name: 'FCP'; + entries: PerformancePaintTiming[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the FCP value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ + export interface FCPAttribution { + /** + * The time from when the user initiates loading the page until when the + * browser receives the first byte of the response (a.k.a. TTFB). + */ + timeToFirstByte: number; + /** + * The delta between TTFB and the first contentful paint (FCP). + */ + firstByteToFCP: number; + /** + * The loading state of the document at the time when FCP `occurred (see + * `LoadState` for details). Ideally, documents can paint before they finish + * loading (e.g. the `loading` or `domInteractive` phases). + */ + loadState: LoadState, + /** + * The `PerformancePaintTiming` entry corresponding to FCP. + */ + fcpEntry?: PerformancePaintTiming, + /** + * The `navigation` entry of the current page, which is useful for diagnosing + * general page load issues. + */ + navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; +} + +/** + * An FCP-specific version of the Metric object with attribution. + */ +export interface FCPMetricWithAttribution extends FCPMetric { + attribution: FCPAttribution; +} + +/** + * An FCP-specific version of the ReportCallback function. + */ +export interface FCPReportCallback extends ReportCallback { + (metric: FCPMetric): void; +} + +/** + * An FCP-specific version of the ReportCallback function with attribution. + */ +export interface FCPReportCallbackWithAttribution extends FCPReportCallback { + (metric: FCPMetricWithAttribution): void; +} diff --git a/src/types/fid.ts b/src/types/fid.ts new file mode 100644 index 00000000..f30668fe --- /dev/null +++ b/src/types/fid.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2022 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 {LoadState, Metric, ReportCallback} from './base.js'; +import {FirstInputPolyfillEntry} from './polyfills.js'; + + +/** + * An FID-specific version of the Metric object. + */ +export interface FIDMetric extends Metric { + name: 'FID'; + entries: (PerformanceEventTiming | FirstInputPolyfillEntry)[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the FID value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ +export interface FIDAttribution { + /** + * A selector identifying the element that the user interacted with. This + * element will be the `target` of the `event` dispatched. + */ + eventTarget: string; + /** + * The time when the user interacted. This time will match the `timeStamp` + * value of the `event` dispatched. + */ + eventTime: number; + /** + * The `type` of the `event` dispatched from the user interaction. + */ + eventType: string; + /** + * The `PerformanceEventTiming` entry corresponding to FID (or the + * polyfill entry in browsers that don't support Event Timing). + */ + eventEntry: PerformanceEventTiming | FirstInputPolyfillEntry, + /** + * The loading state of the document at the time when the first interaction + * occurred (see `LoadState` for details). If the first interaction occurred + * while the document was loading and executing script (e.g. usually in the + * `domInteractive` phase) it can result in long input delays. + */ + loadState: LoadState; +} + +/** + * An FID-specific version of the Metric object with attribution. + */ +export interface FIDMetricWithAttribution extends FIDMetric { + attribution: FIDAttribution; +} + +/** + * An FID-specific version of the ReportCallback function. + */ +export interface FIDReportCallback extends ReportCallback { + (metric: FIDMetric): void; +} + +/** + * An FID-specific version of the ReportCallback function with attribution. + */ +export interface FIDReportCallbackWithAttribution extends FIDReportCallback { + (metric: FIDMetricWithAttribution): void; +} diff --git a/src/types/inp.ts b/src/types/inp.ts new file mode 100644 index 00000000..f6af8cf7 --- /dev/null +++ b/src/types/inp.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2022 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 {LoadState, Metric, ReportCallback} from './base.js'; + + +/** + * An INP-specific version of the Metric object. + */ +export interface INPMetric extends Metric { + name: 'INP'; + entries: PerformanceEventTiming[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the INP value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ +export interface INPAttribution { + /** + * A selector identifying the element that the user interacted with for + * the event corresponding to INP. This element will be the `target` of the + * `event` dispatched. + */ + eventTarget?: string; + /** + * The time when the user interacted for the event corresponding to INP. + * This time will match the `timeStamp` value of the `event` dispatched. + */ + eventTime?: number; + /** + * The `type` of the `event` dispatched corresponding to INP. + */ + eventType?: string; + /** + * The `PerformanceEventTiming` entry corresponding to INP. + */ + eventEntry?: PerformanceEventTiming; + /** + * The loading state of the document at the time when the even corresponding + * to INP occurred (see `LoadState` for details). If the interaction occurred + * while the document was loading and executing script (e.g. usually in the + * `domInteractive` phase) it can result in long delays. + */ + loadState?: LoadState; +} + +/** + * An INP-specific version of the Metric object with attribution. + */ +export interface INPMetricWithAttribution extends INPMetric { + attribution: INPAttribution; +} + +/** + * An INP-specific version of the ReportCallback function. + */ +export interface INPReportCallback extends ReportCallback { + (metric: INPMetric): void; +} + +/** + * An INP-specific version of the ReportCallback function with attribution. + */ +export interface INPReportCallbackWithAttribution extends INPReportCallback { + (metric: INPMetricWithAttribution): void; +} diff --git a/src/types/lcp.ts b/src/types/lcp.ts new file mode 100644 index 00000000..61c666d3 --- /dev/null +++ b/src/types/lcp.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2022 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 {Metric, ReportCallback} from './base.js'; +import {NavigationTimingPolyfillEntry} from './polyfills.js'; + + +/** + * An LCP-specific version of the Metric object. + */ +export interface LCPMetric extends Metric { + name: 'LCP'; + entries: LargestContentfulPaint[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the LCP value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ +export interface LCPAttribution { + /** + * The element corresponding to the largest contentful paint for the page. + */ + element?: string, + /** + * The time from when the user initiates loading the page until when the + * browser receives the first byte of the response (a.k.a. TTFB). See + * [Optimize LCP](https://web.dev/optimize-lcp/) for details. + */ + timeToFirstByte: number; + /** + * The delta between TTFB and when the browser starts loading the LCP + * resource (if there is one, otherwise 0). See [Optimize + * LCP](https://web.dev/optimize-lcp/) for details. + */ + resourceLoadDelay: number; + /** + * The total time it takes to load the LCP resource itself (if there is one, + * otherwise 0). See [Optimize LCP](https://web.dev/optimize-lcp/) for + * details. + */ + resourceLoadTime: number; + /** + * The delta between when the LCP resource finishes loading until the LCP + * element is fully rendered. See [Optimize + * LCP](https://web.dev/optimize-lcp/) for details. + */ + elementRenderDelay: number; + /** + * The `navigation` entry of the current page, which is useful for diagnosing + * general page load issues. + */ + navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + /** + * 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. + */ + lcpEntry?: LargestContentfulPaint; +} + +/** + * An LCP-specific version of the Metric object with attribution. + */ +export interface LCPMetricWithAttribution extends LCPMetric { + attribution: LCPAttribution; +} + +/** + * An LCP-specific version of the ReportCallback function. + */ +export interface LCPReportCallback extends ReportCallback { + (metric: LCPMetric): void; +} + +/** + * An LCP-specific version of the ReportCallback function with attribution. + */ +export interface LCPReportCallbackWithAttribution extends LCPReportCallback { + (metric: LCPMetricWithAttribution): void; +} diff --git a/src/types/polyfills.ts b/src/types/polyfills.ts new file mode 100644 index 00000000..67498332 --- /dev/null +++ b/src/types/polyfills.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2022 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. + */ + +export type FirstInputPolyfillEntry = Omit; + +export interface FirstInputPolyfillCallback { + (entry: FirstInputPolyfillEntry): void; +} + +export type NavigationTimingPolyfillEntry = Omit & { + type?: PerformanceNavigationTiming['type']; +} diff --git a/src/types/ttfb.ts b/src/types/ttfb.ts new file mode 100644 index 00000000..838a0a1c --- /dev/null +++ b/src/types/ttfb.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2022 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 {Metric, ReportCallback} from './base.js'; +import {NavigationTimingPolyfillEntry} from './polyfills.js'; + + +/** + * A TTFB-specific version of the Metric object. + */ +export interface TTFBMetric extends Metric { + name: 'TTFB'; + entries: PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the TTFB value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ + export interface TTFBAttribution { + /** + * The total time from when the user initiates loading the page to when the + * DNS lookup begins. This includes redirects, service worker startup, and + * HTTP cache lookup times. + */ + waitingTime: number; + /** + * The total time to resolve the DNS for the current request. + */ + dnsTime: number; + /** + * The total time to create the connection to the requested domain. + */ + connectionTime: number; + /** + * The time time from when the request was sent until the first byte of the + * response was received. This includes network time as well as server + * processing time. + */ + requestTime: number; + /** + * The `PerformanceNavigationTiming` entry used to determine TTFB (or the + * polyfill entry in browsers that don't support Navigation Timing). + */ + navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; +} + +/** + * A TTFB-specific version of the Metric object with attribution. + */ +export interface TTFBMetricWithAttribution extends TTFBMetric { + attribution: TTFBAttribution; +} + +/** + * A TTFB-specific version of the ReportCallback function. + */ +export interface TTFBReportCallback extends ReportCallback { + (metric: TTFBMetric): void; +} + +/** + * A TTFB-specific version of the ReportCallback function with attribution. + */ +export interface TTFBReportCallbackWithAttribution extends TTFBReportCallback { + (metric: TTFBMetricWithAttribution): void; +} diff --git a/test/e2e/onCLS-test.js b/test/e2e/onCLS-test.js index 9134cb5f..4cbde8e7 100644 --- a/test/e2e/onCLS-test.js +++ b/test/e2e/onCLS-test.js @@ -14,13 +14,13 @@ * limitations under the License. */ -const assert = require('assert'); -const {beaconCountIs, clearBeacons, getBeacons} = require('../utils/beacons.js'); -const {browserSupportsEntry} = require('../utils/browserSupportsEntry.js'); -const {afterLoad} = require('../utils/afterLoad.js'); -const {imagesPainted} = require('../utils/imagesPainted.js'); -const {stubForwardBack} = require('../utils/stubForwardBack.js'); -const {stubVisibilityChange} = require('../utils/stubVisibilityChange.js'); +import assert from 'assert'; +import {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js'; +import {browserSupportsEntry} from '../utils/browserSupportsEntry.js'; +import {domReadyState} from '../utils/domReadyState.js'; +import {imagesPainted} from '../utils/imagesPainted.js'; +import {stubForwardBack} from '../utils/stubForwardBack.js'; +import {stubVisibilityChange} from '../utils/stubVisibilityChange.js'; describe('onCLS()', async function() { @@ -456,7 +456,7 @@ describe('onCLS()', async function() { await browser.url(`/test/cls?noLayoutShifts=1`); // Wait until the page is loaded before hiding. - await afterLoad(); + await domReadyState('complete'); await stubVisibilityChange('hidden'); await beaconCountIs(1); @@ -476,7 +476,7 @@ describe('onCLS()', async function() { await browser.url(`/test/cls?reportAllChanges=1&noLayoutShifts=1`); // Wait until the page is loaded before hiding. - await afterLoad(); + await domReadyState('complete'); await stubVisibilityChange('hidden'); await beaconCountIs(1); @@ -496,7 +496,7 @@ describe('onCLS()', async function() { await browser.url(`/test/cls?noLayoutShifts=1`); // Wait until the page is loaded before navigating away. - await afterLoad(); + await domReadyState('complete'); await browser.url('about:blank'); await beaconCountIs(1); @@ -516,7 +516,7 @@ describe('onCLS()', async function() { await browser.url(`/test/cls?noLayoutShifts=1&reportAllChanges=1`); // Wait until the page is loaded before navigating away. - await afterLoad(); + await domReadyState('complete'); await browser.url('about:blank'); await beaconCountIs(1); @@ -566,6 +566,102 @@ describe('onCLS()', async function() { assert.strictEqual(cls.entries.length, 1); assert.strictEqual(cls.navigationType, 'back_forward_cache'); }); + + describe('attribution', function() { + it('includes attribution data on the metric object', async function() { + if (!browserSupportsCLS) this.skip(); + + await browser.url('/test/cls?attribution=1&delayDCL=2000'); + + // Wait until all images are loaded and rendered, then change to hidden. + await imagesPainted(); + await stubVisibilityChange('hidden'); + + await beaconCountIs(1); + + const [cls] = await getBeacons(); + assert(cls.value >= 0); + assert(cls.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(cls.name, 'CLS'); + assert.strictEqual(cls.value, cls.delta); + assert.strictEqual(cls.entries.length, 2); + assert.match(cls.navigationType, /navigate|reload/); + + const { + largestShiftEntry, + largestShiftSource, + } = getAttribution(cls.entries); + + assert.deepEqual(cls.attribution.largestShiftEntry, largestShiftEntry); + assert.deepEqual(cls.attribution.largestShiftSource, largestShiftSource); + + assert.equal(cls.attribution.largestShiftValue, largestShiftEntry.value); + assert.equal(cls.attribution.largestShiftTarget, '#p3'); + assert.equal( + cls.attribution.largestShiftTime, largestShiftEntry.startTime); + + // The first shift (before the second image loads) is the largest. + assert.match(cls.attribution.loadState, /dom(ContentLoaded|Interactive)/); + }); + + it('reports whether the largest shift was before or after load', async function() { + if (!browserSupportsCLS) this.skip(); + + await browser.url('/test/cls?attribution=1&noLayoutShifts=1'); + + await domReadyState('complete'); + await triggerLayoutShift(); + await stubVisibilityChange('hidden'); + + await beaconCountIs(1); + const [cls] = await getBeacons(); + + assert(cls.value >= 0); + assert(cls.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(cls.name, 'CLS'); + assert.strictEqual(cls.value, cls.delta); + assert.strictEqual(cls.entries.length, 1); + assert.match(cls.navigationType, /navigate|reload/); + + const { + largestShiftEntry, + largestShiftSource, + } = getAttribution(cls.entries); + + assert.deepEqual(cls.attribution.largestShiftEntry, largestShiftEntry); + assert.deepEqual(cls.attribution.largestShiftSource, largestShiftSource); + + assert.equal(cls.attribution.largestShiftValue, largestShiftEntry.value); + assert.equal(cls.attribution.largestShiftTarget, 'html>body>main>h1'); + assert.equal( + cls.attribution.largestShiftTime, largestShiftEntry.startTime); + + // The first shift (before the second image loads) is the largest. + assert.equal(cls.attribution.loadState, 'loaded'); + }); + + it('reports an empty object when no shifts', async function() { + if (!browserSupportsCLS) this.skip(); + + await browser.url('/test/cls?attribution=1&noLayoutShifts=1'); + + // Wait until the page is loaded before navigating away. + await domReadyState('complete'); + await stubVisibilityChange('hidden'); + + await beaconCountIs(1); + const [cls] = await getBeacons(); + + assert(cls.value >= 0); + assert(cls.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(cls.name, 'CLS'); + assert.strictEqual(cls.value, cls.delta); + assert.strictEqual(cls.entries.length, 0); + assert.match(cls.navigationType, /navigate|reload/); + + assert.deepEqual(cls.attribution, {}); + }); + }); }); let marginTop = 0; @@ -580,3 +676,24 @@ function triggerLayoutShift() { document.querySelector('h1').style.marginTop = marginTop + 'em'; }, ++marginTop); } + +/** + * + * @param {Array} entries + * @return {Object} + */ +function getAttribution(entries) { + let largestShiftEntry; + for (const entry of entries) { + if (!largestShiftEntry || entry.value > largestShiftEntry.value) { + largestShiftEntry = entry; + } + } + + const largestShiftSource = largestShiftEntry.sources.find((source) => { + return source.node !== '#text'; + }); + + return {largestShiftEntry, largestShiftSource}; +} + diff --git a/test/e2e/onFCP-test.js b/test/e2e/onFCP-test.js index 0de0356d..be83557f 100644 --- a/test/e2e/onFCP-test.js +++ b/test/e2e/onFCP-test.js @@ -14,11 +14,13 @@ * limitations under the License. */ -const assert = require('assert'); -const {beaconCountIs, clearBeacons, getBeacons} = require('../utils/beacons.js'); -const {browserSupportsEntry} = require('../utils/browserSupportsEntry.js'); -const {stubForwardBack} = require('../utils/stubForwardBack.js'); -const {stubVisibilityChange} = require('../utils/stubVisibilityChange.js'); +import assert from 'assert'; +import {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js'; +import {browserSupportsEntry} from '../utils/browserSupportsEntry.js'; +import {domReadyState} from '../utils/domReadyState.js'; +import {stubForwardBack} from '../utils/stubForwardBack.js'; +import {stubVisibilityChange} from '../utils/stubVisibilityChange.js'; + describe('onFCP()', async function() { // Retry all tests in this suite up to 2 times. @@ -96,6 +98,7 @@ describe('onFCP()', async function() { if (!browserSupportsFCP) this.skip(); await browser.url('/test/fcp?hidden=1'); + await domReadyState('interactive'); await stubVisibilityChange('visible'); @@ -169,6 +172,7 @@ describe('onFCP()', async function() { if (!browserSupportsFCP) this.skip(); await browser.url('/test/fcp?hidden=1'); + await domReadyState('interactive'); await stubVisibilityChange('visible'); @@ -204,4 +208,121 @@ describe('onFCP()', async function() { assert.strictEqual(fcp2.entries.length, 0); assert.strictEqual(fcp2.navigationType, 'back_forward_cache'); }); + + describe('attribution', function() { + it('includes attribution data on the metric object', async function() { + if (!browserSupportsFCP) this.skip(); + + await browser.url('/test/fcp?attribution=1'); + + await beaconCountIs(1); + + await domReadyState('complete'); + const navEntry = await browser.execute(() => { + return performance.getEntriesByType('navigation')[0].toJSON(); + }); + const fcpEntry = await browser.execute(() => { + return performance + .getEntriesByName('first-contentful-paint')[0].toJSON(); + }); + + const [fcp] = await getBeacons(); + + assert(fcp.value >= 0); + assert(fcp.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(fcp.name, 'FCP'); + assert.strictEqual(fcp.value, fcp.delta); + assert.strictEqual(fcp.entries.length, 1); + assert.match(fcp.navigationType, /navigate|reload/); + + assert.equal(fcp.attribution.timeToFirstByte, navEntry.responseStart); + assert.equal(fcp.attribution.firstByteToFCP, + fcp.value - navEntry.responseStart); + assert.match(fcp.attribution.loadState, + /load(ing|ed)|dom(Interactive|ContentLoaded)/); + + assert.deepEqual(fcp.attribution.fcpEntry, fcpEntry); + + // When FCP is reported, not all values on the NavigationTiming entry + // are finalized, so just check some keys that should be set before FCP. + const {navigationEntry: attributionNavEntry} = fcp.attribution; + assert.equal(attributionNavEntry.startTime, navEntry.startTime); + assert.equal(attributionNavEntry.fetchStart, navEntry.fetchStart); + assert.equal(attributionNavEntry.requestStart, navEntry.requestStart); + assert.equal(attributionNavEntry.responseStart, navEntry.responseStart); + }); + + it('accounts for time prerendering the page', async function() { + if (!browserSupportsFCP) this.skip(); + + await browser.url('/test/fcp?attribution=1&prerender=1'); + + await beaconCountIs(1); + + await domReadyState('complete'); + const navEntry = await browser.execute(() => { + return performance.getEntriesByType('navigation')[0].toJSON(); + }); + const fcpEntry = await browser.execute(() => { + return performance + .getEntriesByName('first-contentful-paint')[0].toJSON(); + }); + + // Since this value is stubbed in the browser, get it separately. + const activationStart = await browser.execute(() => { + return performance.getEntriesByType('navigation')[0].activationStart; + }); + + const [fcp] = await getBeacons(); + assert(fcp.value >= 0); + assert(fcp.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(fcp.name, 'FCP'); + assert.strictEqual(fcp.value, fcp.delta); + assert.strictEqual(fcp.entries.length, 1); + assert.strictEqual(fcp.navigationType, 'prerender'); + + assert.equal(fcp.attribution.timeToFirstByte, + Math.max(0, navEntry.responseStart - activationStart)); + assert.equal(fcp.attribution.firstByteToFCP, + fcp.value - Math.max(0, navEntry.responseStart - activationStart)); + + assert.deepEqual(fcp.attribution.fcpEntry, fcpEntry); + + // When FCP is reported, not all values on the NavigationTiming entry + // are finalized, so just check some keys that should be set before FCP. + const {navigationEntry: attributionNavEntry} = fcp.attribution; + assert.equal(attributionNavEntry.startTime, navEntry.startTime); + assert.equal(attributionNavEntry.fetchStart, navEntry.fetchStart); + assert.equal(attributionNavEntry.requestStart, navEntry.requestStart); + assert.equal(attributionNavEntry.responseStart, navEntry.responseStart); + }); + + it('reports after a bfcache restore', async function() { + if (!browserSupportsFCP) this.skip(); + + await browser.url('/test/fcp?attribution=1'); + + await beaconCountIs(1); + + await clearBeacons(); + + await domReadyState('complete'); + await stubForwardBack(); + + await beaconCountIs(1); + + const [fcp] = await getBeacons(); + assert(fcp.value >= 0); + assert(fcp.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(fcp.name, 'FCP'); + assert.strictEqual(fcp.value, fcp.delta); + assert.strictEqual(fcp.entries.length, 0); + assert.strictEqual(fcp.navigationType, 'back_forward_cache'); + + assert.equal(fcp.attribution.timeToFirstByte, 0); + assert.equal(fcp.attribution.firstByteToFCP, fcp.value); + assert.equal(fcp.attribution.loadState, 'loaded'); + assert.equal(fcp.attribution.navigationEntry, undefined); + }); + }); }); diff --git a/test/e2e/onFID-test.js b/test/e2e/onFID-test.js index cf1a38ab..629b1e39 100644 --- a/test/e2e/onFID-test.js +++ b/test/e2e/onFID-test.js @@ -14,11 +14,12 @@ * limitations under the License. */ -const assert = require('assert'); -const {beaconCountIs, clearBeacons, getBeacons} = require('../utils/beacons.js'); -const {browserSupportsEntry} = require('../utils/browserSupportsEntry.js'); -const {stubForwardBack} = require('../utils/stubForwardBack.js'); -const {stubVisibilityChange} = require('../utils/stubVisibilityChange.js'); +import assert from 'assert'; +import {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js'; +import {browserSupportsEntry} from '../utils/browserSupportsEntry.js'; +import {domReadyState} from '../utils/domReadyState.js'; +import {stubForwardBack} from '../utils/stubForwardBack.js'; +import {stubVisibilityChange} from '../utils/stubVisibilityChange.js'; describe('onFID()', async function() { @@ -116,6 +117,7 @@ describe('onFID()', async function() { if (browser.capabilities.browserName === 'Safari') this.skip(); await browser.url('/test/fid?hidden=1'); + await domReadyState('interactive'); await stubVisibilityChange('visible'); @@ -187,6 +189,64 @@ describe('onFID()', async function() { assert.strictEqual(fid2.navigationType, 'back_forward_cache'); assert.match(fid2.entries[0].name, /(mouse|pointer)down/); }); + + describe('attribution', function() { + it('includes attribution data on the metric object', async function() { + if (!browserSupportsFID) this.skip(); + + await browser.url('/test/fid?attribution=1'); + + // Click on the

. + const h1 = await $('h1'); + await h1.click(); + + await beaconCountIs(1); + + const [fid] = await getBeacons(); + assert(fid.value >= 0); + assert(fid.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(fid.name, 'FID'); + assert.strictEqual(fid.value, fid.delta); + assert.match(fid.navigationType, /navigate|reload/); + assert.match(fid.entries[0].name, /(mouse|pointer)down/); + + // This value is frequently not set in Chrome for some reason, + // so just check that it's a string. + assert(typeof fid.attribution.eventTarget === 'string'); + assert.equal(fid.attribution.eventTime, fid.entries[0].startTime); + assert.equal(fid.attribution.eventType, fid.entries[0].name); + assert.deepEqual(fid.attribution.eventEntry, fid.entries[0]); + assert.equal(fid.attribution.loadState, 'loaded'); + }); + + it('reports the domReadyState when input occurred', async function() { + if (!browserSupportsFID) this.skip(); + + await browser.url('/test/fid?attribution=1&delayDCL=1000'); + + // Click on the

. + const h1 = await $('h1'); + await h1.click(); + + await beaconCountIs(1); + + const [fid1] = await getBeacons(); + assert.equal(fid1.attribution.loadState, 'domInteractive'); + + await clearBeacons(); + + await browser.url('/test/fid?attribution=1&delayResponse=1000'); + + // Click on the

. + const p = await $('p'); + await p.click(); + + await beaconCountIs(1); + + const [fid2] = await getBeacons(); + assert.equal(fid2.attribution.loadState, 'loading'); + }); + }); }); diff --git a/test/e2e/onINP-test.js b/test/e2e/onINP-test.js index e5f46b79..53a30635 100644 --- a/test/e2e/onINP-test.js +++ b/test/e2e/onINP-test.js @@ -14,11 +14,15 @@ * limitations under the License. */ -const assert = require('assert'); -const {beaconCountIs, clearBeacons, getBeacons} = require('../utils/beacons.js'); -const {browserSupportsEntry} = require('../utils/browserSupportsEntry.js'); -const {stubForwardBack} = require('../utils/stubForwardBack.js'); -const {stubVisibilityChange} = require('../utils/stubVisibilityChange.js'); +import assert from 'assert'; +import {beaconCountIs, clearBeacons, getBeacons} from '../utils/beacons.js'; +import {browserSupportsEntry} from '../utils/browserSupportsEntry.js'; +import {nextFrame} from '../utils/nextFrame.js'; +import {stubForwardBack} from '../utils/stubForwardBack.js'; +import {stubVisibilityChange} from '../utils/stubVisibilityChange.js'; + + +const ROUNDING_ERROR = 8; describe('onINP()', async function() { @@ -253,7 +257,7 @@ describe('onINP()', async function() { assert(inp1.id !== inp2.id); assert.strictEqual(inp2.name, 'INP'); assert.strictEqual(inp2.value, inp2.delta); - assert(containsEntry(inp2.entries, 'keydown', 'textarea')); + assert(containsEntry(inp2.entries, 'keydown', '#textarea')); assert(interactionIDsMatch(inp2.entries)); assert(inp2.entries[0].interactionId > inp1.entries[0].interactionId); assert.strictEqual(inp2.navigationType, 'back_forward_cache'); @@ -266,8 +270,8 @@ describe('onINP()', async function() { const button = await $('button'); await button.click(); - // Pause to ensure the interaction finishes (test is flakey without this). - await browser.pause(500); + // Ensure the interaction completes. + await nextFrame(); await stubVisibilityChange('hidden'); await beaconCountIs(1); @@ -278,13 +282,13 @@ describe('onINP()', async function() { assert(inp1.id !== inp3.id); assert.strictEqual(inp3.name, 'INP'); assert.strictEqual(inp3.value, inp3.delta); - assert(containsEntry(inp3.entries, 'pointerdown', 'button')); + assert(containsEntry(inp3.entries, 'pointerdown', '#reset')); assert(interactionIDsMatch(inp3.entries)); assert(inp3.entries[0].interactionId > inp2.entries[0].interactionId); assert.strictEqual(inp3.navigationType, 'back_forward_cache'); }); - it('does not reports if there were no interactions', async function() { + it('does not report if there were no interactions', async function() { if (!browserSupportsINP) this.skip(); await browser.url('/test/inp'); @@ -297,6 +301,118 @@ describe('onINP()', async function() { const beacons = await getBeacons(); assert.strictEqual(beacons.length, 0); }); + + describe('attribution', function() { + it('includes attribution data on the metric object', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?click=100&attribution=1'); + + const h1 = await $('h1'); + await h1.click(); + + // Ensure the interaction completes. + await nextFrame(); + + await stubVisibilityChange('hidden'); + + await beaconCountIs(1); + + const [inp1] = await getBeacons(); + + assert(inp1.value >= 100 - ROUNDING_ERROR); + assert(inp1.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(inp1.name, 'INP'); + assert.strictEqual(inp1.value, inp1.delta); + assert(containsEntry(inp1.entries, 'click', 'h1')); + assert(interactionIDsMatch(inp1.entries)); + assert(inp1.entries[0].interactionId > 0); + assert.match(inp1.navigationType, /navigate|reload/); + + const clickEntry = inp1.entries.find((e) => e.name === 'click'); + assert.equal(inp1.attribution.eventTarget, 'html>body>main>h1'); + assert.equal(inp1.attribution.eventType, clickEntry.name); + assert.equal(inp1.attribution.eventTime, clickEntry.startTime); + assert.equal(inp1.attribution.loadState, 'loaded'); + + // Deep equal won't work since some of the properties are removed before + // sending to /collect, so just compare some. + const eventEntry1 = inp1.attribution.eventEntry; + assert.equal(eventEntry1.startTime, clickEntry.startTime); + assert.equal(eventEntry1.duration, clickEntry.duration); + assert.equal(eventEntry1.name, clickEntry.name); + assert.equal(eventEntry1.processingStart, clickEntry.processingStart); + + await clearBeacons(); + await setBlockingTime('pointerup', 200); + + await stubVisibilityChange('visible'); + const reset = await $('#reset'); + await reset.click(); + + // Ensure the interaction completes. + await nextFrame(); + + await stubVisibilityChange('hidden'); + await beaconCountIs(1); + + const [inp2] = await getBeacons(); + + assert(inp2.value >= 300 - ROUNDING_ERROR); + assert(inp2.id.match(/^v2-\d+-\d+$/)); + assert.strictEqual(inp2.name, 'INP'); + assert.strictEqual(inp2.value, inp1.value + inp2.delta); + assert(containsEntry(inp2.entries, 'pointerup', '#reset')); + assert(interactionIDsMatch(inp2.entries)); + assert(inp2.entries[0].interactionId > 0); + assert.match(inp2.navigationType, /navigate|reload/); + + const pointerupEntry = inp2.entries.find((e) => e.name === 'pointerup'); + assert.equal(inp2.attribution.eventTarget, '#reset'); + assert.equal(inp2.attribution.eventType, pointerupEntry.name); + assert.equal(inp2.attribution.eventTime, pointerupEntry.startTime); + assert.equal(inp2.attribution.loadState, 'loaded'); + + // Deep equal won't work since some of the properties are removed before + // sending to /collect, so just compare some. + const eventEntry2 = inp2.attribution.eventEntry; + assert.equal(eventEntry2.startTime, pointerupEntry.startTime); + assert.equal(eventEntry2.duration, pointerupEntry.duration); + assert.equal(eventEntry2.name, pointerupEntry.name); + assert.equal(eventEntry2.processingStart, pointerupEntry.processingStart); + }); + + it('reports the domReadyState when input occurred', async function() { + if (!browserSupportsINP) this.skip(); + + await browser.url('/test/inp?' + + 'attribution=1&reportAllChanges=1&click=100&delayDCL=1000'); + + // Click on the

. + const h1 = await $('h1'); + await h1.click(); + + await stubVisibilityChange('visible'); + await beaconCountIs(1); + + const [inp1] = await getBeacons(); + assert.equal(inp1.attribution.loadState, 'domInteractive'); + + await clearBeacons(); + + await browser.url('/test/inp?' + + 'attribution=1&reportAllChanges=1&click=100&delayResponse=1000'); + + // Click on the