Skip to content

Commit

Permalink
Feat: load analytics in cookie-less mode (#2375)
Browse files Browse the repository at this point in the history
* Feat: load analytics in cookie-less mode

* Add types for gtag

* window.gtag?

* Delete cookies with a _ga_* prefix
  • Loading branch information
katspaugh authored Aug 11, 2023
1 parent 5cf25e9 commit fbd17fa
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 113 deletions.
12 changes: 6 additions & 6 deletions src/components/common/CookieBanner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,8 @@ export const CookieBanner = ({
<Grid container alignItems="center">
<Grid item xs>
<Typography variant="body2" mb={2}>
By clicking &quot;Accept all&quot; you agree to the use of the tools listed below and their corresponding{' '}
<span style={{ whiteSpace: 'nowrap' }}>3rd-party</span> cookies.{' '}
<ExternalLink href={AppRoutes.cookie}>Cookie policy</ExternalLink>
By clicking &quot;Accept all&quot; you agree to the use of the tools listed below and their corresponding
cookies. <ExternalLink href={AppRoutes.cookie}>Cookie policy</ExternalLink>
</Typography>

<Grid container alignItems="center" gap={4}>
Expand All @@ -84,6 +83,7 @@ export const CookieBanner = ({
<br />
<Typography variant="body2">Locally stored data for core functionality</Typography>
</Box>

<Box mb={2}>
<CookieCheckbox
checkboxProps={{ ...register(CookieType.UPDATES), id: 'beamer' }}
Expand All @@ -93,16 +93,16 @@ export const CookieBanner = ({
<br />
<Typography variant="body2">New features and product announcements</Typography>
</Box>

<Box>
<CookieCheckbox
checkboxProps={{ ...register(CookieType.ANALYTICS), id: 'ga' }}
label="Google Analytics"
label="Analytics"
checked={watch(CookieType.ANALYTICS)}
/>
<br />
<Typography variant="body2">
Help us make the app better. We never track your Safe Account address or wallet addresses, or any
transaction data.
Opt in for Google Analytics cookies to help us analyze app usage patterns.
</Typography>
</Box>
</Grid>
Expand Down
3 changes: 2 additions & 1 deletion src/definitions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ declare global {
}
beamer_config?: BeamerConfig
Beamer?: BeamerMethods
dataLayer?: DataLayerArgs['dataLayer']
dataLayer?: any[]
gtag?: (...args: any[]) => void
Cypress?
}
}
Expand Down
69 changes: 43 additions & 26 deletions src/services/analytics/TagManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@ import Cookies from 'js-cookie'

import { IS_PRODUCTION } from '@/config/constants'

type DataLayer = Record<string, unknown>

export type TagManagerArgs = {
// GTM id, e.g. GTM-000000
gtmId: string
// GTM authentication key
auth: string
// GTM environment, e.g. env-00.
preview: string
// Object that contains all of the information that you want to pass to GTM
dataLayer?: DataLayer
}

const DATA_LAYER_NAME = 'dataLayer'
Expand All @@ -38,44 +34,65 @@ const TagManager = {

return script
},
isInitialized: () => {
const GTM_SCRIPT = 'https://www.googletagmanager.com/gtm.js'

return !!document.querySelector(`[src^="${GTM_SCRIPT}"]`)
dataLayer: (data: Record<string, any>) => {
window[DATA_LAYER_NAME] = window[DATA_LAYER_NAME] || []
window[DATA_LAYER_NAME].push(data)

if (!IS_PRODUCTION) {
console.info('[GTM] -', data)
}
},

initialize: (args: TagManagerArgs) => {
if (TagManager.isInitialized()) {
return
window[DATA_LAYER_NAME] = window[DATA_LAYER_NAME] || []
// This function MUST be in `window`, otherwise GTM Consent Mode just doesn't work
window.gtag = function () {
window[DATA_LAYER_NAME]?.push(arguments)
}

// Initialize dataLayer (with configuration)
window[DATA_LAYER_NAME] = args.dataLayer ? [args.dataLayer] : []
// Consent mode
window.gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
functionality_storage: 'granted',
personalization_storage: 'denied',
security_storage: 'granted',
wait_for_update: 500,
})

TagManager.dataLayer({
// Block JS variables and custom scripts
// @see https://developers.google.com/tag-platform/tag-manager/web/restrict
'gtm.blocklist': ['j', 'jsm', 'customScripts'],
pageLocation: `${location.origin}${location.pathname}`,
pagePath: location.pathname,
})

const script = TagManager._getScript(args)

// Initialize GTM. This pushes the default dataLayer event:
// { "gtm.start": new Date().getTime(), event: "gtm.js" }
document.head.insertBefore(script, document.head.childNodes[0])
},
dataLayer: (dataLayer: DataLayer) => {
if (!TagManager.isInitialized()) {
return
}

window[DATA_LAYER_NAME].push(dataLayer)

if (!IS_PRODUCTION) {
console.info('[GTM] -', dataLayer)
}
enableCookies: () => {
window.gtag?.('consent', 'update', {
analytics_storage: 'granted',
})
},
disable: () => {
if (!TagManager.isInitialized()) {
return
}

const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid']
disableCookies: () => {
window.gtag?.('consent', 'update', {
analytics_storage: 'denied',
})

const GA_COOKIE_LIST = ['_ga', '_gat', '_gid']
const GA_PREFIX = '_ga_'
const allCookies = document.cookie.split(';').map((cookie) => cookie.split('=')[0].trim())
const gaCookies = allCookies.filter((cookie) => cookie.startsWith(GA_PREFIX))

GTM_COOKIE_LIST.forEach((cookie) => {
GA_COOKIE_LIST.concat(gaCookies).forEach((cookie) => {
Cookies.remove(cookie, {
path: '/',
domain: `.${location.host.split('.').slice(-2).join('.')}`,
Expand Down
78 changes: 28 additions & 50 deletions src/services/analytics/__tests__/TagManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,6 @@ describe('TagManager', () => {
})
})

describe('TagManager.isInitialized', () => {
it('should return false if no script is found', () => {
expect(TagManager.isInitialized()).toBe(false)
})

it('should return true if a script is found', () => {
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })

expect(TagManager.isInitialized()).toBe(true)
})
})

describe('TagManager.initialize', () => {
it('should initialize TagManager', () => {
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })
Expand All @@ -80,35 +68,27 @@ describe('TagManager', () => {
TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }),
)

expect(window.dataLayer).toHaveLength(1)
expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) })
})

it('should not re-initialize the scripts if previously enabled', async () => {
const getScriptSpy = jest.spyOn(gtm.default, '_getScript')

TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })

expect(getScriptSpy).toHaveBeenCalledTimes(1)
})

it('should push to the dataLayer if povided', () => {
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW, dataLayer: { test: '456' } })

expect(window.dataLayer).toHaveLength(2)
expect(window.dataLayer[0]).toStrictEqual({ test: '456' })
expect(window.dataLayer[1]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) })
expect(window.dataLayer).toHaveLength(3)
expect(window.dataLayer?.[0][0]).toBe('consent')
expect(window.dataLayer?.[0][1]).toBe('default')
expect(window.dataLayer?.[0][2]).toStrictEqual({
ad_storage: 'denied',
analytics_storage: 'denied',
functionality_storage: 'granted',
personalization_storage: 'denied',
security_storage: 'granted',
wait_for_update: 500,
})
expect(window.dataLayer?.[1]).toStrictEqual({
'gtm.blocklist': ['j', 'jsm', 'customScripts'],
pageLocation: 'http://localhost/balances',
pagePath: '/balances',
})
expect(window.dataLayer?.[2]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) })
})
})

describe('TagManager.dataLayer', () => {
it('should not push to the dataLayer if not initialized', () => {
TagManager.dataLayer({ test: '456' })

expect(window.dataLayer).toBeUndefined()
})

it('should push data to the dataLayer', () => {
expect(window.dataLayer).toBeUndefined()

Expand All @@ -118,39 +98,37 @@ describe('TagManager', () => {
preview: MOCK_PREVIEW,
})

expect(window.dataLayer).toHaveLength(1)
expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) })
expect(window.dataLayer).toHaveLength(3)

TagManager.dataLayer({
test: '123',
})

expect(window.dataLayer).toHaveLength(2)
expect(window.dataLayer[1]).toStrictEqual({ test: '123' })
expect(window.dataLayer).toHaveLength(4)
expect(window.dataLayer?.[3]).toStrictEqual({ test: '123' })
})
})

describe('TagManager.disable', () => {
it('should not remove GA cookies and reload if not mounted', () => {
TagManager.disable()

expect(Cookies.remove).not.toHaveBeenCalled()

expect(global.location.reload).not.toHaveBeenCalled()
})
it('should remove GA cookies and reload if mounted', () => {
it('should remove GA cookies and reload', () => {
TagManager.initialize({
gtmId: MOCK_ID,
auth: MOCK_AUTH,
preview: MOCK_PREVIEW,
})

TagManager.disable()
document.cookie = '_ga=GA123;'
document.cookie = '_ga_JB9NXCRJ0G=GS123;'
document.cookie = '_gat=GA123;'
document.cookie = '_gid=GI123;'

TagManager.disableCookies()

const path = '/'
const domain = '.localhost'

expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain })
expect(Cookies.remove).toHaveBeenCalledWith('_ga_JB9NXCRJ0G', { path, domain })
expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain })
expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain })

Expand Down
12 changes: 3 additions & 9 deletions src/services/analytics/gtm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const gtmSetChainId = (chainId: string): void => {
_chainId = chainId
}

export const gtmInit = (pagePath: string): void => {
export const gtmInit = (): void => {
const GTM_ENVIRONMENT = IS_PRODUCTION ? GTM_ENV_AUTH.LIVE : GTM_ENV_AUTH.DEVELOPMENT

if (!GOOGLE_TAG_MANAGER_ID || !GTM_ENVIRONMENT.auth) {
Expand All @@ -57,17 +57,11 @@ export const gtmInit = (pagePath: string): void => {
TagManager.initialize({
gtmId: GOOGLE_TAG_MANAGER_ID,
...GTM_ENVIRONMENT,
dataLayer: {
pageLocation: `${location.origin}${pagePath}`,
pagePath,
// Block JS variables and custom scripts
// @see https://developers.google.com/tag-platform/tag-manager/web/restrict
'gtm.blocklist': ['j', 'jsm', 'customScripts'],
},
})
}

export const gtmClear = TagManager.disable
export const gtmEnableCookies = TagManager.enableCookies
export const gtmDisableCookies = TagManager.disableCookies

type GtmEvent = {
event: EventType
Expand Down
38 changes: 26 additions & 12 deletions src/services/analytics/useGtm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* It won't initialize GTM if a consent wasn't given for analytics cookies.
* The hook needs to be called when the app starts.
*/
import { useEffect } from 'react'
import { gtmClear, gtmInit, gtmTrackPageview, gtmSetChainId } from '@/services/analytics/gtm'
import { useEffect, useState } from 'react'
import { gtmInit, gtmTrackPageview, gtmSetChainId, gtmEnableCookies, gtmDisableCookies } from '@/services/analytics/gtm'
import { useAppSelector } from '@/store'
import { CookieType, selectCookies } from '@/store/cookiesSlice'
import useChainId from '@/hooks/useChainId'
Expand All @@ -16,14 +16,27 @@ const useGtm = () => {
const chainId = useChainId()
const cookies = useAppSelector(selectCookies)
const isAnalyticsEnabled = cookies[CookieType.ANALYTICS] || false
const [, setPrevAnalytics] = useState(isAnalyticsEnabled)
const router = useRouter()

// Initialize GTM, or clear it if analytics is disabled
// Initialize GTM
useEffect(() => {
// router.pathname doesn't contain the safe address
// so we can override the initial dataLayer
isAnalyticsEnabled ? gtmInit(router.pathname) : gtmClear()
// eslint-disable-next-line react-hooks/exhaustive-deps
gtmInit()
}, [])

// Enable GA cookies if consent was given
useEffect(() => {
setPrevAnalytics((prev) => {
if (isAnalyticsEnabled === prev) return prev

if (isAnalyticsEnabled) {
gtmEnableCookies()
} else {
gtmDisableCookies()
}

return isAnalyticsEnabled
})
}, [isAnalyticsEnabled])

// Set the chain ID for GTM
Expand All @@ -34,13 +47,14 @@ const useGtm = () => {
// Track page views – anononimized by default.
// Sensitive info, like the safe address or tx id, is always in the query string, which we DO NOT track.
useEffect(() => {
if (isAnalyticsEnabled && router.pathname !== AppRoutes['404']) {
gtmTrackPageview(router.pathname)
}
}, [isAnalyticsEnabled, router.pathname])
// Don't track 404 because it's not a real page, it immediately does a client-side redirect
if (router.pathname === AppRoutes['404']) return

gtmTrackPageview(router.pathname)
}, [router.pathname])

// Track meta events on app load
useMetaEvents(isAnalyticsEnabled)
useMetaEvents()
}

export default useGtm
Loading

0 comments on commit fbd17fa

Please sign in to comment.