Skip to content

Commit

Permalink
feat(gatsby-plugin-google-analytics): enable core webvitals tracking (#…
Browse files Browse the repository at this point in the history
…31665)

Co-authored-by: Michal Piechowiak <misiek.piechowiak@gmail.com>
Co-authored-by: Lennart <lekoarts@gmail.com>
  • Loading branch information
3 people committed Jun 15, 2021
1 parent d06d6b5 commit 1ecd6e1
Show file tree
Hide file tree
Showing 19 changed files with 458 additions and 17 deletions.
8 changes: 7 additions & 1 deletion 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 }]]
}
]
}
12 changes: 12 additions & 0 deletions packages/gatsby-plugin-google-analytics/README.md
Expand Up @@ -43,6 +43,8 @@ module.exports = {
sampleRate: 5,
siteSpeedSampleRate: 10,
cookieDomain: "example.com",
// defaults to false
enableWebVitalsTracking: true,
},
},
],
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion packages/gatsby-plugin-google-analytics/package.json
Expand Up @@ -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",
Expand Down
@@ -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`, () => {
Expand Down Expand Up @@ -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`, () => {
Expand Down Expand Up @@ -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()
})
})
})
})
Expand Down
Expand Up @@ -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)
})
})
})
})
Expand Down
73 changes: 71 additions & 2 deletions 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
}
Expand All @@ -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
Expand All @@ -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()
}
}
1 change: 1 addition & 0 deletions packages/gatsby-plugin-google-analytics/src/gatsby-node.js
Expand Up @@ -54,4 +54,5 @@ exports.pluginOptionsSchema = ({ Joi }) =>
queueTime: Joi.number(),
forceSSL: Joi.boolean(),
transport: Joi.string(),
enableWebVitalsTracking: Joi.boolean().default(false),
})
26 changes: 23 additions & 3 deletions packages/gatsby-plugin-google-analytics/src/gatsby-ssr.js
Expand Up @@ -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([
<script
key="gatsby-plugin-google-analytics-web-vitals"
data-gatsby="web-vitals-polyfill"
dangerouslySetInnerHTML={{
__html: `
!function(){var e,t,n,i,r={passive:!0,capture:!0},a=new Date,o=function(){i=[],t=-1,e=null,f(addEventListener)},c=function(i,r){e||(e=r,t=i,n=new Date,f(removeEventListener),u())},u=function(){if(t>=0&&t<n-a){var r={entryType:"first-input",name:e.type,target:e.target,cancelable:e.cancelable,startTime:e.timeStamp,processingStart:e.timeStamp+t};i.forEach((function(e){e(r)})),i=[]}},s=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){c(e,t),a()},i=function(){a()},a=function(){removeEventListener("pointerup",n,r),removeEventListener("pointercancel",i,r)};addEventListener("pointerup",n,r),addEventListener("pointercancel",i,r)}(t,e):c(t,e)}},f=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,s,r)}))},p="hidden"===document.visibilityState?0:1/0;addEventListener("visibilitychange",(function e(t){"hidden"===document.visibilityState&&(p=t.timeStamp,removeEventListener("visibilitychange",e,!0))}),!0);o(),self.webVitals={firstInputPolyfill:function(e){i.push(e),u()},resetFirstInputPolyfill:o,get firstHiddenTime(){return p}}}();
`,
}}
/>,
])
}

inlineScripts.push(
<script
key={`gatsby-plugin-google-analytics`}
dangerouslySetInnerHTML={{
Expand Down Expand Up @@ -134,6 +152,8 @@ export const onRenderBody = (
}, ``)}
}`,
}}
/>,
])
/>
)

return setComponents(inlineScripts)
}
8 changes: 7 additions & 1 deletion packages/gatsby-plugin-google-tagmanager/.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 }]]
}
]
}
14 changes: 14 additions & 0 deletions packages/gatsby-plugin-google-tagmanager/README.md
Expand Up @@ -37,6 +37,8 @@ plugins: [
//
// Defaults to gatsby-route-change
routeChangeEventName: "YOUR_ROUTE_CHANGE_EVENT_NAME",
// Defaults to false
enableWebVitalsTracking: true,
},
},
]
Expand Down Expand Up @@ -80,6 +82,18 @@ This plugin will fire a new event called `gatsby-route-change` (or as in the `ga

This tag will now catch every route change in Gatsby, and you can add Google tag services as you wish to it.

#### Tracking Core Web Vitals

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`, GTM will get ["core-web-vitals"](https://web.dev/vitals/) events with their values.

You can save this data in Google Analytics or any database of your choosing.

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 0.1. or less.

#### Note

Out of the box this plugin will simply load Google Tag Manager on the initial page/app load. It's up to you to fire tags based on changes in your app. See the above "Tracking routes" section for an example.
3 changes: 2 additions & 1 deletion packages/gatsby-plugin-google-tagmanager/package.json
Expand Up @@ -7,7 +7,8 @@
"url": "https://github.com/gatsbyjs/gatsby/issues"
},
"dependencies": {
"@babel/runtime": "^7.14.0"
"@babel/runtime": "^7.14.0",
"web-vitals": "^1.1.2"
},
"devDependencies": {
"@babel/cli": "^7.14.0",
Expand Down

0 comments on commit 1ecd6e1

Please sign in to comment.