Skip to content

Commit

Permalink
Merge pull request #1827 from alphagov/ldeb-add-search-tracking
Browse files Browse the repository at this point in the history
Add search tracking
  • Loading branch information
lfdebrux authored Aug 19, 2021
2 parents 907836c + d9b23a0 commit e273c74
Show file tree
Hide file tree
Showing 3 changed files with 271 additions and 0 deletions.
159 changes: 159 additions & 0 deletions __tests__/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,163 @@ describe('Site search', () => {

expect($activeElement).toEqual($input)
})

describe('tracking', () => {
it('should track if there are no results', async () => {
await page.goto(baseUrl, { waitUntil: 'load' })

await page.evaluate(() => { window.__SITE_SEARCH_TRACKING_TIMEOUT = 0 })

await page.waitForSelector('.app-site-search__input')
await page.focus('.app-site-search__input')
await page.type('.app-site-search__input', 'lorem ipsum')
const GoogleTagManagerDataLayer = await page.evaluate(() => window.dataLayer)

expect(GoogleTagManagerDataLayer).toEqual(
expect.arrayContaining([
expect.objectContaining({
ecommerce: {
impressions: []
},
event: 'site-search',
eventDetails: {
action: 'no result',
category: 'site search',
label: 'lorem ipsum'
}
})
])
)
})
it('should track if there are results', async () => {
await page.goto(baseUrl, { waitUntil: 'load' })

await page.evaluate(() => { window.__SITE_SEARCH_TRACKING_TIMEOUT = 0 })

await page.waitForSelector('.app-site-search__input')
await page.focus('.app-site-search__input')
await page.type('.app-site-search__input', 'g')
const optionResults = await page.$$('.app-site-search__option')
const GoogleTagManagerDataLayer = await page.evaluate(() => window.dataLayer)

// Find layer that has the impressions to test.
const impressions =
GoogleTagManagerDataLayer
.filter(layer => layer.ecommerce)
.map(layer => layer.ecommerce.impressions)[0]

expect(impressions.length).toEqual(optionResults.length)
expect(GoogleTagManagerDataLayer).toEqual(
expect.arrayContaining([
expect.objectContaining({
ecommerce: {
impressions: expect.arrayContaining([
expect.objectContaining({
name: expect.any(String),
category: expect.any(String),
list: 'g',
position: expect.any(Number)
})
])
},
event: 'site-search',
eventDetails: {
action: 'results',
category: 'site search',
label: 'g'
}
})
])
)
})
it('should track if a result is clicked', async () => {
await page.goto(baseUrl, { waitUntil: 'load' })

// Prevent page from unloading so we can check what was tracked.
// By setting onbeforeunload it forces a dialog to appear that allows a user
// to cancel leaving the page, so we detect the dialog opening and dismiss it to stop the navigation.
await page.evaluate(() => {
window.onbeforeunload = () => true
})
page.on('dialog', async dialog => {
await dialog.dismiss()
})

await page.waitForSelector('.app-site-search__input')
await page.focus('.app-site-search__input')
await page.type('.app-site-search__input', 'g')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')

const GoogleTagManagerDataLayer = await page.evaluate(() => window.dataLayer)

expect(GoogleTagManagerDataLayer).toEqual(
expect.arrayContaining([
expect.objectContaining({
ecommerce: {
click: {
actionField: {
list: 'g'
},
products: expect.arrayContaining([
expect.objectContaining({
name: expect.any(String),
category: expect.any(String),
list: 'g',
position: 2
})
])
}
},
event: 'site-search',
eventDetails: {
action: 'click',
category: 'site search',
label: expect.stringContaining('g |')
}
})
])
)
})
it('should block personally identifable information emails', async () => {
await page.goto(baseUrl, { waitUntil: 'load' })

await page.evaluate(() => { window.__SITE_SEARCH_TRACKING_TIMEOUT = 0 })

await page.waitForSelector('.app-site-search__input')
await page.focus('.app-site-search__input')
await page.type('.app-site-search__input', 'user@example.com')
const GoogleTagManagerDataLayer = await page.evaluate(() => window.dataLayer)

expect(GoogleTagManagerDataLayer).toEqual(
expect.arrayContaining([
expect.objectContaining({
eventDetails: expect.objectContaining({
label: '[REDACTED EMAIL]'
})
})
])
)
})
it('should block personally identifable information numbers', async () => {
await page.goto(baseUrl, { waitUntil: 'load' })

await page.evaluate(() => { window.__SITE_SEARCH_TRACKING_TIMEOUT = 0 })

await page.waitForSelector('.app-site-search__input')
await page.focus('.app-site-search__input')
await page.type('.app-site-search__input', '079460999')
const GoogleTagManagerDataLayer = await page.evaluate(() => window.dataLayer)

expect(GoogleTagManagerDataLayer).toEqual(
expect.arrayContaining([
expect.objectContaining({
eventDetails: expect.objectContaining({
label: '[REDACTED NUMBER]'
})
})
])
)
})
})
})
27 changes: 27 additions & 0 deletions src/javascripts/components/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import accessibleAutocomplete from 'accessible-autocomplete'
import lunr from 'lunr'

import { trackSearchResults, trackConfirm } from './search.tracking.js'

// CONSTANTS
var TIMEOUT = 10 // Time to wait before giving up fetching the search index
var STATE_DONE = 4 // XHR client readyState DONE
Expand All @@ -16,6 +18,18 @@ var searchCallback = function () {}
// Results that are rendered by the autocomplete
var searchResults = []

// Timer that allows us to only fire events after a user has finished typing
var inputDebounceTimer = null

// We want to wait a bit before firing events to indicate that
// someone is looking at a result and not that it's come up in passing.
var DEBOUNCE_TIME_TO_WAIT = function () {
// We want to be able to reduce this timeout in order to make sure
// our tests do not run very slowly.
var timeout = window.__SITE_SEARCH_TRACKING_TIMEOUT
return (typeof timeout !== 'undefined') ? timeout : 2000 // milliseconds
}

function Search ($module) {
this.$module = $module
}
Expand Down Expand Up @@ -61,6 +75,11 @@ Search.prototype.handleSearchQuery = function (query, callback) {
searchQuery = query
searchCallback = callback

clearTimeout(inputDebounceTimer)
inputDebounceTimer = setTimeout(function () {
trackSearchResults(searchQuery, searchResults)
}, DEBOUNCE_TIME_TO_WAIT())

this.renderResults()
}

Expand All @@ -69,6 +88,7 @@ Search.prototype.handleOnConfirm = function (result) {
if (!path) {
return
}
trackConfirm(searchQuery, searchResults, result)
window.location.href = '/' + path
}

Expand Down Expand Up @@ -149,6 +169,13 @@ Search.prototype.init = function () {
tNoResults: function () { return statusMessage }
})

var $input = $module.querySelector('.app-site-search__input')

// Ensure if the user stops using the search that we do not send tracking events
$input.addEventListener('blur', function (event) {
clearTimeout(inputDebounceTimer)
})

var searchIndexUrl = $module.getAttribute('data-search-index')
this.fetchSearchIndex(searchIndexUrl, function () {
this.renderResults()
Expand Down
85 changes: 85 additions & 0 deletions src/javascripts/components/search.tracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
function addToDataLayer (payload) {
window.dataLayer = window.dataLayer || []
window.dataLayer.push(payload)
}

function stripPossiblePII (string) {
// Try to detect emails, postcodes, and NI numbers, and redact them.
// Regexes copied from GTM variable 'JS - Remove PII from Hit Payload'
string = string.replace(/[^\s=/?&]+(?:@|%40)[^\s=/?&]+/g, '[REDACTED EMAIL]')
string = string.replace(/\b[A-PR-UWYZ][A-HJ-Z]?[0-9][0-9A-HJKMNPR-Y]?(?:[\s+]|%20)*[0-9](?!refund)[ABD-HJLNPQ-Z]{2,3}\b/gi, '[REDACTED POSTCODE]')
string = string.replace(/^\s*[a-zA-Z]{2}(?:\s*\d\s*){6}[a-zA-Z]?\s*$/g, '[REDACTED NI NUMBER]')
// If someone has typed in a number it's likely not related so redact it
string = string.replace(/[0-9]+/g, '[REDACTED NUMBER]')
return string
}

function trackConfirm (searchQuery, searchResults, result) {
if (window.DO_NOT_TRACK_ENABLED) {
return
}

var searchTerm = stripPossiblePII(searchQuery)
var products =
searchResults
.map(function (result, key) {
return {
name: result.title,
category: result.section,
list: searchTerm, // Used to match an searchTerm with results
position: (key + 1)
}
})
.filter(function (product) {
// Only return the product that matches what was clicked
return product.name === result.title
})

addToDataLayer({
event: 'site-search',
eventDetails: {
category: 'site search',
action: 'click',
label: searchTerm + ' | ' + result.title
},
ecommerce: {
click: {
actionField: { list: searchTerm },
products: products
}
}
})
}

function trackSearchResults (searchQuery, searchResults) {
if (window.DO_NOT_TRACK_ENABLED) {
return
}

var searchTerm = stripPossiblePII(searchQuery)

var hasResults = (searchResults.length > 0)
// Impressions is Google Analytics lingo for what people have seen.
var impressions = searchResults.map(function (result, key) {
return {
name: result.title,
category: result.section,
list: searchTerm, // Used to match an searchTerm with results
position: (key + 1)
}
})

addToDataLayer({
event: 'site-search',
eventDetails: {
category: 'site search',
action: hasResults ? 'results' : 'no result',
label: searchTerm
},
ecommerce: {
impressions: impressions
}
})
}

export { trackConfirm, trackSearchResults }

0 comments on commit e273c74

Please sign in to comment.