Skip to content

Commit 1ecd6e1

Browse files
wardpeetpiehLekoArts
authored
feat(gatsby-plugin-google-analytics): enable core webvitals tracking (#31665)
Co-authored-by: Michal Piechowiak <[email protected]> Co-authored-by: Lennart <[email protected]>
1 parent d06d6b5 commit 1ecd6e1

File tree

19 files changed

+458
-17
lines changed

19 files changed

+458
-17
lines changed
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
{
2-
"presets": [["babel-preset-gatsby-package", { "browser": true }]]
2+
"presets": [["babel-preset-gatsby-package"]],
3+
"overrides": [
4+
{
5+
"test": ["**/gatsby-browser.js"],
6+
"presets": [["babel-preset-gatsby-package", { "browser": true, "esm": true }]]
7+
}
8+
]
39
}

packages/gatsby-plugin-google-analytics/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ module.exports = {
4343
sampleRate: 5,
4444
siteSpeedSampleRate: 10,
4545
cookieDomain: "example.com",
46+
// defaults to false
47+
enableWebVitalsTracking: true,
4648
},
4749
},
4850
],
@@ -133,6 +135,16 @@ If you need to set up SERVER_SIDE Google Optimize experiment, you can add the ex
133135

134136
Besides the experiment ID you also need the variation ID for SERVER_SIDE experiments in Google Optimize. Set 0 for original version.
135137

138+
### `enableWebVitalsTracking`
139+
140+
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.
141+
142+
We send three metrics:
143+
144+
- **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.
145+
- **First Input Delay (FID)**: measures interactivity. To provide a good user experience, pages should have a FID of 100 milliseconds or less.
146+
- **Cumulative Layout Shift (CLS)**: measures visual stability. To provide a good user experience, pages should maintain a CLS of 1 or less.
147+
136148
## Optional Fields
137149

138150
This plugin supports all optional Create Only Fields documented in [Google Analytics](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#create):

packages/gatsby-plugin-google-analytics/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
},
99
"dependencies": {
1010
"@babel/runtime": "^7.14.0",
11-
"minimatch": "3.0.4"
11+
"minimatch": "3.0.4",
12+
"web-vitals": "^1.1.2"
1213
},
1314
"devDependencies": {
1415
"@babel/cli": "^7.14.0",

packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-browser.js

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
1-
import { onRouteUpdate } from "../gatsby-browser"
1+
import { onInitialClientRender, onRouteUpdate } from "../gatsby-browser"
22
import { Minimatch } from "minimatch"
3+
import { getLCP, getFID, getCLS } from "web-vitals/base"
4+
5+
jest.mock(`web-vitals/base`, () => {
6+
function createEntry(type, id, value) {
7+
return { name: type, id, value }
8+
}
9+
10+
return {
11+
getLCP: jest.fn(report => {
12+
report(createEntry(`LCP`, `1`, `300`))
13+
}),
14+
getFID: jest.fn(report => {
15+
report(createEntry(`FID`, `2`, `150`))
16+
}),
17+
getCLS: jest.fn(report => {
18+
report(createEntry(`CLS`, `3`, `0.10`))
19+
}),
20+
}
21+
})
322

423
describe(`gatsby-plugin-google-analytics`, () => {
524
describe(`gatsby-browser`, () => {
@@ -28,11 +47,12 @@ describe(`gatsby-plugin-google-analytics`, () => {
2847

2948
beforeEach(() => {
3049
jest.useFakeTimers()
50+
jest.clearAllMocks()
3151
window.ga = jest.fn()
3252
})
3353

3454
afterEach(() => {
35-
jest.resetAllMocks()
55+
jest.useRealTimers()
3656
})
3757

3858
it(`does not send page view when ga is undefined`, () => {
@@ -85,6 +105,62 @@ describe(`gatsby-plugin-google-analytics`, () => {
85105
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000)
86106
expect(window.ga).toHaveBeenCalledTimes(2)
87107
})
108+
109+
it(`sends core web vitals when enabled`, async () => {
110+
onInitialClientRender({}, { enableWebVitalsTracking: true })
111+
112+
// wait 2 ticks to wait for dynamic import to resolve
113+
await Promise.resolve()
114+
await Promise.resolve()
115+
116+
jest.runAllTimers()
117+
118+
expect(window.ga).toBeCalledTimes(3)
119+
expect(window.ga).toBeCalledWith(
120+
`send`,
121+
`event`,
122+
expect.objectContaining({
123+
eventAction: `LCP`,
124+
eventCategory: `Web Vitals`,
125+
eventLabel: `1`,
126+
eventValue: 300,
127+
})
128+
)
129+
expect(window.ga).toBeCalledWith(
130+
`send`,
131+
`event`,
132+
expect.objectContaining({
133+
eventAction: `FID`,
134+
eventCategory: `Web Vitals`,
135+
eventLabel: `2`,
136+
eventValue: 150,
137+
})
138+
)
139+
expect(window.ga).toBeCalledWith(
140+
`send`,
141+
`event`,
142+
expect.objectContaining({
143+
eventAction: `CLS`,
144+
eventCategory: `Web Vitals`,
145+
eventLabel: `3`,
146+
eventValue: 100,
147+
})
148+
)
149+
})
150+
151+
it(`sends nothing when web vitals tracking is disabled`, async () => {
152+
onInitialClientRender({}, { enableWebVitalsTracking: false })
153+
154+
// wait 2 ticks to wait for dynamic import to resolve
155+
await Promise.resolve()
156+
await Promise.resolve()
157+
158+
jest.runAllTimers()
159+
160+
expect(getLCP).not.toBeCalled()
161+
expect(getFID).not.toBeCalled()
162+
expect(getCLS).not.toBeCalled()
163+
})
88164
})
89165
})
90166
})

packages/gatsby-plugin-google-analytics/src/__tests__/gatsby-ssr.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,27 @@ describe(`gatsby-plugin-google-analytics`, () => {
190190
expect(result).not.toContain(`defer=1;`)
191191
expect(result).toContain(`async=1;`)
192192
})
193+
194+
it(`adds the web-vitals polyfill to the head`, () => {
195+
const { setHeadComponents } = setup({
196+
enableWebVitalsTracking: true,
197+
head: false,
198+
})
199+
200+
expect(setHeadComponents.mock.calls.length).toBe(2)
201+
expect(setHeadComponents.mock.calls[1][0][0].key).toBe(
202+
`gatsby-plugin-google-analytics-web-vitals`
203+
)
204+
})
205+
206+
it(`should not add the web-vitals polyfill when enableWebVitalsTracking is false `, () => {
207+
const { setHeadComponents } = setup({
208+
enableWebVitalsTracking: false,
209+
head: false,
210+
})
211+
212+
expect(setHeadComponents.mock.calls.length).toBe(1)
213+
})
193214
})
194215
})
195216
})

packages/gatsby-plugin-google-analytics/src/gatsby-browser.js

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,63 @@
1+
const listOfMetricsSend = new Set()
2+
3+
function debounce(fn, timeout) {
4+
let timer = null
5+
6+
return function (...args) {
7+
if (timer) {
8+
clearTimeout(timer)
9+
}
10+
11+
timer = setTimeout(fn, timeout, ...args)
12+
}
13+
}
14+
15+
function sendWebVitals() {
16+
function sendData(data) {
17+
if (listOfMetricsSend.has(data.name)) {
18+
return
19+
}
20+
listOfMetricsSend.add(data.name)
21+
22+
sendToGoogleAnalytics(data)
23+
}
24+
25+
return import(`web-vitals/base`).then(({ getLCP, getFID, getCLS }) => {
26+
const debouncedCLS = debounce(sendData, 3000)
27+
// we don't need to debounce FID - we send it when it happens
28+
const debouncedFID = sendData
29+
// LCP can occur multiple times so we debounce it
30+
const debouncedLCP = debounce(sendData, 3000)
31+
32+
// With the true flag, we measure all previous occurences too, in case we start listening to late.
33+
getCLS(debouncedCLS, true)
34+
getFID(debouncedFID, true)
35+
getLCP(debouncedLCP, true)
36+
})
37+
}
38+
39+
function sendToGoogleAnalytics({ name, value, id }) {
40+
window.ga(`send`, `event`, {
41+
eventCategory: `Web Vitals`,
42+
eventAction: name,
43+
// The `id` value will be unique to the current page load. When sending
44+
// multiple values from the same page (e.g. for CLS), Google Analytics can
45+
// compute a total by grouping on this ID (note: requires `eventLabel` to
46+
// be a dimension in your report).
47+
eventLabel: id,
48+
// Google Analytics metrics must be integers, so the value is rounded.
49+
// For CLS the value is first multiplied by 1000 for greater precision
50+
// (note: increase the multiplier for greater precision if needed).
51+
eventValue: Math.round(name === `CLS` ? value * 1000 : value),
52+
// Use a non-interaction event to avoid affecting bounce rate.
53+
nonInteraction: true,
54+
// Use `sendBeacon()` if the browser supports it.
55+
transport: `beacon`,
56+
})
57+
}
58+
159
export const onRouteUpdate = ({ location }, pluginOptions = {}) => {
60+
const ga = window.ga
261
if (process.env.NODE_ENV !== `production` || typeof ga !== `function`) {
362
return null
463
}
@@ -16,8 +75,8 @@ export const onRouteUpdate = ({ location }, pluginOptions = {}) => {
1675
const pagePath = location
1776
? location.pathname + location.search + location.hash
1877
: undefined
19-
window.ga(`set`, `page`, pagePath)
20-
window.ga(`send`, `pageview`)
78+
ga(`set`, `page`, pagePath)
79+
ga(`send`, `pageview`)
2180
}
2281

2382
// Minimum delay for reactHelmet's requestAnimationFrame
@@ -26,3 +85,13 @@ export const onRouteUpdate = ({ location }, pluginOptions = {}) => {
2685

2786
return null
2887
}
88+
89+
export function onInitialClientRender(_, pluginOptions) {
90+
if (
91+
process.env.NODE_ENV === `production` &&
92+
typeof ga === `function` &&
93+
pluginOptions.enableWebVitalsTracking
94+
) {
95+
sendWebVitals()
96+
}
97+
}

packages/gatsby-plugin-google-analytics/src/gatsby-node.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,5 @@ exports.pluginOptionsSchema = ({ Joi }) =>
5454
queueTime: Joi.number(),
5555
forceSSL: Joi.boolean(),
5656
transport: Joi.string(),
57+
enableWebVitalsTracking: Joi.boolean().default(false),
5758
})

packages/gatsby-plugin-google-analytics/src/gatsby-ssr.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,25 @@ export const onRenderBody = (
6666
const setComponents = pluginOptions.head
6767
? setHeadComponents
6868
: setPostBodyComponents
69-
return setComponents([
69+
70+
const inlineScripts = []
71+
if (pluginOptions.enableWebVitalsTracking) {
72+
// web-vitals/polyfill (necessary for non chromium browsers)
73+
// @seehttps://www.npmjs.com/package/web-vitals#how-the-polyfill-works
74+
setHeadComponents([
75+
<script
76+
key="gatsby-plugin-google-analytics-web-vitals"
77+
data-gatsby="web-vitals-polyfill"
78+
dangerouslySetInnerHTML={{
79+
__html: `
80+
!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}}}();
81+
`,
82+
}}
83+
/>,
84+
])
85+
}
86+
87+
inlineScripts.push(
7088
<script
7189
key={`gatsby-plugin-google-analytics`}
7290
dangerouslySetInnerHTML={{
@@ -134,6 +152,8 @@ export const onRenderBody = (
134152
}, ``)}
135153
}`,
136154
}}
137-
/>,
138-
])
155+
/>
156+
)
157+
158+
return setComponents(inlineScripts)
139159
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
{
2-
"presets": [["babel-preset-gatsby-package", { "browser": true }]]
2+
"presets": [["babel-preset-gatsby-package"]],
3+
"overrides": [
4+
{
5+
"test": ["**/gatsby-browser.js"],
6+
"presets": [["babel-preset-gatsby-package", { "browser": true, "esm": true }]]
7+
}
8+
]
39
}

packages/gatsby-plugin-google-tagmanager/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ plugins: [
3737
//
3838
// Defaults to gatsby-route-change
3939
routeChangeEventName: "YOUR_ROUTE_CHANGE_EVENT_NAME",
40+
// Defaults to false
41+
enableWebVitalsTracking: true,
4042
},
4143
},
4244
]
@@ -80,6 +82,18 @@ This plugin will fire a new event called `gatsby-route-change` (or as in the `ga
8082

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

85+
#### Tracking Core Web Vitals
86+
87+
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.
88+
89+
You can save this data in Google Analytics or any database of your choosing.
90+
91+
We send three metrics:
92+
93+
- **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.
94+
- **First Input Delay (FID)**: measures interactivity. To provide a good user experience, pages should have a FID of 100 milliseconds or less.
95+
- **Cumulative Layout Shift (CLS)**: measures visual stability. To provide a good user experience, pages should maintain a CLS of 0.1. or less.
96+
8397
#### Note
8498

8599
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.

packages/gatsby-plugin-google-tagmanager/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"url": "https://github.com/gatsbyjs/gatsby/issues"
88
},
99
"dependencies": {
10-
"@babel/runtime": "^7.14.0"
10+
"@babel/runtime": "^7.14.0",
11+
"web-vitals": "^1.1.2"
1112
},
1213
"devDependencies": {
1314
"@babel/cli": "^7.14.0",

0 commit comments

Comments
 (0)