Skip to content

Commit

Permalink
Merge pull request #2915 from alphagov/ga4-consent
Browse files Browse the repository at this point in the history
Integrate GA4 analytics code with cookie consent mechanism
  • Loading branch information
andysellick authored Sep 8, 2022
2 parents 29acfca + e36947b commit b007835
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 97 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
useful summary for people upgrading their application, not a replication
of the commit log.

## Unreleased

* Integrate GA4 analytics code with cookie consent mechanism ([PR #2915](https://github.com/alphagov/govuk_publishing_components/pull/2915))

## 30.4.1

* Revert addition of `awesome_print` gem ([PR #2943](https://github.com/alphagov/govuk_publishing_components/pull/2943))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,4 @@
//= require ./analytics-ga4/ga4-page-views
//= require ./analytics-ga4/ga4-link-tracker
//= require ./analytics-ga4/ga4-event-tracker

window.GOVUK.analyticsGA4.pageViewTracker.sendPageView() // this will need integrating with cookie consent before production
window.GOVUK.analyticsGA4.linkTracker.trackLinkClicks()
//= require ./analytics-ga4/init-ga4
Original file line number Diff line number Diff line change
Expand Up @@ -11,68 +11,80 @@ window.GOVUK.Modules = window.GOVUK.Modules || {};
}

Ga4EventTracker.prototype.init = function () {
this.module.addEventListener('click', this.trackClick.bind(this), true) // useCapture must be true
var consentCookie = window.GOVUK.getConsentCookie()

if (consentCookie && consentCookie.settings) {
this.startModule()
} else {
this.startModule = this.startModule.bind(this)
window.addEventListener('cookie-consent', this.startModule)
}
}

Ga4EventTracker.prototype.trackClick = function (event) {
// triggered by cookie-consent event, which happens when users consent to cookies
Ga4EventTracker.prototype.startModule = function () {
if (window.dataLayer) {
var target = this.findTrackingAttributes(event.target)
if (target) {
var schema = new window.GOVUK.analyticsGA4.Schemas().eventSchema()

try {
var data = target.getAttribute(this.trackingTrigger)
data = JSON.parse(data)
} catch (e) {
// if there's a problem with the config, don't start the tracker
console.error('GA4 configuration error: ' + e.message, window.location)
return
}
this.module.addEventListener('click', this.trackClick.bind(this), true) // useCapture must be true
}
}

schema.event = 'event_data'
// get attributes from the data attribute to send to GA
// only allow it if it already exists in the schema
for (var property in data) {
if (property in schema.event_data) {
schema.event_data[property] = data[property]
}
}
Ga4EventTracker.prototype.trackClick = function (event) {
var target = this.findTrackingAttributes(event.target)
if (target) {
var schema = new window.GOVUK.analyticsGA4.Schemas().eventSchema()

// Ensure it only tracks aria-expanded in an accordion element, instead of in any child of the clicked element
if (target.closest('.gem-c-accordion')) {
var ariaExpanded = this.getClosestAttribute(target, 'aria-expanded')
}
try {
var data = target.getAttribute(this.trackingTrigger)
data = JSON.parse(data)
} catch (e) {
// if there's a problem with the config, don't start the tracker
console.error('GA4 configuration error: ' + e.message, window.location)
return
}

/*
the details component uses an 'open' attribute instead of aria-expanded, so we need to check if we're on a details component.
since details deletes the 'open' attribute when closed, we need this boolean, otherwise every element which
doesn't contain an 'open' attr would be pushed to gtm as a closed element.
*/
var detailsElement = target.closest('details')

if (ariaExpanded) {
schema.event_data.text = data.text || target.innerText
schema.event_data.action = (ariaExpanded === 'false') ? 'opened' : 'closed'
} else if (detailsElement) {
schema.event_data.text = data.text || detailsElement.textContent
var openAttribute = detailsElement.getAttribute('open')
schema.event_data.action = (openAttribute == null) ? 'opened' : 'closed'
schema.event = 'event_data'
// get attributes from the data attribute to send to GA
// only allow it if it already exists in the schema
for (var property in data) {
if (property in schema.event_data) {
schema.event_data[property] = data[property]
}
}

// Ensure it only tracks aria-expanded in an accordion element, instead of in any child of the clicked element
if (target.closest('.gem-c-accordion')) {
var ariaExpanded = this.getClosestAttribute(target, 'aria-expanded')
}

/* If a tab was clicked, grab the href of the clicked tab (usually an anchor # link) */
var tabElement = event.target.closest('.gem-c-tabs')
if (tabElement) {
var aTag = event.target.closest('a')
if (aTag) {
var href = aTag.getAttribute('href')
if (href) {
schema.event_data.url = href
}
/*
the details component uses an 'open' attribute instead of aria-expanded, so we need to check if we're on a details component.
since details deletes the 'open' attribute when closed, we need this boolean, otherwise every element which
doesn't contain an 'open' attr would be pushed to gtm as a closed element.
*/
var detailsElement = target.closest('details')

if (ariaExpanded) {
schema.event_data.text = data.text || target.innerText
schema.event_data.action = (ariaExpanded === 'false') ? 'opened' : 'closed'
} else if (detailsElement) {
schema.event_data.text = data.text || detailsElement.textContent
var openAttribute = detailsElement.getAttribute('open')
schema.event_data.action = (openAttribute == null) ? 'opened' : 'closed'
}

/* If a tab was clicked, grab the href of the clicked tab (usually an anchor # link) */
var tabElement = event.target.closest('.gem-c-tabs')
if (tabElement) {
var aTag = event.target.closest('a')
if (aTag) {
var href = aTag.getAttribute('href')
if (href) {
schema.event_data.url = href
}
}

window.dataLayer.push(schema)
}

window.dataLayer.push(schema)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// = require govuk/vendor/polyfills/Element/prototype/closest.js
window.GOVUK = window.GOVUK || {}
window.GOVUK.analyticsGA4 = window.GOVUK.analyticsGA4 || {}
window.GOVUK.analyticsGA4.analyticsModules = window.GOVUK.analyticsGA4.analyticsModules || {};

;(function (global) {
(function (analyticsModules) {
'use strict'

var GOVUK = global.GOVUK || {}
GOVUK.analyticsGA4 = GOVUK.analyticsGA4 || {}

GOVUK.analyticsGA4.linkTracker = {
trackLinkClicks: function () {
var Ga4LinkTracker = {
init: function () {
if (window.dataLayer) {
this.internalLinksDomain = 'www.gov.uk/'
this.internalLinksDomainWithoutWww = 'gov.uk/'
Expand Down Expand Up @@ -167,5 +167,5 @@
}
}

global.GOVUK = GOVUK
})(window)
analyticsModules.Ga4LinkTracker = Ga4LinkTracker
})(window.GOVUK.analyticsGA4.analyticsModules)
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
;(function (global) {
'use strict'
window.GOVUK = window.GOVUK || {}
window.GOVUK.analyticsGA4 = window.GOVUK.analyticsGA4 || {}
window.GOVUK.analyticsGA4.analyticsModules = window.GOVUK.analyticsGA4.analyticsModules || {};

var GOVUK = global.GOVUK || {}
GOVUK.analyticsGA4 = GOVUK.analyticsGA4 || {}
(function (analyticsModules) {
'use strict'

GOVUK.analyticsGA4.pageViewTracker = {
PIIRemover: new GOVUK.analyticsGA4.PIIRemover(), // imported in analytics-ga4.js
var PageViewTracker = {
PIIRemover: new window.GOVUK.analyticsGA4.PIIRemover(), // imported in analytics-ga4.js
nullValue: null,

sendPageView: function () {
init: function () {
if (window.dataLayer) {
var data = {
event: 'page_view',
Expand Down Expand Up @@ -61,7 +62,7 @@
},

// window.httpStatusCode is set in the source of the error page in static
// https://github.com/alphagov/static/blob/main/app/views/root/_error_page.html.erb#L32
// https://github.com/alphagov/static/blob/1c734451f2dd6fc0c7e80beccbdcbfa5aaffd0e4/app/views/root/_error_page.html.erb#L41-L43
getStatusCode: function () {
if (window.httpStatusCode) {
return window.httpStatusCode.toString()
Expand Down Expand Up @@ -109,5 +110,5 @@
}
}

global.GOVUK = GOVUK
})(window)
analyticsModules.PageViewTracker = PageViewTracker
})(window.GOVUK.analyticsGA4.analyticsModules)
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
window.GOVUK = window.GOVUK || {}
window.GOVUK.analyticsGA4 = window.GOVUK.analyticsGA4 || {}

var initFunction = function () {
// to be added: digital identity consent mechanism

var consentCookie = window.GOVUK.getConsentCookie()

if (consentCookie && consentCookie.usage) {
var analyticsModules = window.GOVUK.analyticsGA4.analyticsModules
for (var property in analyticsModules) {
var module = analyticsModules[property]
if (typeof module.init === 'function') {
module.init()
}
}
// to be added: attach JS from Google to the DOM and execute
// to be added: cross domain tracking code
} else {
window.addEventListener('cookie-consent', function () {
window.GOVUK.analyticsGA4.init()
})
}
}

window.GOVUK.analyticsGA4.init = initFunction
36 changes: 36 additions & 0 deletions docs/analytics-gtm/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,42 @@ Events happen when a user interacts with certain things, for example clicking on

Search data is gathered when users perform a search.

## Cookie consent

The analytics code is only loaded if users consent to cookies. This is managed by the `init-ga4.js` script.

If the page loads and cookie consent has already been given, the analytics code is initialised. This includes sending a page view and creating any event listeners for analytics code such as link tracking.

If the page loads and cookie consent has not been given, an event listener is created for the `cookie-consent` event, which is dispatched by the [cookie banner component](https://github.com/alphagov/govuk_publishing_components/pull/2041/commits/777a381d2ccb67f0a7e78ebf659be806d8d6442d). If triggered, the event listener will initialise the analytics code as described above. This allows analytics to begin on the page where the user consents to cookies.

## Code structure

It is important that no analytics code runs until cookie consent is given. Code to be initialised as part of cookie consent should be attached to the `window.GOVUK.analyticsGA4.analyticsModules` object and include an `init` function, using the structure shown below.

```JavaScript
window.GOVUK = window.GOVUK || {}
window.GOVUK.analyticsGA4 = window.GOVUK.analyticsGA4 || {}
window.GOVUK.analyticsGA4.analyticsModules = window.GOVUK.analyticsGA4.analyticsModules || {};

(function (analyticsModules) {
'use strict'

var ExampleCode = {
init: function () {
// do analytics stuff, like send a page view
}
}

analyticsModules.ExampleCode = ExampleCode
})(window.GOVUK.analyticsGA4.analyticsModules)
```

When cookie consent is given, `init-ga4.js` looks through the `analyticsModules` object for anything with an `init` function, and executes them if found. This means that analytics code will not be executed unless consent is given, and gives a standard way to add more analytics code without additional initialisation.

### Code structure for Modules

Where analytics code is required as a [GOV.UK JavaScript Module](https://github.com/alphagov/govuk_publishing_components/blob/main/docs/javascript-modules.md), the code structure for the [existing model for deferred loading](https://github.com/alphagov/govuk_publishing_components/blob/main/docs/javascript-modules.md#modules-and-cookie-consent) should be used.

## Data schemas

All of the data sent to GTM is based on a common schema.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,65 @@ describe('Google Analytics event tracking', function () {
var element
var expected

function agreeToCookies () {
GOVUK.setCookie('cookies_policy', '{"essential":true,"settings":true,"usage":true,"campaigns":true}')
}

function denyCookies () {
GOVUK.setCookie('cookies_policy', '{"essential":false,"settings":false,"usage":false,"campaigns":false}')
}

beforeEach(function () {
window.dataLayer = []
element = document.createElement('div')
agreeToCookies()
})

afterEach(function () {
document.body.removeChild(element)
})

describe('when the user has a cookie consent choice', function () {
it('starts the module if consent has already been given', function () {
agreeToCookies()
document.body.appendChild(element)
var tracker = new GOVUK.Modules.Ga4EventTracker(element)
spyOn(tracker, 'trackClick')
tracker.init()

element.click()
expect(tracker.trackClick).toHaveBeenCalled()
})

it('starts the module on the same page as cookie consent is given', function () {
denyCookies()
document.body.appendChild(element)
var tracker = new GOVUK.Modules.Ga4EventTracker(element)
spyOn(tracker, 'trackClick')
tracker.init()

element.click()
expect(tracker.trackClick).not.toHaveBeenCalled()

// page has not been reloaded, user consents to cookies
window.GOVUK.triggerEvent(window, 'cookie-consent')

element.click()
expect(tracker.trackClick).toHaveBeenCalled()
})

it('does not do anything if consent is not given', function () {
denyCookies()
document.body.appendChild(element)
var tracker = new GOVUK.Modules.Ga4EventTracker(element)
spyOn(tracker, 'trackClick')
tracker.init()

element.click()
expect(tracker.trackClick).not.toHaveBeenCalled()
})
})

describe('configuring tracking without any data', function () {
beforeEach(function () {
element.setAttribute('data-ga4', '')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ describe('GOVUK.analyticsGA4.linkTracker', function () {
body.appendChild(links)
body.addEventListener('click', preventDefault)

linkTracker = GOVUK.analyticsGA4.linkTracker
linkTracker.trackLinkClicks()
linkTracker = GOVUK.analyticsGA4.analyticsModules.Ga4LinkTracker
linkTracker.init()
})

afterEach(function () {
Expand Down Expand Up @@ -291,8 +291,8 @@ describe('GOVUK.analyticsGA4.linkTracker', function () {
body.appendChild(links)
body.addEventListener('click', preventDefault)

linkTracker = GOVUK.analyticsGA4.linkTracker
linkTracker.trackLinkClicks()
linkTracker = GOVUK.analyticsGA4.analyticsModules.Ga4LinkTracker
linkTracker.init()
})

afterEach(function () {
Expand Down Expand Up @@ -463,8 +463,8 @@ describe('GOVUK.analyticsGA4.linkTracker', function () {
body.appendChild(links)
body.addEventListener('click', preventDefault)

linkTracker = GOVUK.analyticsGA4.linkTracker
linkTracker.trackLinkClicks()
linkTracker = GOVUK.analyticsGA4.analyticsModules.Ga4LinkTracker
linkTracker.init()
})

afterEach(function () {
Expand Down
Loading

0 comments on commit b007835

Please sign in to comment.