diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..eef7762 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +**/*.min.js +**/node_modules/** +**/vendor/** +**/js/dist/**/*.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100755 index 0000000..e2cb683 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,48 @@ +{ + "extends": [ + "plugin:@wordpress/eslint-plugin/recommended", + "prettier" + ], + "rules": { + "@wordpress/i18n-text-domain": [ + "error", + { + "allowedTextDomain": [ "xcurrent" ] + } + ], + "prettier/prettier": [ + "error", + { + "singleQuote": true, + "parenSpacing": true, + "trailingComma": "es5", + "jsxBracketSameLine": false, + "arrowParens": "avoid" + } + ] + }, + "overrides": [ + { + "files":[ + "**/__tests__/**/*.js", + "**/test/*.js", + "**/?(*.)test.js", + "tests/js/**/*.js" + ], + "extends": [ + "plugin:jest/all" + ], + "rules": { + "jest/lowercase-name": [ + "error", + { + "ignore": [ "describe" ] + } + ], + "jest/no-hooks": "off", + "jest/prefer-expect-assertions": "off", + "jest/prefer-inline-snapshots": "off" + } + } + ] +} diff --git a/README.md b/README.md index 30f5de1..05ba1ae 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,42 @@ Filter the [entry types](https://developer.mozilla.org/en-US/docs/Web/API/Perfor apply_filters( 'site_performance_tracker_event_types', array $entry_types = [ 'paint', 'navigation', 'mark' ] ); ``` +##### Enable web vitals tracking + +To send web vitals to Google Analytics in a format compatible with the [Web Vitals Report](https://web-vitals-report.web.app/), enable the following theme support and passing in the ID, both UA- and G- ID formats are supported: + +Analytics is suppored, requires passing the ID using `ga_id`: +```php +add_theme_support( 'site_performance_tracker_vitals', array( + 'ga_id' => 'UA-XXXXXXXX-Y', +) ); +``` +Gtag is suppored, requires passing the ID using `gtag_id`: +```php +add_theme_support( 'site_performance_tracker_vitals', array( + 'gtag_id' => 'UA-XXXXXXXX-Y', +) ); +``` + +If you need to override the Google Analytics dimensions (defaults to dimensions1 through 6) to store these under, pass them along on the add theme support initialisation: +```php +add_theme_support( 'site_performance_tracker_vitals', array( + 'gtag_id' => 'UA-XXXXXXXX-Y', + 'measurementVersion' => 'dimension7', + 'clientId' => 'dimension8', + 'segments' => 'dimension9', + 'config' => 'dimension10', + 'eventMeta' => 'dimension11', + 'eventDebug' => 'dimension12', +) ); +``` + ## Changelog +#### 0.5 - April 13, 2021 + +* Feature: Add support for sending data in the web vitals report format. + #### 0.3.1 - March 11, 2020 * Feature: Add support to Analytics added through Google Tag Managere. diff --git a/js/dist/module/web-vitals-analytics.asset.php b/js/dist/module/web-vitals-analytics.asset.php new file mode 100644 index 0000000..6560129 --- /dev/null +++ b/js/dist/module/web-vitals-analytics.asset.php @@ -0,0 +1 @@ + array('wp-polyfill'), 'version' => '545643e091ef707f7501413bf114f4ab'); \ No newline at end of file diff --git a/js/dist/module/web-vitals-analytics.js b/js/dist/module/web-vitals-analytics.js new file mode 100644 index 0000000..1306ce9 --- /dev/null +++ b/js/dist/module/web-vitals-analytics.js @@ -0,0 +1 @@ +!function(e){var t={};function n(i){if(t[i])return t[i].exports;var a=t[i]={i:i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(i,a,function(t){return e[t]}.bind(null,a));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){"use strict";n.r(t),n.d(t,"measureWebVitals",(function(){return M})),n.d(t,"initAnalytics",(function(){return I}));var i,a,o,r,s=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v1-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},u=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},d=function(e){addEventListener("pageshow",(function(t){t.persisted&&e(t)}),!0)},l="function"==typeof WeakSet?new WeakSet:new Set,f=function(e,t,n){var i;return function(){t.value>=0&&(n||l.has(t)||"hidden"===document.visibilityState)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},w=-1,v=function(){return"hidden"===document.visibilityState?0:1/0},m=function(){u((function(e){var t=e.timeStamp;w=t}),!0)},p=function(){return w<0&&(w=v(),m(),d((function(){setTimeout((function(){w=v(),m()}),0)}))),{get timeStamp(){return w}}},g={passive:!0,capture:!0},b=new Date,y=function(e,t){i||(i=t,a=e,o=new Date,A(removeEventListener),h())},h=function(){if(a>=0&&a1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){y(e,t),a()},i=function(){a()},a=function(){removeEventListener("pointerup",n,g),removeEventListener("pointercancel",i,g)};addEventListener("pointerup",n,g),addEventListener("pointercancel",i,g)}(t,e):y(t,e)}},A=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,D,g)}))};const V={CLS:[.1,.25],FCP:[1800,3e3],FID:[100,300],LCP:[2500,4e3]},S=window.webVitalsAnalyticsData.measurementVersion?window.webVitalsAnalyticsData.measurementVersion:"dimension1",_=window.webVitalsAnalyticsData.clientId?window.webVitalsAnalyticsData.cliegantId:"dimension2",L=window.webVitalsAnalyticsData.segments?window.webVitalsAnalyticsData.segments:"dimension3",E=window.webVitalsAnalyticsData.config?window.webVitalsAnalyticsData.config:"dimension4",C=window.webVitalsAnalyticsData.eventMeta?window.webVitalsAnalyticsData.eventMeta:"dimension5",P=window.webVitalsAnalyticsData.eventDebug?window.webVitalsAnalyticsData.eventDebug:"dimension6",j=e=>{const t={page_path:location.pathname};return"gtag"===window.webVitalsAnalyticsData.delivery&&Object.assign(t,{transport_type:"beacon",measurement_version:"6"}),e.startsWith("UA-")&&("gtag"===window.webVitalsAnalyticsData.delivery&&Object.assign(t,{custom_map:{[S]:"measurement_version",[_]:"client_id",[L]:"segments",[E]:"config",[C]:"event_meta",[P]:"event_debug",metric1:"report_size"}}),"ga"===window.webVitalsAnalyticsData.delivery&&Object.assign(t,{[S]:"6"})),["config",e,t]};function k(e,t){return e>t[1]?"poor":e>t[0]?"ni":"good"}function O(e){try{let t=e.nodeName.toLowerCase();return"body"===t?"html>body":e.id?`${t}#${e.id}`:(e.className&&e.className.length&&(t+="."+[...e.classList.values()].join(".")),`${O(e.parentElement)}>${t}`)}catch(e){return"(error)"}}function F(e,t=[]){const n=t[0],i=t[t.length-1];switch(e){case"LCP":if(i)return O(i.element);break;case"FID":if(n){const{name:e}=n;return`${e}(${O(n.target)})`}break;case"CLS":if(t.length){const e=t.reduce((e,t)=>e&&e.value>t.value?e:t);if(e&&e.sources){const t=e.sources.reduce((e,t)=>e.node&&e.previousRect.width*e.previousRect.height>t.previousRect.width*t.previousRect.height?e:t);if(t)return O(t.node)}}break;default:return"(not set)"}}function T({name:e,value:t,delta:n,id:i,entries:a}){void 0!==window.webVitalsAnalyticsData.gtag_id&>ag("event",e,{event_category:"Web Vitals",event_label:i,value:Math.round("CLS"===e?1e3*n:n),non_interaction:!0,event_meta:k(t,V[e]),metric_rating:k(t,V[e]),event_debug:F(e,a)}),void 0!==window.webVitalsAnalyticsData.ga_id&&ga("send","event",{eventCategory:"Web Vitals",eventAction:e,eventLabel:i,eventValue:Math.round("CLS"===e?1e3*n:n),nonInteraction:!0,transport:"beacon",[C]:k(t,V[e]),[P]:F(e,a)})}function M(){!function(e,t){var n,i=s("CLS",0),a=function(e){e.hadRecentInput||(i.value+=e.value,i.entries.push(e),n())},o=c("layout-shift",a);o&&(n=f(e,i,t),u((function(){o.takeRecords().map(a),n()})),d((function(){i=s("CLS",0),n=f(e,i,t)})))}(T),function(e,t){var n,i=p(),a=s("FCP"),o=c("paint",(function(e){"first-contentful-paint"===e.name&&(o&&o.disconnect(),e.startTime { + const config = { + page_path: location.pathname, + }; + + if ( 'gtag' === window.webVitalsAnalyticsData.delivery ) { + Object.assign( config, { + transport_type: 'beacon', + measurement_version: '6', + } ); + } + + if ( id.startsWith( 'UA-' ) ) { + // Only gtag suports custom maps. + if ( 'gtag' === window.webVitalsAnalyticsData.delivery ) { + Object.assign( config, { + custom_map: { + [ uaDimMeasurementVersion ]: 'measurement_version', + [ uaDimclientId ]: 'client_id', + [ uaDimSegments ]: 'segments', + [ uaDimConfig ]: 'config', + [ uaDimEventMeta ]: 'event_meta', + [ uaDimEventDebug ]: 'event_debug', + metric1: 'report_size', + }, + } ); + } + + if ( 'ga' === window.webVitalsAnalyticsData.delivery ) { + Object.assign( config, { + [ uaDimMeasurementVersion ]: '6', + } ); + } + } + + return [ 'config', id, config ]; +}; + +function getRating( value, thresholds ) { + if ( value > thresholds[ 1 ] ) { + return 'poor'; + } + if ( value > thresholds[ 0 ] ) { + return 'ni'; + } + return 'good'; +} + +function getNodePath( node ) { + try { + let name = node.nodeName.toLowerCase(); + if ( name === 'body' ) { + return 'html>body'; + } + if ( node.id ) { + return `${ name }#${ node.id }`; + } + if ( node.className && node.className.length ) { + name += `.${ [ ...node.classList.values() ].join( '.' ) }`; + } + return `${ getNodePath( node.parentElement ) }>${ name }`; + } catch ( error ) { + return '(error)'; + } +} + +function getDebugInfo( metricName, entries = [] ) { + const firstEntry = entries[ 0 ]; + const lastEntry = entries[ entries.length - 1 ]; + + switch ( metricName ) { + case 'LCP': + if ( lastEntry ) { + return getNodePath( lastEntry.element ); + } + break; + case 'FID': + if ( firstEntry ) { + const { name } = firstEntry; + return `${ name }(${ getNodePath( firstEntry.target ) })`; + } + break; + case 'CLS': + if ( entries.length ) { + const largestShift = entries.reduce( ( a, b ) => { + return a && a.value > b.value ? a : b; + } ); + if ( largestShift && largestShift.sources ) { + const largestSource = largestShift.sources.reduce( ( a, b ) => { + return a.node && + a.previousRect.width * a.previousRect.height > + b.previousRect.width * b.previousRect.height + ? a + : b; + } ); + if ( largestSource ) { + return getNodePath( largestSource.node ); + } + } + } + break; + default: + return '(not set)'; + } +} + +function sendToGoogleAnalytics( { name, value, delta, id, entries } ) { + if ( 'undefined' !== typeof window.webVitalsAnalyticsData.gtag_id ) { + gtag( 'event', name, { + event_category: 'Web Vitals', + event_label: id, + value: Math.round( name === 'CLS' ? delta * 1000 : delta ), + non_interaction: true, + event_meta: getRating( value, vitalThresholds[ name ] ), + metric_rating: getRating( value, vitalThresholds[ name ] ), + event_debug: getDebugInfo( name, entries ), + } ); + } + if ( 'undefined' !== typeof window.webVitalsAnalyticsData.ga_id ) { + ga( 'send', 'event', { + eventCategory: 'Web Vitals', + eventAction: name, + eventLabel: id, + eventValue: Math.round( name === 'CLS' ? delta * 1000 : delta ), + nonInteraction: true, + transport: 'beacon', + [ uaDimEventMeta ]: getRating( value, vitalThresholds[ name ] ), + [ uaDimEventDebug ]: getDebugInfo( name, entries ), + } ); + } +} + +export function measureWebVitals() { + getCLS( sendToGoogleAnalytics ); + getFCP( sendToGoogleAnalytics ); + getFID( sendToGoogleAnalytics ); + getLCP( sendToGoogleAnalytics ); +} + +export function initAnalytics() { + if ( 'undefined' === typeof window.webVitalsAnalyticsData ) { + return false; + // Do nothing without a config. + } + if ( 'undefined' !== typeof window.webVitalsAnalyticsData.gtag_id ) { + window.webVitalsAnalyticsData.delivery = 'gtag'; + } else if ( 'undefined' !== typeof window.webVitalsAnalyticsData.ga_id ) { + window.webVitalsAnalyticsData.delivery = 'ga'; + } + + if ( 'gtag' === window.webVitalsAnalyticsData.delivery ) { + window.webVitalsAnalyticsData.type = 'gtag'; + if ( 'undefined' === typeof window.gtag ) { + // eslint-disable-next-line no-console + window.gtag = console.log; + } + gtag( 'js', new Date() ); + gtag( ...getConfig( window.webVitalsAnalyticsData.gtag_id ) ); + } + + if ( 'ga' === window.webVitalsAnalyticsData.delivery ) { + if ( 'undefined' === typeof window.ga ) { + // eslint-disable-next-line no-console + window.ga = console.log; + } else { + ga( 'js', new Date() ); + ga( ...getConfig( window.webVitalsAnalyticsData.ga_id ) ); + } + } + + measureWebVitals(); +} + +( function () { + requestIdleCallback( initAnalytics ); +} )(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..2fb7937 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "site-performance-tracker", + "version": "1.0.0", + "description": "Site Performance Tracker", + "author": "XWP", + "license": "GPL-2.0-or-later", + "keywords": [ + "WordPress", + "Plugin" + ], + "engines": { + "node": ">=14.0.0" + }, + "homepage": "https://github.com/xwp/site-performance-tracker#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/xwp/site-performance-tracker.git" + }, + "bugs": { + "url": "https://github.com/xwp/site-performance-tracker/issues" + }, + "browserslist": [ + "last 2 Chrome versions", + "last 2 Firefox versions", + "last 2 Safari versions", + "last 2 Edge versions", + "last 2 iOS versions", + "last 1 Android version", + "last 1 ChromeAndroid version", + "> 2%" + ], + "devDependencies": { + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@wordpress/scripts": "^13.0.3", + "del": "^6.0.0", + "dir-archiver": "^1.1.1", + "gulp": "^4.0.2", + "gulp-rename": "^2.0.0", + "npm-run-all": "^4.1.5", + "webpackbar": "^5.0.0-3" + }, + "scripts": { + "dev:js": "wp-scripts start", + "build:js": "wp-scripts build", + "lint:js": "wp-scripts lint-js", + "format:js": "npm run lint:js -- --fix" + }, + "dependencies": { + "web-vitals": "^1.1.1" + } +} diff --git a/php/Plugin.php b/php/Plugin.php index a1d95cb..4015a3c 100644 --- a/php/Plugin.php +++ b/php/Plugin.php @@ -71,6 +71,16 @@ protected function register_hooks() { add_action( 'wp_footer', array( $this, 'add_before_action_mark' ), - PHP_INT_MAX ); add_action( 'wp_footer', array( $this, 'add_after_action_mark' ), PHP_INT_MAX ); } + + /** + * Load web vitals analytics. + */ + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); + + /** + * Load only for modern browsers + */ + add_filter( 'script_loader_tag', array( $this, 'optimize_scripts' ), 10, 2 ); } /** @@ -215,5 +225,67 @@ public static function get_the_performance_mark( $mark_slug ) { return sprintf( 'performance && performance.mark( %s );', wp_json_encode( 'mark_' . $mark_slug ) ); } + + /** + * Enqueue javascript to trigger web vitals tracking. + */ + public function enqueue_scripts() { + $asset = include plugin_dir_path( __DIR__ ) . '/js/dist/module/web-vitals-analytics.asset.php'; + $vitals_theme_support = get_theme_support( 'site_performance_tracker_vitals' ); + + if ( $vitals_theme_support ) { + // Add to footer. + wp_enqueue_script( + 'web-vitals-analytics', + plugin_dir_url( __DIR__ ) . 'js/dist/module/web-vitals-analytics.js', + array(), + $asset['version'], + true + ); + + $chance = apply_filters( 'site_performance_tracker_chance', $this->default_chance ); + $web_vitals_analytics_data = array(); + if ( isset( $vitals_theme_support[0] ) ) { + $web_vitals_analytics_data = $vitals_theme_support[0]; + } + $web_vitals_analytics_data['chance'] = htmlspecialchars( $chance ); + + wp_localize_script( 'web-vitals-analytics', 'webVitalsAnalyticsData', $web_vitals_analytics_data ); + + $web_vitals_init = "( function () { + if ( 'requestIdleCallback' in window ) { + var randNumber = Math.random(); + if ( randNumber <= window.webVitalsAnalyticsData.chance ) { + requestIdleCallback( function() { + webVitalsAnalyticsScript = document.querySelector( 'script[data-src*=\"web-vitals-analytics.js\"]' ); + webVitalsAnalyticsScript.src = webVitalsAnalyticsScript.dataset.src; + delete webVitalsAnalyticsScript.dataset.src; + } ); + } + } +} )();"; + wp_add_inline_script( 'web-vitals-analytics', $web_vitals_init ); + } + } + + /** + * Optimize script tag attributes. + * + * @param string $tag Tag mark-up. + * @param string $handle Script ID. + * + * @return $tag + */ + public function optimize_scripts( $tag, $handle ) { + if ( 'web-vitals-analytics' !== $handle ) { + return $tag; + } + + // Replaces only the first occurrence of src in the tag. Avoids replacing inside inline scripts. + if ( false !== strpos( $tag, ' src' ) ) { + return substr_replace( $tag, ' type="module" data-src', strpos( $tag, ' src' ), strlen( ' src' ) ); + } + return $tag; + } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 21acd1f..22e2c53 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -4,11 +4,13 @@ - + /vendor/ + */js/dist/* + */node_modules/* diff --git a/site-performance-tracker.php b/site-performance-tracker.php index 6b46679..2c247c4 100644 --- a/site-performance-tracker.php +++ b/site-performance-tracker.php @@ -8,7 +8,7 @@ * Plugin Name: Site Performance Tracker * Plugin URI: https://github.com/xwp/site-performance-tracker * Description: Allows you to detect and track site performance metrics. - * Version: 0.3.3 + * Version: 0.5 * Author: XWP.co * Author URI: https://xwp.co */ @@ -51,6 +51,8 @@ function site_performance_tracker_php_version_text() { // Setup the Composer auto loader for classes. if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { require_once __DIR__ . '/vendor/autoload.php'; +} else { + require_once __DIR__ . '/php/Plugin.php'; } // Load helper functions manually. diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..21d81cd --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ + +// TerserPlugin is bundled in Webpack 5. +// eslint-disable-next-line import/no-extraneous-dependencies +const TerserPlugin = require( 'terser-webpack-plugin' ); +// path is a native Node module +// eslint-disable-next-line import/no-extraneous-dependencies +const path = require( 'path' ); +const WebpackBar = require( 'webpackbar' ); + +const config = { + srcDir: '/js/src/', + distDirModern: 'js/dist/module/', + distDirLegacy: 'js/dist/nomodule/', +}; + +config.modernJsEntries = { + 'web-vitals-analytics': config.srcDir + 'web-vitals-analytics.js', +}; + +config.legacyJsEntries = {}; + +/** + * WordPress dependencies + */ +const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); + +const sharedConfig = { + optimization: { + minimizer: [ + new TerserPlugin( { + parallel: true, + sourceMap: false, + cache: true, + terserOptions: { + output: { + comments: /translators:/i, + }, + }, + extractComments: false, + } ), + ], + }, + plugins: [ ...defaultConfig.plugins ], +}; + +const configureBabelLoader = browserlist => { + return { + test: /\.js$/, + use: { + loader: 'babel-loader', + options: { + babelrc: false, + exclude: [ /core-js/, /regenerator-runtime/ ], + presets: [ + [ + '@babel/preset-env', + { + loose: true, + modules: false, + // debug: true, + corejs: 3, + useBuiltIns: 'usage', + targets: { + browsers: browserlist, + }, + }, + ], + ], + plugins: [ '@babel/plugin-syntax-dynamic-import' ], + }, + }, + }; +}; + +const modernConfig = { + output: { + path: path.join( __dirname, config.distDirModern ), + filename: `[name].js`, + chunkFilename: `[name].js`, + }, + module: { + rules: [ + configureBabelLoader( [ + // The last two versions of each browser, excluding versions + // that don't support