diff --git a/common/changes/@snowplow/browser-plugin-web-vitals/feature-1189-browser-plugin-web-vitals_2023-05-16-09-13.json b/common/changes/@snowplow/browser-plugin-web-vitals/feature-1189-browser-plugin-web-vitals_2023-05-16-09-13.json new file mode 100644 index 000000000..d60be47e1 --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-web-vitals/feature-1189-browser-plugin-web-vitals_2023-05-16-09-13.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-plugin-web-vitals", + "comment": "Create browser-plugin-web-vitals", + "type": "none" + } + ], + "packageName": "@snowplow/browser-plugin-web-vitals" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 8dbba135f..0b880ba9c 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -126,6 +126,10 @@ "name": "@snowplow/browser-plugin-timezone", "allowedCategories": [ "trackers" ] }, + { + "name": "@snowplow/browser-plugin-web-vitals", + "allowedCategories": [ "trackers" ] + }, { "name": "@snowplow/browser-plugin-youtube-tracking", "allowedCategories": [ "trackers" ] @@ -320,7 +324,7 @@ }, { "name": "rollup-plugin-filesize", - "allowedCategories": [ "trackers" ] + "allowedCategories": [ "plugins", "trackers" ] }, { "name": "rollup-plugin-license", @@ -374,6 +378,10 @@ "name": "wdio-chromedriver-service", "allowedCategories": [ "trackers" ] }, + { + "name": "web-vitals", + "allowedCategories": [ "plugins" ] + }, { "name": "webdriverio", "allowedCategories": [ "trackers" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 2af36d24e..0c3be6f9a 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1178,6 +1178,57 @@ importers: ts-jest: 27.1.3_60149d457e34ffba7d4e845dde6a1263 typescript: 4.6.2 + ../../plugins/browser-plugin-web-vitals: + specifiers: + '@ampproject/rollup-plugin-closure-compiler': ~0.27.0 + '@rollup/plugin-commonjs': ~21.0.2 + '@rollup/plugin-node-resolve': ~13.1.3 + '@snowplow/browser-tracker-core': workspace:* + '@snowplow/tracker-core': workspace:* + '@types/jest': ~27.4.1 + '@types/jsdom': ~16.2.14 + '@typescript-eslint/eslint-plugin': ~5.15.0 + '@typescript-eslint/parser': ~5.15.0 + eslint: ~8.11.0 + jest: ~27.5.1 + jest-environment-jsdom: ~27.5.1 + jest-environment-jsdom-global: ~3.0.0 + jest-standard-reporter: ~2.0.0 + rollup: ~2.70.1 + rollup-plugin-cleanup: ~3.2.1 + rollup-plugin-license: ~2.6.1 + rollup-plugin-terser: ~7.0.2 + rollup-plugin-ts: ~2.0.5 + ts-jest: ~27.1.3 + tslib: ^2.3.1 + typescript: ~4.6.2 + web-vitals: ~3.3.2 + dependencies: + '@snowplow/browser-tracker-core': link:../../libraries/browser-tracker-core + '@snowplow/tracker-core': link:../../libraries/tracker-core + tslib: 2.3.1 + web-vitals: 3.3.2 + devDependencies: + '@ampproject/rollup-plugin-closure-compiler': 0.27.0_rollup@2.70.1 + '@rollup/plugin-commonjs': 21.0.2_rollup@2.70.1 + '@rollup/plugin-node-resolve': 13.1.3_rollup@2.70.1 + '@types/jest': 27.4.1 + '@types/jsdom': 16.2.14 + '@typescript-eslint/eslint-plugin': 5.15.0_f2c49ce7d0e93ebcfdb4b7d25b131b28 + '@typescript-eslint/parser': 5.15.0_eslint@8.11.0+typescript@4.6.2 + eslint: 8.11.0 + jest: 27.5.1 + jest-environment-jsdom: 27.5.1 + jest-environment-jsdom-global: 3.0.0_jest-environment-jsdom@27.5.1 + jest-standard-reporter: 2.0.0 + rollup: 2.70.1 + rollup-plugin-cleanup: 3.2.1_rollup@2.70.1 + rollup-plugin-license: 2.6.1_rollup@2.70.1 + rollup-plugin-terser: 7.0.2_rollup@2.70.1 + rollup-plugin-ts: 2.0.5_rollup@2.70.1+typescript@4.6.2 + ts-jest: 27.1.3_60149d457e34ffba7d4e845dde6a1263 + typescript: 4.6.2 + ../../plugins/browser-plugin-youtube-tracking: specifiers: '@ampproject/rollup-plugin-closure-compiler': ~0.27.0 @@ -2248,6 +2299,7 @@ packages: /@npmcli/move-file/1.1.2: resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} engines: {node: '>=10'} + deprecated: This functionality has been moved to @npmcli/fs dependencies: mkdirp: 1.0.4 rimraf: 3.0.2 @@ -3111,7 +3163,7 @@ packages: resolution: {integrity: sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==} engines: {node: '>= 8.0.0'} dependencies: - debug: 4.3.3 + debug: 4.3.4 depd: 1.1.2 humanize-ms: 1.2.1 transitivePeerDependencies: @@ -6353,7 +6405,7 @@ packages: dev: true /is-typedarray/1.0.0: - resolution: {integrity: sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=} + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} dev: true /is-unicode-supported/1.1.0: @@ -7591,9 +7643,9 @@ packages: dependencies: agentkeepalive: 4.2.1 cacache: 15.3.0 - http-cache-semantics: 4.1.0 + http-cache-semantics: 4.1.1 http-proxy-agent: 4.0.1 - https-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 is-lambda: 1.0.1 lru-cache: 6.0.0 minipass: 3.3.4 @@ -8149,7 +8201,7 @@ packages: dev: true /object-assign/4.1.1: - resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} dev: true @@ -9454,7 +9506,7 @@ packages: engines: {node: '>= 10'} dependencies: agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 socks: 2.7.0 transitivePeerDependencies: - supports-color @@ -10430,6 +10482,10 @@ packages: - supports-color dev: true + /web-vitals/3.3.2: + resolution: {integrity: sha512-qRkpmSeKfEWAzNhtX541xA8gCJ+pqCqBmUlDVkVDSCSYUvfvNqF+k9g8I+uyreRcDBdfiJrd0/aLbTy5ydo49Q==} + dev: false + /webdriver/8.3.5: resolution: {integrity: sha512-TNlvRtX4OVDzkrLSH5vAVpzTRbpOEYKyU80S8wVx9OddTFgs4fc4SLEqZobdSc1W/IvKjoTNa8TkFx9M237tOw==} engines: {node: ^16.13 || >=18} diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index be8927900..29af959f0 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "1b4af3f37be662be92115c24013c6b260e74acc8", + "pnpmShrinkwrapHash": "ef90a45d902f5b23bb18e3992eb4217a796363bf", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/plugins/browser-plugin-web-vitals/LICENSE b/plugins/browser-plugin-web-vitals/LICENSE new file mode 100644 index 000000000..5b56059dd --- /dev/null +++ b/plugins/browser-plugin-web-vitals/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2023 Snowplow Analytics Ltd +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/plugins/browser-plugin-web-vitals/README.md b/plugins/browser-plugin-web-vitals/README.md new file mode 100644 index 000000000..b49101df0 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/README.md @@ -0,0 +1,68 @@ +# Snowplow Web Vitals Tracking + +[![npm version][npm-image]][npm-url] +[![License][license-image]](LICENSE) + +Browser Plugin to be used with `@snowplow/browser-tracker`. + +The plugin adds the capability to track web performance metrics categorized as [Web Vitals](https://web.dev/vitals/). These metrics are tracked with an event based on the [web_vitals schema](https://github.com/snowplow/iglu-central/blob/master/schemas/com.snowplowanalytics.snowplow/web_vitals/jsonschema/). + +## Maintainer quick start + +Part of the Snowplow JavaScript Tracker monorepo. +Build with [Node.js](https://nodejs.org/en/) (14 or 16) and [Rush](https://rushjs.io/). + +### Setup repository + +```bash +npm install -g @microsoft/rush +git clone https://github.com/snowplow/snowplow-javascript-tracker.git +rush update +``` + +## Package Installation + +With npm: + +```bash +npm install @snowplow/browser-plugin-web-vitals +``` + +## Usage + +Initialize your tracker with the `WebVitalsPlugin`: + +```js +import { newTracker } from '@snowplow/browser-tracker'; +import { WebVitalsPlugin } from '@snowplow/browser-plugin-web-vitals'; + +newTracker('sp1', '{{collector}}', { plugins: [ WebVitalsPlugin(/* pluginOptions */) ] }); + +/* + * Available plugin options `WebVitalsPluginOptions`: + * { + * loadWebVitalsScript: Should the plugin immediately load the Core Web Vitals measurement script from UNPKG CDN. + * webVitalsSource: The URL endpoint the Web Vitals script should be loaded from. Defaults to the UNPKG CDN. + * } + */ +``` + +### Choosing a Web Vitals measurement source + +The default Web Vitals measurement script is loaded from the [UNPKG](https://www.unpkg.com/) CDN. This choice is chosen as a default but you should consider your own setup when choosing the script source. Selecting a script source from a CDN which might already be used in your website might save you from yet another connection startup time (_Queueing_,_DNS lookup_,_TCP_, _SSL_). + +Another reasonable choice could be [jsDelivr](https://cdn.jsdelivr.net/npm/web-vitals@3/dist/web-vitals.iife.js). + +## Copyright and license + +Licensed and distributed under the [BSD 3-Clause License](LICENSE) ([An OSI Approved License][osi]). + +Copyright (c) 2023 Snowplow Analytics Ltd. + +All rights reserved. + +[npm-url]: https://www.npmjs.com/package/@snowplow/browser-plugin-web-vitals +[npm-image]: https://img.shields.io/npm/v/@snowplow/browser-plugin-web-vitals +[docs]: https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-tracker/ +[osi]: https://opensource.org/licenses/BSD-3-Clause +[license-image]: https://img.shields.io/npm/l/@snowplow/browser-plugin-web-vitals diff --git a/plugins/browser-plugin-web-vitals/jest.config.js b/plugins/browser-plugin-web-vitals/jest.config.js new file mode 100644 index 000000000..bd3ea4e2a --- /dev/null +++ b/plugins/browser-plugin-web-vitals/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-web-vitals/package.json b/plugins/browser-plugin-web-vitals/package.json new file mode 100644 index 000000000..7aecdaf3b --- /dev/null +++ b/plugins/browser-plugin-web-vitals/package.json @@ -0,0 +1,54 @@ +{ + "name": "@snowplow/browser-plugin-web-vitals", + "version": "3.11.0", + "description": "Adds the capability to track web performance metrics categorized as Web Vitals.", + "homepage": "https://github.com/snowplow/snowplow-javascript-tracker", + "bugs": "https://github.com/snowplow/snowplow-javascript-tracker/issues", + "repository": { + "type": "git", + "url": "https://github.com/snowplow/snowplow-javascript-tracker.git" + }, + "license": "BSD-3-Clause", + "author": "Snowplow Analytics Ltd (https://snowplow.io/)", + "sideEffects": false, + "main": "./dist/index.umd.js", + "module": "./dist/index.module.js", + "types": "./dist/index.module.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c --silent --failAfterWarnings", + "test": "jest" + }, + "dependencies": { + "@snowplow/browser-tracker-core": "workspace:*", + "@snowplow/tracker-core": "workspace:*", + "tslib": "^2.3.1", + "web-vitals": "~3.3.2" + }, + "devDependencies": { + "@ampproject/rollup-plugin-closure-compiler": "~0.27.0", + "@rollup/plugin-commonjs": "~21.0.2", + "@rollup/plugin-node-resolve": "~13.1.3", + "@types/jest": "~27.4.1", + "@types/jsdom": "~16.2.14", + "@typescript-eslint/eslint-plugin": "~5.15.0", + "@typescript-eslint/parser": "~5.15.0", + "eslint": "~8.11.0", + "jest": "~27.5.1", + "jest-environment-jsdom": "~27.5.1", + "jest-environment-jsdom-global": "~3.0.0", + "jest-standard-reporter": "~2.0.0", + "rollup": "~2.70.1", + "rollup-plugin-cleanup": "~3.2.1", + "rollup-plugin-license": "~2.6.1", + "rollup-plugin-terser": "~7.0.2", + "rollup-plugin-ts": "~2.0.5", + "ts-jest": "~27.1.3", + "typescript": "~4.6.2" + }, + "peerDependencies": { + "@snowplow/browser-tracker": "~3.11.0" + } +} diff --git a/plugins/browser-plugin-web-vitals/rollup.config.js b/plugins/browser-plugin-web-vitals/rollup.config.js new file mode 100644 index 000000000..48d2287f5 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/rollup.config.js @@ -0,0 +1,37 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import ts from 'rollup-plugin-ts'; // Preferred over @rollup/plugin-typescript as it bundles .d.ts files +import { banner } from '../../banner'; +import compiler from '@ampproject/rollup-plugin-closure-compiler'; +import { terser } from 'rollup-plugin-terser'; +import cleanup from 'rollup-plugin-cleanup'; +import pkg from './package.json'; +import { builtinModules } from 'module'; + +const umdPlugins = [nodeResolve({ browser: true }), commonjs(), ts()]; +const umdName = 'snowplowWebVitals'; + +export default [ + // CommonJS (for Node) and ES module (for bundlers) build. + { + input: './src/index.ts', + plugins: [...umdPlugins, banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main, format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + plugins: [...umdPlugins, compiler(), terser(), cleanup({ comments: 'none' }), banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main.replace('.js', '.min.js'), format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + external: [...builtinModules, ...Object.keys(pkg.dependencies), ...Object.keys(pkg.devDependencies)], + plugins: [ + ts(), // so Rollup can convert TypeScript to JavaScript + banner(), + ], + output: [{ file: pkg.module, format: 'es', sourcemap: true }], + }, +]; diff --git a/plugins/browser-plugin-web-vitals/src/index.ts b/plugins/browser-plugin-web-vitals/src/index.ts new file mode 100644 index 000000000..9177d8bba --- /dev/null +++ b/plugins/browser-plugin-web-vitals/src/index.ts @@ -0,0 +1,74 @@ +import { BrowserPlugin, BrowserTracker, dispatchToTrackersInCollection } from '@snowplow/browser-tracker-core'; +import { buildSelfDescribingEvent } from '@snowplow/tracker-core'; +import { WEB_VITALS_SCHEMA } from './schemata'; +import { attachWebVitalsPageListeners, createWebVitalsScript, webVitalsListener } from './utils'; + +const _trackers: Record = {}; +const WEB_VITALS_SOURCE = 'https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js'; +let listenersAttached = false; + +interface WebVitalsPluginOptions { + loadWebVitalsScript?: boolean; + webVitalsSource?: string; +} + +const defaultPluginOptions = { + loadWebVitalsScript: true, + webVitalsSource: WEB_VITALS_SOURCE, +}; + +/** + * Adds Web Vitals measurement events + * + * @param pluginOptions.loadWebVitalsScript - Should the plugin immediately load the Core Web Vitals measurement script from UNPKG CDN. + * @param pluginOptions.webVitalsSource - The URL endpoint the Web Vitals script should be loaded from. Defaults to the UNPKG CDN. + * @remarks + */ +export function WebVitalsPlugin(pluginOptions: WebVitalsPluginOptions = defaultPluginOptions): BrowserPlugin { + const webVitalsObject: Record = {}; + const options = { ...defaultPluginOptions, ...pluginOptions }; + let trackerId: string; + let webVitalsScript: HTMLScriptElement | undefined; + return { + activateBrowserPlugin: (tracker) => { + trackerId = tracker.id; + _trackers[trackerId] = tracker; + + function sendWebVitals() { + if (!Object.keys(webVitalsObject).length) { + return; + } + dispatchToTrackersInCollection(Object.keys(_trackers), _trackers, (t) => { + t.core.track( + buildSelfDescribingEvent({ + event: { + schema: WEB_VITALS_SCHEMA, + data: webVitalsObject, + }, + }) + ); + }); + } + + if (options.loadWebVitalsScript) { + webVitalsScript = createWebVitalsScript(options.webVitalsSource); + } + + /* + * Attach page listeners only once per page. + * Prevent multiple trackers from attaching listeners multiple times. + */ + if (!listenersAttached) { + if (webVitalsScript) { + webVitalsScript.addEventListener('load', () => webVitalsListener(webVitalsObject)); + } else { + webVitalsListener(webVitalsObject); + } + attachWebVitalsPageListeners(sendWebVitals); + listenersAttached = true; + } + + return; + }, + }; +} diff --git a/plugins/browser-plugin-web-vitals/src/schemata.ts b/plugins/browser-plugin-web-vitals/src/schemata.ts new file mode 100644 index 000000000..b71c38fcc --- /dev/null +++ b/plugins/browser-plugin-web-vitals/src/schemata.ts @@ -0,0 +1 @@ +export const WEB_VITALS_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/web_vitals/jsonschema/1-0-0'; diff --git a/plugins/browser-plugin-web-vitals/src/types.ts b/plugins/browser-plugin-web-vitals/src/types.ts new file mode 100644 index 000000000..681b8a70f --- /dev/null +++ b/plugins/browser-plugin-web-vitals/src/types.ts @@ -0,0 +1,12 @@ +import type { ReportCallback, Metric, WebVitalsGlobal, onCLS, onLCP, onFID, onFCP, onINP, onTTFB } from 'web-vitals'; + +export interface WebVitals extends WebVitalsGlobal { + onCLS: typeof onCLS; + onFID: typeof onFID; + onLCP: typeof onLCP; + onFCP: typeof onFCP; + onINP: typeof onINP; + onTTFB: typeof onTTFB; +} + +export { Metric, ReportCallback }; diff --git a/plugins/browser-plugin-web-vitals/src/utils.ts b/plugins/browser-plugin-web-vitals/src/utils.ts new file mode 100644 index 000000000..31916bfee --- /dev/null +++ b/plugins/browser-plugin-web-vitals/src/utils.ts @@ -0,0 +1,72 @@ +import { LOG } from '@snowplow/tracker-core'; +import { ReportCallback, WebVitals } from './types'; + +/** + * Attach page listeners to collect the Web Vitals values + * @param {() => void} callback + */ +export function attachWebVitalsPageListeners(callback: () => void) { + // Safari does not fire "visibilitychange" on the tab close + // So we have 2 options: lose Safari data, or lose LCP/CLS that depends on "visibilitychange" logic. + // Current solution: if LCP/CLS supported, use `onHidden` otherwise, use `pagehide` to fire the callback in the end. + // + // More details: https://github.com/treosh/web-vitals-reporter/issues/3 + const supportedEntryTypes = (PerformanceObserver && PerformanceObserver.supportedEntryTypes) || []; + const isLatestVisibilityChangeSupported = supportedEntryTypes.indexOf('layout-shift') !== -1; + + if (isLatestVisibilityChangeSupported) { + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + callback(); + window.removeEventListener('visibilitychange', onVisibilityChange, true); + } + }; + window.addEventListener('visibilitychange', onVisibilityChange, true); + } else { + window.addEventListener('pagehide', callback, { capture: true, once: true }); + } +} + +/** + * + * @param {string} webVitalsSource Web Vitals script source. + * @returns {string} The script element of the Web Vitals script. Used for attaching listeners on it. + */ +export function createWebVitalsScript(webVitalsSource: string) { + const webVitalsScript = document.createElement('script'); + webVitalsScript.setAttribute('src', webVitalsSource); + webVitalsScript.setAttribute('async', '1'); + webVitalsScript.addEventListener('error', () => { + LOG.error(`Failed to load ${webVitalsSource}`); + }); + + document.head.appendChild(webVitalsScript); + return webVitalsScript; +} + +/** + * + * Adds the Web Vitals measurements on the object used by the trackers to store metric properties. + * @param {Record} webVitalsObject + * @return {void} + */ +export function webVitalsListener(webVitalsObject: Record) { + function addWebVitalsMeasurement(metricSchemaName: string): ReportCallback { + return (arg) => { + webVitalsObject[metricSchemaName] = arg.value; + webVitalsObject.navigationType = arg.navigationType; + }; + } + if (!window.webVitals) { + LOG.warn('The window.webVitals API is currently unavailable. web_vitals events will not be collected.'); + return; + } + + const webVitals = window.webVitals as WebVitals; + webVitals.onCLS(addWebVitalsMeasurement('cls')); + webVitals.onFID(addWebVitalsMeasurement('fid')); + webVitals.onLCP(addWebVitalsMeasurement('lcp')); + webVitals.onFCP(addWebVitalsMeasurement('fcp')); + webVitals.onINP(addWebVitalsMeasurement('inp')); + webVitals.onTTFB(addWebVitalsMeasurement('ttfb')); +} diff --git a/plugins/browser-plugin-web-vitals/test/__snapshots__/web-vitals.test.ts.snap b/plugins/browser-plugin-web-vitals/test/__snapshots__/web-vitals.test.ts.snap new file mode 100644 index 000000000..5eb575c50 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/test/__snapshots__/web-vitals.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Web Vitals plugin Returns values for Web Vitals properties 1`] = ` +Object { + "data": Object { + "cls": 0.01, + "fcp": 0.01, + "fid": 0.01, + "inp": 0.01, + "lcp": 0.01, + "navigationType": "navigation", + "ttfb": 0.01, + }, + "schema": "iglu:com.snowplowanalytics.snowplow/web_vitals/jsonschema/1-0-0", +} +`; diff --git a/plugins/browser-plugin-web-vitals/test/web-vitals.test.ts b/plugins/browser-plugin-web-vitals/test/web-vitals.test.ts new file mode 100644 index 000000000..9f5376957 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/test/web-vitals.test.ts @@ -0,0 +1,53 @@ +import { JSDOM } from 'jsdom'; +import { trackerCore } from '@snowplow/tracker-core'; +import { WebVitalsPlugin } from '../src'; +import { BrowserTracker } from '@snowplow/browser-tracker-core'; + +declare var jsdom: JSDOM; + +// @ts-expect-error +jsdom.window.webVitals = {}; + +describe('Web Vitals plugin', () => { + function webVitalsCallback(callback: any) { + callback({ + value: 0.01, + navigationType: 'navigation', + }); + return; + } + it('Returns values for Web Vitals properties', (done) => { + Object.defineProperty(jsdom.window, 'PerformanceObserver', { value: jest.fn() }); + Object.defineProperty(jsdom.window.webVitals, 'onCLS', { + value: webVitalsCallback, + }); + Object.defineProperty(jsdom.window.webVitals, 'onLCP', { + value: webVitalsCallback, + }); + Object.defineProperty(jsdom.window.webVitals, 'onFCP', { + value: webVitalsCallback, + }); + Object.defineProperty(jsdom.window.webVitals, 'onFID', { + value: webVitalsCallback, + }); + Object.defineProperty(jsdom.window.webVitals, 'onINP', { + value: webVitalsCallback, + }); + Object.defineProperty(jsdom.window.webVitals, 'onTTFB', { + value: webVitalsCallback, + }); + + const core = trackerCore({ + corePlugins: [], + callback: (payloadBuilder) => { + const { data } = payloadBuilder.getJson()[0].json; + expect(data).toMatchSnapshot(); + done(); + }, + }); + + WebVitalsPlugin({ loadWebVitalsScript: false }).activateBrowserPlugin?.({ core } as BrowserTracker); + const pagehideEvent = new PageTransitionEvent('pagehide'); + jsdom.window.dispatchEvent(pagehideEvent); + }); +}); diff --git a/plugins/browser-plugin-web-vitals/tsconfig.json b/plugins/browser-plugin-web-vitals/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/rush.json b/rush.json index a317c0c6c..a645f1f6b 100644 --- a/rush.json +++ b/rush.json @@ -547,6 +547,12 @@ "projectFolder": "plugins/browser-plugin-performance-navigation-timing", "reviewCategory": "plugins", "versionPolicyName": "tracker" + }, + { + "packageName": "@snowplow/browser-plugin-web-vitals", + "projectFolder": "plugins/browser-plugin-web-vitals", + "reviewCategory": "plugins", + "versionPolicyName": "tracker" } ] }