diff --git a/packages/gatsby-plugin-google-analytics/.babelrc b/packages/gatsby-plugin-google-analytics/.babelrc index 31043522b2321..7094b00b614c2 100644 --- a/packages/gatsby-plugin-google-analytics/.babelrc +++ b/packages/gatsby-plugin-google-analytics/.babelrc @@ -1,3 +1,9 @@ { - "presets": [["babel-preset-gatsby-package", { "browser": true }]] + "presets": [["babel-preset-gatsby-package"]], + "overrides": [ + { + "test": ["**/gatsby-browser.js"], + "presets": [["babel-preset-gatsby-package", { "browser": true, "esm": true }]] + } + ] } diff --git a/packages/gatsby-plugin-google-analytics/README.md b/packages/gatsby-plugin-google-analytics/README.md index 6c2d36ea8ea45..bc004e8930365 100644 --- a/packages/gatsby-plugin-google-analytics/README.md +++ b/packages/gatsby-plugin-google-analytics/README.md @@ -43,6 +43,8 @@ module.exports = { sampleRate: 5, siteSpeedSampleRate: 10, cookieDomain: "example.com", + // defaults to false + enableWebVitalsTracking: true, }, }, ], @@ -133,6 +135,16 @@ If you need to set up SERVER_SIDE Google Optimize experiment, you can add the ex Besides the experiment ID you also need the variation ID for SERVER_SIDE experiments in Google Optimize. Set 0 for original version. +### `enableWebVitalsTracking` + +Optimizing for the quality of user experience is key to the long-term success of any site on the web. Capturing Real user metrics (RUM) helps you understand the experience of your user/customer. By setting `enableWebVitalsTracking` to `true`, Google Analytics will get ["core-web-vitals"](https://web.dev/vitals/) events with their values. + +We send three metrics: + +- **Largest Contentful Paint (LCP)**: measures loading performance. To provide a good user experience, LCP should occur within 2.5 seconds of when the page first starts loading. +- **First Input Delay (FID)**: measures interactivity. To provide a good user experience, pages should have a FID of 100 milliseconds or less. +- **Cumulative Layout Shift (CLS)**: measures visual stability. To provide a good user experience, pages should maintain a CLS of 1 or less. + ## Optional Fields This plugin supports all optional Create Only Fields documented in [Google Analytics](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#create): diff --git a/packages/gatsby-plugin-google-analytics/package.json b/packages/gatsby-plugin-google-analytics/package.json index f65b25cd0e970..c3b802d28a28d 100644 --- a/packages/gatsby-plugin-google-analytics/package.json +++ b/packages/gatsby-plugin-google-analytics/package.json @@ -8,7 +8,8 @@ }, "dependencies": { "@babel/runtime": "^7.14.0", - "minimatch": "3.0.4" + "minimatch": "3.0.4", + "web-vitals": "^1.1.2" }, "devDependencies": { "@babel/cli": "^7.14.0", diff --git a/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-browser.js b/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-browser.js index 06d2267458d00..c46c88b08e9ed 100644 --- a/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-browser.js +++ b/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-browser.js @@ -1,5 +1,24 @@ -import { onRouteUpdate } from "../gatsby-browser" +import { onInitialClientRender, onRouteUpdate } from "../gatsby-browser" import { Minimatch } from "minimatch" +import { getLCP, getFID, getCLS } from "web-vitals/base" + +jest.mock(`web-vitals/base`, () => { + function createEntry(type, id, value) { + return { name: type, id, value } + } + + return { + getLCP: jest.fn(report => { + report(createEntry(`LCP`, `1`, `300`)) + }), + getFID: jest.fn(report => { + report(createEntry(`FID`, `2`, `150`)) + }), + getCLS: jest.fn(report => { + report(createEntry(`CLS`, `3`, `0.10`)) + }), + } +}) describe(`gatsby-plugin-google-analytics`, () => { describe(`gatsby-browser`, () => { @@ -28,11 +47,12 @@ describe(`gatsby-plugin-google-analytics`, () => { beforeEach(() => { jest.useFakeTimers() + jest.clearAllMocks() window.ga = jest.fn() }) afterEach(() => { - jest.resetAllMocks() + jest.useRealTimers() }) it(`does not send page view when ga is undefined`, () => { @@ -85,6 +105,62 @@ describe(`gatsby-plugin-google-analytics`, () => { expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000) expect(window.ga).toHaveBeenCalledTimes(2) }) + + it(`sends core web vitals when enabled`, async () => { + onInitialClientRender({}, { enableWebVitalsTracking: true }) + + // wait 2 ticks to wait for dynamic import to resolve + await Promise.resolve() + await Promise.resolve() + + jest.runAllTimers() + + expect(window.ga).toBeCalledTimes(3) + expect(window.ga).toBeCalledWith( + `send`, + `event`, + expect.objectContaining({ + eventAction: `LCP`, + eventCategory: `Web Vitals`, + eventLabel: `1`, + eventValue: 300, + }) + ) + expect(window.ga).toBeCalledWith( + `send`, + `event`, + expect.objectContaining({ + eventAction: `FID`, + eventCategory: `Web Vitals`, + eventLabel: `2`, + eventValue: 150, + }) + ) + expect(window.ga).toBeCalledWith( + `send`, + `event`, + expect.objectContaining({ + eventAction: `CLS`, + eventCategory: `Web Vitals`, + eventLabel: `3`, + eventValue: 100, + }) + ) + }) + + it(`sends nothing when web vitals tracking is disabled`, async () => { + onInitialClientRender({}, { enableWebVitalsTracking: false }) + + // wait 2 ticks to wait for dynamic import to resolve + await Promise.resolve() + await Promise.resolve() + + jest.runAllTimers() + + expect(getLCP).not.toBeCalled() + expect(getFID).not.toBeCalled() + expect(getCLS).not.toBeCalled() + }) }) }) }) diff --git a/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-ssr.js b/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-ssr.js index e97a649980078..e5db6037a2b59 100644 --- a/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-ssr.js +++ b/packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-ssr.js @@ -190,6 +190,27 @@ describe(`gatsby-plugin-google-analytics`, () => { expect(result).not.toContain(`defer=1;`) expect(result).toContain(`async=1;`) }) + + it(`adds the web-vitals polyfill to the head`, () => { + const { setHeadComponents } = setup({ + enableWebVitalsTracking: true, + head: false, + }) + + expect(setHeadComponents.mock.calls.length).toBe(2) + expect(setHeadComponents.mock.calls[1][0][0].key).toBe( + `gatsby-plugin-google-analytics-web-vitals` + ) + }) + + it(`should not add the web-vitals polyfill when enableWebVitalsTracking is false `, () => { + const { setHeadComponents } = setup({ + enableWebVitalsTracking: false, + head: false, + }) + + expect(setHeadComponents.mock.calls.length).toBe(1) + }) }) }) }) diff --git a/packages/gatsby-plugin-google-analytics/src/gatsby-browser.js b/packages/gatsby-plugin-google-analytics/src/gatsby-browser.js index 2b73f8e254bc4..172d5eaeef4d0 100644 --- a/packages/gatsby-plugin-google-analytics/src/gatsby-browser.js +++ b/packages/gatsby-plugin-google-analytics/src/gatsby-browser.js @@ -1,4 +1,63 @@ +const listOfMetricsSend = new Set() + +function debounce(fn, timeout) { + let timer = null + + return function (...args) { + if (timer) { + clearTimeout(timer) + } + + timer = setTimeout(fn, timeout, ...args) + } +} + +function sendWebVitals() { + function sendData(data) { + if (listOfMetricsSend.has(data.name)) { + return + } + listOfMetricsSend.add(data.name) + + sendToGoogleAnalytics(data) + } + + return import(`web-vitals/base`).then(({ getLCP, getFID, getCLS }) => { + const debouncedCLS = debounce(sendData, 3000) + // we don't need to debounce FID - we send it when it happens + const debouncedFID = sendData + // LCP can occur multiple times so we debounce it + const debouncedLCP = debounce(sendData, 3000) + + // With the true flag, we measure all previous occurences too, in case we start listening to late. + getCLS(debouncedCLS, true) + getFID(debouncedFID, true) + getLCP(debouncedLCP, true) + }) +} + +function sendToGoogleAnalytics({ name, value, id }) { + window.ga(`send`, `event`, { + eventCategory: `Web Vitals`, + eventAction: name, + // The `id` value will be unique to the current page load. When sending + // multiple values from the same page (e.g. for CLS), Google Analytics can + // compute a total by grouping on this ID (note: requires `eventLabel` to + // be a dimension in your report). + eventLabel: id, + // Google Analytics metrics must be integers, so the value is rounded. + // For CLS the value is first multiplied by 1000 for greater precision + // (note: increase the multiplier for greater precision if needed). + eventValue: Math.round(name === `CLS` ? value * 1000 : value), + // Use a non-interaction event to avoid affecting bounce rate. + nonInteraction: true, + // Use `sendBeacon()` if the browser supports it. + transport: `beacon`, + }) +} + export const onRouteUpdate = ({ location }, pluginOptions = {}) => { + const ga = window.ga if (process.env.NODE_ENV !== `production` || typeof ga !== `function`) { return null } @@ -16,8 +75,8 @@ export const onRouteUpdate = ({ location }, pluginOptions = {}) => { const pagePath = location ? location.pathname + location.search + location.hash : undefined - window.ga(`set`, `page`, pagePath) - window.ga(`send`, `pageview`) + ga(`set`, `page`, pagePath) + ga(`send`, `pageview`) } // Minimum delay for reactHelmet's requestAnimationFrame @@ -26,3 +85,13 @@ export const onRouteUpdate = ({ location }, pluginOptions = {}) => { return null } + +export function onInitialClientRender(_, pluginOptions) { + if ( + process.env.NODE_ENV === `production` && + typeof ga === `function` && + pluginOptions.enableWebVitalsTracking + ) { + sendWebVitals() + } +} diff --git a/packages/gatsby-plugin-google-analytics/src/gatsby-node.js b/packages/gatsby-plugin-google-analytics/src/gatsby-node.js index bbc57071e63f6..ffabe694025f2 100644 --- a/packages/gatsby-plugin-google-analytics/src/gatsby-node.js +++ b/packages/gatsby-plugin-google-analytics/src/gatsby-node.js @@ -54,4 +54,5 @@ exports.pluginOptionsSchema = ({ Joi }) => queueTime: Joi.number(), forceSSL: Joi.boolean(), transport: Joi.string(), + enableWebVitalsTracking: Joi.boolean().default(false), }) diff --git a/packages/gatsby-plugin-google-analytics/src/gatsby-ssr.js b/packages/gatsby-plugin-google-analytics/src/gatsby-ssr.js index 1afa395dc63b6..6eeb8b3ab1fea 100644 --- a/packages/gatsby-plugin-google-analytics/src/gatsby-ssr.js +++ b/packages/gatsby-plugin-google-analytics/src/gatsby-ssr.js @@ -66,7 +66,25 @@ export const onRenderBody = ( const setComponents = pluginOptions.head ? setHeadComponents : setPostBodyComponents - return setComponents([ + + const inlineScripts = [] + if (pluginOptions.enableWebVitalsTracking) { + // web-vitals/polyfill (necessary for non chromium browsers) + // @seehttps://www.npmjs.com/package/web-vitals#how-the-polyfill-works + setHeadComponents([ +