From 6aa80b848133ab724d7c0686894e34baa71768db Mon Sep 17 00:00:00 2001
From: Peter Perlepes
Date: Mon, 1 May 2023 16:02:24 +0300
Subject: [PATCH] Add browser-plugin-web-vitals to track Web Vital metrics
(close #1189)
---
...er-plugin-web-vitals_2023-05-16-09-13.json | 10 +++
.../rush/browser-approved-packages.json | 10 ++-
common/config/rush/pnpm-lock.yaml | 55 ++++++++++++++
common/config/rush/repo-state.json | 2 +-
plugins/browser-plugin-web-vitals/LICENSE | 29 ++++++++
plugins/browser-plugin-web-vitals/README.md | 68 +++++++++++++++++
.../browser-plugin-web-vitals/jest.config.js | 5 ++
.../browser-plugin-web-vitals/package.json | 54 ++++++++++++++
.../rollup.config.js | 37 ++++++++++
.../browser-plugin-web-vitals/src/index.ts | 74 +++++++++++++++++++
.../browser-plugin-web-vitals/src/schemata.ts | 1 +
.../browser-plugin-web-vitals/src/types.ts | 12 +++
.../browser-plugin-web-vitals/src/utils.ts | 72 ++++++++++++++++++
.../__snapshots__/web-vitals.test.ts.snap | 16 ++++
.../test/web-vitals.test.ts | 53 +++++++++++++
.../browser-plugin-web-vitals/tsconfig.json | 3 +
rush.json | 6 ++
17 files changed, 505 insertions(+), 2 deletions(-)
create mode 100644 common/changes/@snowplow/browser-plugin-web-vitals/feature-1189-browser-plugin-web-vitals_2023-05-16-09-13.json
create mode 100644 plugins/browser-plugin-web-vitals/LICENSE
create mode 100644 plugins/browser-plugin-web-vitals/README.md
create mode 100644 plugins/browser-plugin-web-vitals/jest.config.js
create mode 100644 plugins/browser-plugin-web-vitals/package.json
create mode 100644 plugins/browser-plugin-web-vitals/rollup.config.js
create mode 100644 plugins/browser-plugin-web-vitals/src/index.ts
create mode 100644 plugins/browser-plugin-web-vitals/src/schemata.ts
create mode 100644 plugins/browser-plugin-web-vitals/src/types.ts
create mode 100644 plugins/browser-plugin-web-vitals/src/utils.ts
create mode 100644 plugins/browser-plugin-web-vitals/test/__snapshots__/web-vitals.test.ts.snap
create mode 100644 plugins/browser-plugin-web-vitals/test/web-vitals.test.ts
create mode 100644 plugins/browser-plugin-web-vitals/tsconfig.json
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 0d47789ec..9b10465b8 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 f56229ef6..3b6e1215f 100644
--- a/common/config/rush/pnpm-lock.yaml
+++ b/common/config/rush/pnpm-lock.yaml
@@ -1231,6 +1231,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
@@ -10441,6 +10492,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 7fbe4955b..d2fd0cbc9 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": "5484bd61ddb42855b5228699721d75c1d5b9cb25",
+ "pnpmShrinkwrapHash": "1a9deb48a8d2843fb67d9c93ff618d8373ca31c8",
"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 796a51882..f86224086 100644
--- a/rush.json
+++ b/rush.json
@@ -553,6 +553,12 @@
"projectFolder": "plugins/browser-plugin-media",
"reviewCategory": "plugins",
"versionPolicyName": "tracker"
+ },
+ {
+ "packageName": "@snowplow/browser-plugin-web-vitals",
+ "projectFolder": "plugins/browser-plugin-web-vitals",
+ "reviewCategory": "plugins",
+ "versionPolicyName": "tracker"
}
]
}