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..b6b1f485b 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" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 2af36d24e..a6c76cb01 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1178,6 +1178,55 @@ 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 + dependencies: + '@snowplow/browser-tracker-core': link:../../libraries/browser-tracker-core + '@snowplow/tracker-core': link:../../libraries/tracker-core + tslib: 2.3.1 + 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 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index be8927900..ddaa6e101 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": "03bb3208fd300524f775e8f190e9d7b59b2c691e", "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..f449f4731 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/README.md @@ -0,0 +1,62 @@ +# 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. + * } + */ +``` + +## 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..5fc967d13 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/package.json @@ -0,0 +1,53 @@ +{ + "name": "@snowplow/browser-plugin-web-vitals", + "version": "3.10.1", + "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" + }, + "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.9.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..75e2c49b2 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/src/index.ts @@ -0,0 +1,129 @@ +import { BrowserPlugin, BrowserTracker, addEventListener } from '@snowplow/browser-tracker-core'; +import { ReportCallback } from './types'; +import { WEB_VITALS_SCHEMA } from './schemata'; +import { dispatchToTrackersInCollection } from '@snowplow/browser-tracker-core'; +import { LOG, buildSelfDescribingEvent } from '@snowplow/tracker-core'; + +const _trackers: Record = {}; + +declare global { + interface Window { + webVitals: { + onCLS(cb: ReportCallback): void; + onFID(cb: ReportCallback): void; + onLCP(cb: ReportCallback): void; + onFCP(cb: ReportCallback): void; + onINP(cb: ReportCallback): void; + onTTFB(cb: ReportCallback): void; + }; + } +} + +const WEB_VITALS_SOURCE = 'https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js'; + +interface WebVitalsPluginOptions { + loadWebVitalsScript?: boolean; + webVitalsSource?: string; +} + +const defaultPluginOptions = { + loadWebVitalsScript: true, + webVitalsSource: WEB_VITALS_SOURCE, +}; + +/* Adds the web vitals measurements on the object used by the tracker. */ +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; + } + window.webVitals.onCLS(addWebVitalsMeasurement('cls')); + window.webVitals.onFID(addWebVitalsMeasurement('fid')); + window.webVitals.onLCP(addWebVitalsMeasurement('lcp')); + window.webVitals.onFCP(addWebVitalsMeasurement('fcp')); + window.webVitals.onINP(addWebVitalsMeasurement('inp')); + window.webVitals.onTTFB(addWebVitalsMeasurement('ttfb')); +} + +/** + * 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. + * @remarks + */ +export function WebVitalsPlugin(pluginOptions: WebVitalsPluginOptions = defaultPluginOptions): BrowserPlugin { + const webVitalsObject: Record = {}; + const options = { ...defaultPluginOptions, ...pluginOptions }; + let trackerId: string; + return { + activateBrowserPlugin: (tracker) => { + trackerId = tracker.id; + _trackers[trackerId] = tracker; + + if (options.loadWebVitalsScript) { + const webVitalsScript = document.createElement('script'); + webVitalsScript.setAttribute('src', options.webVitalsSource); + webVitalsScript.setAttribute('async', '1'); + addEventListener( + webVitalsScript, + 'error', + () => { + LOG.warn(`Failed to load ${options.webVitalsSource}`); + }, + true + ); + + addEventListener(webVitalsScript, 'load', () => webVitalsListener(webVitalsObject), true); + + document.head.appendChild(webVitalsScript); + } else { + webVitalsListener(webVitalsObject); + } + + function sendValues() { + if (!Object.keys(webVitalsObject).length) { + return; + } + dispatchToTrackersInCollection(Object.keys(_trackers), _trackers, (t) => { + t.core.track( + buildSelfDescribingEvent({ + event: { + schema: WEB_VITALS_SCHEMA, + data: webVitalsObject, + }, + }) + ); + }); + } + + // 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') { + sendValues(); + removeEventListener('visibilitychange', onVisibilityChange, true); + } + }; + window.addEventListener('visibilitychange', onVisibilityChange, true); + } else { + window.addEventListener('pagehide', sendValues, { capture: true, once: 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..b5c227823 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/src/types.ts @@ -0,0 +1,29 @@ +export interface ReportCallback { + (metric: Metric): void; +} + +/** + * @interface Metric + * Partial metric interface from https://github.com/GoogleChrome/web-vitals + */ +interface Metric { + /** + * The name of the metric (in acronym form). + */ + name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; + + value: 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; + navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore'; +} 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..9a3556350 --- /dev/null +++ b/plugins/browser-plugin-web-vitals/test/web-vitals.test.ts @@ -0,0 +1,56 @@ +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, '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" } ] }