Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: load analytics in cookie-less mode #2375

Merged
merged 4 commits into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
62 changes: 38 additions & 24 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,40 +34,58 @@ 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 as any).gtag = function () {
window[DATA_LAYER_NAME].push(arguments)
}
usame-algan marked this conversation as resolved.
Show resolved Hide resolved

// Initialize dataLayer (with configuration)
window[DATA_LAYER_NAME] = args.dataLayer ? [args.dataLayer] : []
// Consent mode
;(window as any).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 as any).gtag('consent', 'update', {
analytics_storage: 'granted',
})
},
disable: () => {
if (!TagManager.isInitialized()) {
return
}

disableCookies: () => {
;(window as any).gtag('consent', 'update', {
analytics_storage: 'denied',
})

const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid']

Expand Down
72 changes: 22 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,34 +98,26 @@ 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()
TagManager.disableCookies()

const path = '/'
const domain = '.localhost'
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.
usame-algan marked this conversation as resolved.
Show resolved Hide resolved
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
Loading