From 4c4f9bbdeea7f933cc57c7fd28f4c72d056a2caa Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 3 Nov 2022 10:07:10 +0100 Subject: [PATCH 01/11] feat: add `TagManager.destroy` function --- src/services/analytics/TagManager.ts | 33 +++++++++++++++++++++++----- src/services/analytics/gtm.ts | 9 +------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 5c19dbf11f..69f2f24f3e 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -1,3 +1,5 @@ +import Cookies from 'js-cookie' + // Based on https://github.com/alinemorelli/react-gtm type DataLayer = Record @@ -34,10 +36,12 @@ export const _getRequiredGtmArgs = ({ gtmId, dataLayer = undefined, auth = '', p // Initialization scripts +let gtmScriptRef: HTMLScriptElement | null = null + export const _getGtmScript = (args: TagManagerArgs) => { const { gtmId, auth, preview } = _getRequiredGtmArgs(args) - const script = document.createElement('script') + gtmScriptRef = document.createElement('script') const gtmScript = ` (function (w, d, s, l, i) { @@ -51,23 +55,25 @@ export const _getGtmScript = (args: TagManagerArgs) => { f.parentNode.insertBefore(j, f); })(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');` - script.innerHTML = gtmScript + gtmScriptRef.innerHTML = gtmScript - return script + return gtmScriptRef } // Data layer scripts +let gtmDataLayerScriptRef: HTMLScriptElement | null = null + export const _getGtmDataLayerScript = (dataLayer: DataLayer) => { - const script = document.createElement('script') + gtmDataLayerScriptRef = document.createElement('script') const gtmDataLayerScript = ` window.${DATA_LAYER_NAME} = window.${DATA_LAYER_NAME} || []; window.${DATA_LAYER_NAME}.push(${JSON.stringify(dataLayer)})` - script.innerHTML = gtmDataLayerScript + gtmDataLayerScriptRef.innerHTML = gtmDataLayerScript - return script + return gtmDataLayerScriptRef } const TagManager = { @@ -90,6 +96,21 @@ const TagManager = { const gtmDataLayerScript = _getGtmDataLayerScript(dataLayer) document.head.insertBefore(gtmDataLayerScript, document.head.childNodes[0]) }, + destroy: () => { + const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] + + gtmScriptRef?.remove() + gtmScriptRef = null + + gtmDataLayerScriptRef?.remove() + gtmDataLayerScriptRef = null + + const path = '/' + const domain = `.${location.host.split('.').slice(-2).join('.')}` + GTM_COOKIE_LIST.forEach((cookie) => { + Cookies.remove(cookie, { path, domain }) + }) + }, } export default TagManager diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index 6520a1b9ed..6f5d3d31aa 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -9,7 +9,6 @@ import type { TagManagerArgs } from './TagManager' import TagManager, { DATA_LAYER_NAME } from './TagManager' -import Cookies from 'js-cookie' import type { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk' import { IS_PRODUCTION, @@ -24,7 +23,6 @@ import { EventType } from './types' type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' type GTMEnvironmentArgs = Required> -const GOOGLE_ANALYTICS_COOKIE_LIST = ['_ga', '_gat', '_gid'] const EMPTY_SAFE_APP = 'unknown' const GTM_ENV_AUTH: Record = { @@ -74,12 +72,7 @@ const isGtmLoaded = (): boolean => { export const gtmClear = (): void => { if (!isGtmLoaded()) return - // Delete GA cookies - const path = '/' - const domain = `.${location.host.split('.').slice(-2).join('.')}` - GOOGLE_ANALYTICS_COOKIE_LIST.forEach((cookie) => { - Cookies.remove(cookie, { path, domain }) - }) + TagManager.destroy() } type GtmEvent = { From c0c39fbd34107987121e3cf72c98af1af4376e7a Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 3 Nov 2022 12:36:43 +0100 Subject: [PATCH 02/11] refactor: GTM --- src/services/analytics/TagManager.ts | 99 +++++------ .../analytics/__tests__/TagManager.test.ts | 154 ++++++++++++------ src/services/analytics/gtm.ts | 20 +-- src/services/analytics/useGtm.ts | 2 +- 4 files changed, 150 insertions(+), 125 deletions(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 69f2f24f3e..500becb117 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -12,99 +12,88 @@ export type TagManagerArgs = { /** * Used to set environments. */ - auth?: string | undefined + auth: string /** * Used to set environments, something like env-00. */ - preview?: string | undefined + preview: string /** * Object that contains all of the information that you want to pass to Google Tag Manager. */ - dataLayer?: DataLayer | undefined + dataLayer?: DataLayer } -export const DATA_LAYER_NAME = 'dataLayer' - -export const _getRequiredGtmArgs = ({ gtmId, dataLayer = undefined, auth = '', preview = '' }: TagManagerArgs) => { - return { - gtmId, - dataLayer, - auth: auth ? `>m_auth=${auth}` : '', - preview: preview ? `>m_preview=${preview}` : '', - } -} +const DATA_LAYER_NAME = 'dataLayer' // Initialization scripts -let gtmScriptRef: HTMLScriptElement | null = null - -export const _getGtmScript = (args: TagManagerArgs) => { - const { gtmId, auth, preview } = _getRequiredGtmArgs(args) - - gtmScriptRef = document.createElement('script') +export const _getGtmScript = ({ gtmId, auth, preview }: TagManagerArgs) => { + const script = document.createElement('script') const gtmScript = ` (function (w, d, s, l, i) { w[l] = w[l] || []; w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); var f = d.getElementsByTagName(s)[0], - j = d.createElement(s), + j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''; - j.async = true; - j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + '${auth}${preview}>m_cookies_win=x'; - f.parentNode.insertBefore(j, f); - })(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');` + j.async = true; + j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + '>m_auth=${auth}>m_preview=${preview}>m_cookies_win=x'; + f.parentNode.insertBefore(j, f); + })(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');` - gtmScriptRef.innerHTML = gtmScript + script.innerHTML = gtmScript - return gtmScriptRef + return script } -// Data layer scripts - -let gtmDataLayerScriptRef: HTMLScriptElement | null = null - -export const _getGtmDataLayerScript = (dataLayer: DataLayer) => { - gtmDataLayerScriptRef = document.createElement('script') - - const gtmDataLayerScript = ` - window.${DATA_LAYER_NAME} = window.${DATA_LAYER_NAME} || []; - window.${DATA_LAYER_NAME}.push(${JSON.stringify(dataLayer)})` - - gtmDataLayerScriptRef.innerHTML = gtmDataLayerScript - - return gtmDataLayerScriptRef -} +let gtmScriptRef: HTMLScriptElement | null = null const TagManager = { initialize: (args: TagManagerArgs) => { - const { dataLayer } = _getRequiredGtmArgs(args) + if (gtmScriptRef) { + return + } - if (dataLayer) { - const gtmDataLayerScript = _getGtmDataLayerScript(dataLayer) - document.head.appendChild(gtmDataLayerScript) + // Push configuration to dataLayer + if (args.dataLayer) { + window[DATA_LAYER_NAME] = [] + window[DATA_LAYER_NAME].push(args.dataLayer) } - const gtmScript = _getGtmScript(args) - document.head.insertBefore(gtmScript, document.head.childNodes[0]) + gtmScriptRef = _getGtmScript(args) + + // Initialize GTM. This pushes the default dataLayer event: + // { "gtm.start": new Date().getTime(), event: "gtm.js" } + document.head.insertBefore(gtmScriptRef, document.head.childNodes[0]) }, dataLayer: (dataLayer: DataLayer) => { - if (window[DATA_LAYER_NAME]) { - return window[DATA_LAYER_NAME].push(dataLayer) + // Push to dataLayer if mounted + if (gtmScriptRef && window[DATA_LAYER_NAME]) { + window[DATA_LAYER_NAME].push(dataLayer) } - - const gtmDataLayerScript = _getGtmDataLayerScript(dataLayer) - document.head.insertBefore(gtmDataLayerScript, document.head.childNodes[0]) }, destroy: () => { + if (!gtmScriptRef) { + return + } + const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] - gtmScriptRef?.remove() + // Unmount GTM script + gtmScriptRef.remove() gtmScriptRef = null - gtmDataLayerScriptRef?.remove() - gtmDataLayerScriptRef = null + // Remove script(s) (gtmScriptRef inserts a script before itself to the DOM) + const scripts = document.querySelectorAll('[src^="https://www.googletagmanager.com/gtm.js"]') + scripts?.forEach((script) => { + script.remove() + }) + + // Empty dataLayer + delete window[DATA_LAYER_NAME] + // Remove cookies const path = '/' const domain = `.${location.host.split('.').slice(-2).join('.')}` GTM_COOKIE_LIST.forEach((cookie) => { diff --git a/src/services/analytics/__tests__/TagManager.test.ts b/src/services/analytics/__tests__/TagManager.test.ts index b112fa1da0..2b3ade0512 100644 --- a/src/services/analytics/__tests__/TagManager.test.ts +++ b/src/services/analytics/__tests__/TagManager.test.ts @@ -1,90 +1,140 @@ -import TagManager, { _getGtmDataLayerScript, _getGtmScript, _getRequiredGtmArgs } from '../TagManager' +import Cookies from 'js-cookie' + +import TagManager, { _getGtmScript } from '../TagManager' const MOCK_ID = 'GTM-123456' +const MOCK_AUTH = 'key123' +const MOCK_PREVIEW = 'env-0' + +jest.mock('js-cookie', () => ({ + remove: jest.fn(), +})) describe('TagManager', () => { beforeEach(() => { - delete window.dataLayer + TagManager.destroy() }) - describe('getRequiredGtmArgs', () => { - it('should assign default arguments', () => { - const result1 = _getRequiredGtmArgs({ gtmId: MOCK_ID }) + describe('getGtmScript', () => { + it('should use the id, auth and preview', () => { + const script1 = _getGtmScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - expect(result1).toStrictEqual({ - gtmId: MOCK_ID, - dataLayer: undefined, - auth: '', - preview: '', - }) + expect(script1.innerHTML).toContain(MOCK_ID) + expect(script1.innerHTML).toContain(`>m_auth=${MOCK_AUTH}`) + expect(script1.innerHTML).toContain(`>m_preview=${MOCK_PREVIEW}`) + expect(script1.innerHTML).toContain('dataLayer') + }) + }) - const result2 = _getRequiredGtmArgs({ gtmId: MOCK_ID, auth: 'abcdefg', preview: 'env-1' }) + describe('TagManager.initialize', () => { + it('should initialize TagManager', () => { + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - expect(result2).toStrictEqual({ - gtmId: MOCK_ID, - dataLayer: undefined, - auth: '>m_auth=abcdefg', - preview: '>m_preview=env-1', - }) + expect(document.head.childNodes).toHaveLength(2) + + // Script added by `_getGtmScript` + // @ts-expect-error + expect(document.head.childNodes[0].src).toBe( + `https://www.googletagmanager.com/gtm.js?id=${MOCK_ID}>m_auth=${MOCK_AUTH}>m_preview=${MOCK_PREVIEW}>m_cookies_win=x`, + ) + + // Manually added script + expect(document.head.childNodes[1]).toStrictEqual( + _getGtmScript({ 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 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) }) }) }) - describe('getGtmScript', () => { - it('should use the id', () => { - const script1 = _getGtmScript({ gtmId: MOCK_ID }) + describe('TagManager.dataLayer', () => { + it("shoult't push to the dataLayer if not initialized", () => { + TagManager.dataLayer({ test: '456' }) - expect(script1.innerHTML).toContain(MOCK_ID) - expect(script1.innerHTML).toContain('dataLayer') + expect(window.dataLayer).toBeUndefined() }) - it('should use the auth and preview if present', () => { - const script1 = _getGtmScript({ + it('should push data to the dataLayer', () => { + expect(window.dataLayer).toBeUndefined() + + TagManager.initialize({ gtmId: MOCK_ID, + auth: MOCK_AUTH, + preview: MOCK_PREVIEW, }) - expect(script1.innerHTML).not.toContain('>m_auth') - expect(script1.innerHTML).not.toContain('>m_preview') + expect(window.dataLayer).toHaveLength(1) + expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) - const script2 = _getGtmScript({ - gtmId: MOCK_ID, - auth: 'abcdefg', - preview: 'env-1', + TagManager.dataLayer({ + test: '123', }) - expect(script2.innerHTML).toContain('>m_auth=abcdefg>m_preview=env-1') + expect(window.dataLayer).toHaveLength(2) + expect(window.dataLayer[1]).toStrictEqual({ test: '123' }) }) }) - describe('getGtmDataLayerScript', () => { - it('should use the `dataLayer` for the script', () => { - const dataLayerScript = _getGtmDataLayerScript({ + describe('TagManager.destroy', () => { + it('should remove the dataLayer', () => { + expect(window.dataLayer).toBeUndefined() + + TagManager.initialize({ gtmId: MOCK_ID, - dataLayer: { foo: 'bar' }, + auth: MOCK_AUTH, + preview: MOCK_PREVIEW, + dataLayer: { + test: '456', + }, }) - expect(dataLayerScript.innerHTML).toContain('{"foo":"bar"}') - }) - }) + 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) }) - describe('TagManager.initialize', () => { - it('should initialize TagManager', () => { - TagManager.initialize({ gtmId: MOCK_ID }) + TagManager.destroy() - expect(window.dataLayer).toHaveLength(1) - expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) + expect(window.dataLayer).toBeUndefined() }) - }) - describe('TagManager.dataLayer', () => { - it('should push data to the dataLayer', () => { - TagManager.dataLayer({ - test: '123', + it('should remove the scripts', () => { + TagManager.initialize({ + gtmId: MOCK_ID, + auth: MOCK_AUTH, + preview: MOCK_PREVIEW, }) - expect(window.dataLayer).toHaveLength(1) - expect(window.dataLayer[0]).toStrictEqual({ - test: '123', + expect(document.head.childNodes).toHaveLength(2) + + TagManager.destroy() + + expect(document.head.childNodes).toHaveLength(0) + }) + + it('should remove the cookies', () => { + TagManager.initialize({ + gtmId: MOCK_ID, + auth: MOCK_AUTH, + preview: MOCK_PREVIEW, }) + + TagManager.destroy() + + const path = '/' + const domain = '.localhost' + + expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain }) + expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain }) + expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain }) }) }) }) diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index 6f5d3d31aa..36b3baa018 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -8,7 +8,7 @@ */ import type { TagManagerArgs } from './TagManager' -import TagManager, { DATA_LAYER_NAME } from './TagManager' +import TagManager from './TagManager' import type { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk' import { IS_PRODUCTION, @@ -65,15 +65,7 @@ export const gtmInit = (): void => { }) } -const isGtmLoaded = (): boolean => { - return typeof window !== 'undefined' && !!window[DATA_LAYER_NAME] -} - -export const gtmClear = (): void => { - if (!isGtmLoaded()) return - - TagManager.destroy() -} +export const gtmClear = TagManager.destroy type GtmEvent = { event: EventType @@ -91,13 +83,7 @@ type PageviewGtmEvent = GtmEvent & { pagePath: string } -const gtmSend = (event: GtmEvent): void => { - console.info('[Analytics]', event) - - if (!isGtmLoaded()) return - - TagManager.dataLayer(event) -} +const gtmSend = TagManager.dataLayer export const gtmTrack = (eventData: AnalyticsEvent): void => { const gtmEvent: ActionGtmEvent = { diff --git a/src/services/analytics/useGtm.ts b/src/services/analytics/useGtm.ts index 327bc17253..78df0aa693 100644 --- a/src/services/analytics/useGtm.ts +++ b/src/services/analytics/useGtm.ts @@ -30,7 +30,7 @@ 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']) { + if (router.pathname !== AppRoutes['404']) { gtmTrackPageview(router.pathname) } }, [isAnalyticsEnabled, router.pathname]) From 55adb4098a2465ae244abbacbf46cf8155e43a5d Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 3 Nov 2022 12:40:51 +0100 Subject: [PATCH 03/11] fix: add dev log --- src/services/analytics/TagManager.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 500becb117..73f0f65d6b 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -1,3 +1,4 @@ +import { IS_PRODUCTION } from '@/config/constants' import Cookies from 'js-cookie' // Based on https://github.com/alinemorelli/react-gtm @@ -69,9 +70,15 @@ const TagManager = { }, dataLayer: (dataLayer: DataLayer) => { // Push to dataLayer if mounted - if (gtmScriptRef && window[DATA_LAYER_NAME]) { - window[DATA_LAYER_NAME].push(dataLayer) + if (!gtmScriptRef || !window[DATA_LAYER_NAME]) { + return + } + + if (!IS_PRODUCTION) { + console.info('[GTM] -', dataLayer) } + + window[DATA_LAYER_NAME].push(dataLayer) }, destroy: () => { if (!gtmScriptRef) { From d3ca65d00de24e4f5342580ee5ffbb1da9c4d7ea Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 3 Nov 2022 12:44:55 +0100 Subject: [PATCH 04/11] Merge branch 'dev' into refactor-gtm --- src/hooks/useChainId.ts | 2 +- src/services/analytics/gtm.ts | 4 +++- src/services/analytics/useGtm.ts | 7 +++++-- src/utils/url.ts | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/hooks/useChainId.ts b/src/hooks/useChainId.ts index 1b1a71fbc6..b7a66bb131 100644 --- a/src/hooks/useChainId.ts +++ b/src/hooks/useChainId.ts @@ -5,6 +5,7 @@ import chains from '@/config/chains' import { useAppSelector } from '@/store' import { selectSession } from '@/store/sessionSlice' import { parsePrefixedAddress } from '@/utils/addresses' +import { prefixedAddressRe } from '@/utils/url' const defaultChainId = IS_PRODUCTION ? chains.eth : chains.gor @@ -15,7 +16,6 @@ const getLocationQuery = (): ParsedUrlQuery => { const query = parse(location.search.slice(1)) if (!query.safe) { - const prefixedAddressRe = /[a-z0-9-]+\:0x[a-f0-9]{40}/i const pathParam = location.pathname.split('/')[1] const safeParam = prefixedAddressRe.test(pathParam) ? pathParam : '' diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index 36b3baa018..d94fceaab6 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -46,7 +46,7 @@ export const gtmSetChainId = (chainId: string): void => { _chainId = chainId } -export const gtmInit = (): void => { +export const gtmInit = (pagePath: string): void => { const GTM_ENVIRONMENT = IS_PRODUCTION ? GTM_ENV_AUTH.LIVE : GTM_ENV_AUTH.DEVELOPMENT if (!GOOGLE_TAG_MANAGER_ID || !GTM_ENVIRONMENT.auth) { @@ -58,6 +58,8 @@ export const gtmInit = (): void => { 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'], diff --git a/src/services/analytics/useGtm.ts b/src/services/analytics/useGtm.ts index 78df0aa693..10fd29db21 100644 --- a/src/services/analytics/useGtm.ts +++ b/src/services/analytics/useGtm.ts @@ -19,7 +19,10 @@ const useGtm = () => { // Initialize GTM, or clear it if analytics is disabled useEffect(() => { - isAnalyticsEnabled ? gtmInit() : gtmClear() + // 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 }, [isAnalyticsEnabled]) // Set the chain ID for GTM @@ -30,7 +33,7 @@ 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 (router.pathname !== AppRoutes['404']) { + if (isAnalyticsEnabled && router.pathname !== AppRoutes['404']) { gtmTrackPageview(router.pathname) } }, [isAnalyticsEnabled, router.pathname]) diff --git a/src/utils/url.ts b/src/utils/url.ts index 5e7435d844..d0322e4748 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -5,7 +5,7 @@ const trimTrailingSlash = (url: string): string => { const isSameUrl = (url1: string, url2: string): boolean => { return trimTrailingSlash(url1) === trimTrailingSlash(url2) } - +export const prefixedAddressRe = /[a-z0-9-]+\:0x[a-f0-9]{40}/i const invalidProtocolRegex = /^(\W*)(javascript|data|vbscript)/im const ctrlCharactersRegex = /[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim const urlSchemeRegex = /^([^:]+):/gm From 732989e414c7ed3539d040eee5b053ecc3c359d4 Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 3 Nov 2022 13:07:27 +0100 Subject: [PATCH 05/11] fix: extract constant --- src/services/analytics/TagManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 73f0f65d6b..7badbec12e 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -85,6 +85,7 @@ const TagManager = { return } + const GTM_SCRIPT = 'https://www.googletagmanager.com/gtm.js' const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] // Unmount GTM script @@ -92,7 +93,7 @@ const TagManager = { gtmScriptRef = null // Remove script(s) (gtmScriptRef inserts a script before itself to the DOM) - const scripts = document.querySelectorAll('[src^="https://www.googletagmanager.com/gtm.js"]') + const scripts = document.querySelectorAll(`[src^="${GTM_SCRIPT}"]`) scripts?.forEach((script) => { script.remove() }) From d0db4661e8d1a684fdc78f21f17535feee7bf24c Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 10 Nov 2022 11:05:57 +0100 Subject: [PATCH 06/11] fix: remove all GTM scripts --- src/services/analytics/TagManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 7badbec12e..dd2d4ebc06 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -85,7 +85,7 @@ const TagManager = { return } - const GTM_SCRIPT = 'https://www.googletagmanager.com/gtm.js' + const GTM_SCRIPT = 'https://www.googletagmanager.com' const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] // Unmount GTM script From 0c84c2cfa5c1b426d9029fef537310a64b87a3b4 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 11 Nov 2022 16:48:05 +0100 Subject: [PATCH 07/11] fix: disable GTM/GA programmatically --- src/config/constants.ts | 3 + src/definitions.d.ts | 2 + src/services/analytics/TagManager.ts | 90 ++++++++++--------- .../analytics/__tests__/TagManager.test.ts | 90 +++++++++++++------ src/services/analytics/gtm.ts | 2 +- 5 files changed, 117 insertions(+), 70 deletions(-) diff --git a/src/config/constants.ts b/src/config/constants.ts index a8c5d38ee0..28828eca8f 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -45,6 +45,9 @@ export const GOOGLE_TAG_MANAGER_AUTH_LIVE = process.env.NEXT_PUBLIC_GOOGLE_TAG_M export const GOOGLE_TAG_MANAGER_AUTH_LATEST = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH || '' export const GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH || '' +// Google Analytics +export const GOOGLE_ANALYTICS_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID || '' + // Tenderly - API docs: https://www.notion.so/Simulate-API-Documentation-6f7009fe6d1a48c999ffeb7941efc104 export const TENDERLY_SIMULATE_ENDPOINT_URL = process.env.NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL || '' export const TENDERLY_PROJECT_NAME = process.env.NEXT_PUBLIC_TENDERLY_PROJECT_NAME || '' diff --git a/src/definitions.d.ts b/src/definitions.d.ts index c2945fcc3c..064ab20685 100644 --- a/src/definitions.d.ts +++ b/src/definitions.d.ts @@ -1,5 +1,6 @@ import type React from 'react' import type { BeamerConfig, BeamerMethods } from '@services/beamer/types' +import { GOOGLE_ANALYTICS_MEASUREMENT_ID } from '@/config/constants' declare global { interface Window { @@ -15,6 +16,7 @@ declare global { Beamer?: BeamerMethods dataLayer?: DataLayerArgs['dataLayer'] Cypress? + [`ga-disable-${GOOGLE_ANALYTICS_MEASUREMENT_ID}`]?: boolean } } diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index dd2d4ebc06..9008a11802 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -1,26 +1,16 @@ -import { IS_PRODUCTION } from '@/config/constants' +import { GOOGLE_ANALYTICS_MEASUREMENT_ID, IS_PRODUCTION } from '@/config/constants' import Cookies from 'js-cookie' -// Based on https://github.com/alinemorelli/react-gtm - type DataLayer = Record export type TagManagerArgs = { - /** - * GTM id, must be something like GTM-000000. - */ + // GTM id, e.g. GTM-000000 gtmId: string - /** - * Used to set environments. - */ + // GTM authetication key auth: string - /** - * Used to set environments, something like env-00. - */ + // GTM environment, e.g. env-00. preview: string - /** - * Object that contains all of the information that you want to pass to Google Tag Manager. - */ + // Object that contains all of the information that you want to pass to GTM dataLayer?: DataLayer } @@ -48,20 +38,37 @@ export const _getGtmScript = ({ gtmId, auth, preview }: TagManagerArgs) => { return script } +// https://developers.google.com/tag-platform/devguides/privacy#turn_off_google_analytics +const GA_DISABLE_KEY = `ga-disable-${GOOGLE_ANALYTICS_MEASUREMENT_ID}` +const GTM_DISABLE_TRIGGER_COOKIE_NAME = 'google-analytics-opt-out' + +// Injected GTM script singleton let gtmScriptRef: HTMLScriptElement | null = null const TagManager = { + isEnabled: () => { + // @ts-expect-error - Element implicitly has an 'any' type because index expression is not of type 'number'. + return Cookies.get(GTM_DISABLE_TRIGGER_COOKIE_NAME) === undefined && window[GA_DISABLE_KEY] === false + }, initialize: (args: TagManagerArgs) => { - if (gtmScriptRef) { + if (TagManager.isEnabled()) { return } - // Push configuration to dataLayer - if (args.dataLayer) { - window[DATA_LAYER_NAME] = [] - window[DATA_LAYER_NAME].push(args.dataLayer) + // Enable GA + // @ts-expect-error - Element implicitly has an 'any' type because index expression is not of type 'number'. + window[GA_DISABLE_KEY] = false + + // Enable GTM triggers + Cookies.remove(GTM_DISABLE_TRIGGER_COOKIE_NAME, { path: '/' }) + + if (gtmScriptRef) { + return } + // Initialize dataLayer (with configuration) + window[DATA_LAYER_NAME] = args.dataLayer ? [args.dataLayer] : [] + gtmScriptRef = _getGtmScript(args) // Initialize GTM. This pushes the default dataLayer event: @@ -69,44 +76,39 @@ const TagManager = { document.head.insertBefore(gtmScriptRef, document.head.childNodes[0]) }, dataLayer: (dataLayer: DataLayer) => { - // Push to dataLayer if mounted - if (!gtmScriptRef || !window[DATA_LAYER_NAME]) { + if (!TagManager.isEnabled()) { return } + window[DATA_LAYER_NAME].push(dataLayer) + if (!IS_PRODUCTION) { console.info('[GTM] -', dataLayer) } - - window[DATA_LAYER_NAME].push(dataLayer) }, - destroy: () => { - if (!gtmScriptRef) { + disable: () => { + if (!TagManager.isEnabled()) { return } - const GTM_SCRIPT = 'https://www.googletagmanager.com' - const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] - - // Unmount GTM script - gtmScriptRef.remove() - gtmScriptRef = null + // Disable GA + // @ts-expect-error - Element implicitly has an 'any' type because index expression is not of type 'number'. + window[GA_DISABLE_KEY] = true - // Remove script(s) (gtmScriptRef inserts a script before itself to the DOM) - const scripts = document.querySelectorAll(`[src^="${GTM_SCRIPT}"]`) - scripts?.forEach((script) => { - script.remove() + // Disable GTM triggers + Cookies.set(GTM_DISABLE_TRIGGER_COOKIE_NAME, 'true', { + expires: Number.MAX_SAFE_INTEGER, + path: '/', }) - // Empty dataLayer - delete window[DATA_LAYER_NAME] + // const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] - // Remove cookies - const path = '/' - const domain = `.${location.host.split('.').slice(-2).join('.')}` - GTM_COOKIE_LIST.forEach((cookie) => { - Cookies.remove(cookie, { path, domain }) - }) + // GTM_COOKIE_LIST.forEach((cookie) => { + // Cookies.remove(cookie, { + // path: '/', + // domain: `.${location.host.split('.').slice(-2).join('.')}`, + // }) + // }) }, } diff --git a/src/services/analytics/__tests__/TagManager.test.ts b/src/services/analytics/__tests__/TagManager.test.ts index 2b3ade0512..8978e31196 100644 --- a/src/services/analytics/__tests__/TagManager.test.ts +++ b/src/services/analytics/__tests__/TagManager.test.ts @@ -1,22 +1,29 @@ import Cookies from 'js-cookie' -import TagManager, { _getGtmScript } from '../TagManager' +import * as constants from '@/config/constants' const MOCK_ID = 'GTM-123456' const MOCK_AUTH = 'key123' const MOCK_PREVIEW = 'env-0' +const MOCK_MEASUREMENT_ID = 'UA-123456-1' + jest.mock('js-cookie', () => ({ remove: jest.fn(), + set: jest.fn(), + get: jest.fn(), })) -describe('TagManager', () => { - beforeEach(() => { - TagManager.destroy() - }) +jest.mock('@/config/constants', () => ({ + get GOOGLE_ANALYTICS_MEASUREMENT_ID() { + return MOCK_MEASUREMENT_ID + }, +})) +describe('TagManager', () => { describe('getGtmScript', () => { it('should use the id, auth and preview', () => { + const { _getGtmScript } = require('../TagManager') const script1 = _getGtmScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) expect(script1.innerHTML).toContain(MOCK_ID) @@ -27,7 +34,19 @@ describe('TagManager', () => { }) describe('TagManager.initialize', () => { + it('should not initialize TagManager if already initialized', () => { + const { default: TagManager } = require('../TagManager') + + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + + expect(document.head.childNodes).toHaveLength(2) + }) + it('should initialize TagManager', () => { + const { default: TagManager, _getGtmScript } = require('../TagManager') + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) expect(document.head.childNodes).toHaveLength(2) @@ -45,9 +64,16 @@ describe('TagManager', () => { expect(window.dataLayer).toHaveLength(1) expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) + + // @ts-expect-error + expect(window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`]).toBe(false) + + expect(Cookies.remove).toHaveBeenCalledWith('google-analytics-opt-out', { path: '/' }) }) it('should push to the dataLayer if povided', () => { + const { default: TagManager } = require('../TagManager') + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW, dataLayer: { test: '456' } }) expect(window.dataLayer).toHaveLength(2) @@ -57,13 +83,17 @@ describe('TagManager', () => { }) describe('TagManager.dataLayer', () => { - it("shoult't push to the dataLayer if not initialized", () => { + it('should not push to the dataLayer if not initialized', () => { + const { default: TagManager } = require('../TagManager') + TagManager.dataLayer({ test: '456' }) expect(window.dataLayer).toBeUndefined() }) it('should push data to the dataLayer', () => { + const { default: TagManager } = require('../TagManager') + expect(window.dataLayer).toBeUndefined() TagManager.initialize({ @@ -84,50 +114,60 @@ describe('TagManager', () => { }) }) - describe('TagManager.destroy', () => { - it('should remove the dataLayer', () => { - expect(window.dataLayer).toBeUndefined() + describe('TagManager.disable', () => { + it('should not disable TagManager if not initialized', () => { + const { default: TagManager } = require('../TagManager') + + TagManager.disable() + + // @ts-expect-error + expect(window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`]).toBeUndefined() + + expect(Cookies.set).not.toHaveBeenCalled() + }) + + it('should disable GA', () => { + const { default: TagManager } = require('../TagManager') 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) }) - - TagManager.destroy() + TagManager.disable() - expect(window.dataLayer).toBeUndefined() + // @ts-expect-error + expect(window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`]).toBe(true) }) - it('should remove the scripts', () => { + it('should disable GTM triggers', () => { + const { default: TagManager } = require('../TagManager') + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW, }) - expect(document.head.childNodes).toHaveLength(2) - - TagManager.destroy() + TagManager.disable() - expect(document.head.childNodes).toHaveLength(0) + expect(Cookies.set).toHaveBeenCalledWith('google-analytics-opt-out', 'true', { + expires: Number.MAX_SAFE_INTEGER, + path: '/', + }) }) - it('should remove the cookies', () => { + it.skip('should remove the GA cookies', () => { + const { default: TagManager } = require('../TagManager') + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW, }) - TagManager.destroy() + TagManager.disable() const path = '/' const domain = '.localhost' diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index 59bb52a495..f54bc4cc98 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -67,7 +67,7 @@ export const gtmInit = (pagePath: string): void => { }) } -export const gtmClear = TagManager.destroy +export const gtmClear = TagManager.disable type GtmEvent = { event: EventType From 63c372e2b81295281284cbeecf4a1765d156e744 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 18 Nov 2022 11:23:55 +0100 Subject: [PATCH 08/11] fix: remove cookies --- src/services/analytics/TagManager.ts | 25 +++++++++++++++---------- src/services/analytics/gtm.ts | 2 -- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 9008a11802..3029ca0384 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -50,6 +50,9 @@ const TagManager = { // @ts-expect-error - Element implicitly has an 'any' type because index expression is not of type 'number'. return Cookies.get(GTM_DISABLE_TRIGGER_COOKIE_NAME) === undefined && window[GA_DISABLE_KEY] === false }, + isMounted: () => { + return GA_DISABLE_KEY in window && gtmScriptRef + }, initialize: (args: TagManagerArgs) => { if (TagManager.isEnabled()) { return @@ -76,7 +79,7 @@ const TagManager = { document.head.insertBefore(gtmScriptRef, document.head.childNodes[0]) }, dataLayer: (dataLayer: DataLayer) => { - if (!TagManager.isEnabled()) { + if (!TagManager.isEnabled() || !TagManager.isMounted()) { return } @@ -87,6 +90,17 @@ const TagManager = { } }, disable: () => { + if (!TagManager.isMounted()) { + const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] + + GTM_COOKIE_LIST.forEach((cookie) => { + Cookies.remove(cookie, { + path: '/', + domain: `.${location.host.split('.').slice(-2).join('.')}`, + }) + }) + } + if (!TagManager.isEnabled()) { return } @@ -100,15 +114,6 @@ const TagManager = { expires: Number.MAX_SAFE_INTEGER, path: '/', }) - - // const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] - - // GTM_COOKIE_LIST.forEach((cookie) => { - // Cookies.remove(cookie, { - // path: '/', - // domain: `.${location.host.split('.').slice(-2).join('.')}`, - // }) - // }) }, } diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index f54bc4cc98..93cbdde7a9 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -23,8 +23,6 @@ import { SAFE_APPS_SDK_CATEGORY } from './events' type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' type GTMEnvironmentArgs = Required> -const EMPTY_SAFE_APP = 'unknown' - const GTM_ENV_AUTH: Record = { LIVE: { auth: GOOGLE_TAG_MANAGER_AUTH_LIVE, From 9822f0ac7839189f326a73e7b2b50c90ecabcb93 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 18 Nov 2022 11:42:34 +0100 Subject: [PATCH 09/11] fix: adjust tests --- .../analytics/__tests__/TagManager.test.ts | 111 +++++++++++++++--- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/src/services/analytics/__tests__/TagManager.test.ts b/src/services/analytics/__tests__/TagManager.test.ts index 8978e31196..b1b9891f00 100644 --- a/src/services/analytics/__tests__/TagManager.test.ts +++ b/src/services/analytics/__tests__/TagManager.test.ts @@ -21,6 +21,14 @@ jest.mock('@/config/constants', () => ({ })) describe('TagManager', () => { + beforeEach(() => { + jest.resetModules() + + // @ts-expect-error + delete window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`] + delete window['dataLayer'] + }) + describe('getGtmScript', () => { it('should use the id, auth and preview', () => { const { _getGtmScript } = require('../TagManager') @@ -33,6 +41,61 @@ describe('TagManager', () => { }) }) + describe('TagManager.isEnabled', () => { + it('returns true if the cookie is not set and the GA_DISABLE_KEY is false', () => { + const { TagManager } = require('../TagManager') + ;(Cookies.get as jest.Mock).mockReturnValue(undefined) + // @ts-expect-error + window[constants.GA_DISABLE_KEY] = false + + expect(TagManager.isEnabled()).toBe(true) + }) + + it('returns false if the cookie is set and the GA_DISABLE_KEY is false', () => { + const { TagManager } = require('../TagManager') + ;(Cookies.get as jest.Mock).mockReturnValue('true') + // @ts-expect-error + window[constants.GA_DISABLE_KEY] = false + + expect(TagManager.isEnabled()).toBe(false) + }) + + it('returns false if the cookie is not set and the GA_DISABLE_KEY is true', () => { + const { TagManager } = require('../TagManager') + ;(Cookies.get as jest.Mock).mockReturnValue(undefined) + // @ts-expect-error + window[constants.GA_DISABLE_KEY] = true + + expect(TagManager.isEnabled()).toBe(false) + }) + }) + + describe('TagManager.isMounted', () => { + it('returns true if the GA_DISABLE_KEY is in window and the gtmScriptRef is set', () => { + const { TagManager } = require('../TagManager') + // @ts-expect-error + window[constants.GA_DISABLE_KEY] = true + + expect(TagManager.isMounted()).toBe(true) + }) + + it('returns false if the GA_DISABLE_KEY is not in window and the gtmScriptRef is set', () => { + const { TagManager } = require('../TagManager') + // @ts-expect-error + window[constants.GA_DISABLE_KEY] = false + + expect(TagManager.isMounted()).toBe(false) + }) + + it('returns false if the GA_DISABLE_KEY is in window and the gtmScriptRef is not set', () => { + const { TagManager } = require('../TagManager') + // @ts-expect-error + window[constants.GA_DISABLE_KEY] = true + + expect(TagManager.isMounted()).toBe(false) + }) + }) + describe('TagManager.initialize', () => { it('should not initialize TagManager if already initialized', () => { const { default: TagManager } = require('../TagManager') @@ -115,6 +178,35 @@ describe('TagManager', () => { }) describe('TagManager.disable', () => { + it('should not remove GA cookies if mounted', () => { + const { default: TagManager } = require('../TagManager') + + TagManager.initialize({ + gtmId: MOCK_ID, + auth: MOCK_AUTH, + preview: MOCK_PREVIEW, + }) + + TagManager.disable() + + const path = '/' + const domain = '.localhost' + + expect(Cookies.remove).not.toHaveBeenCalled() + }) + it('should remove GA cookies if not mounted', () => { + const { default: TagManager } = require('../TagManager') + + TagManager.disable() + + const path = '/' + const domain = '.localhost' + + expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain }) + expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain }) + expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain }) + }) + it('should not disable TagManager if not initialized', () => { const { default: TagManager } = require('../TagManager') @@ -157,24 +249,5 @@ describe('TagManager', () => { path: '/', }) }) - - it.skip('should remove the GA cookies', () => { - const { default: TagManager } = require('../TagManager') - - TagManager.initialize({ - gtmId: MOCK_ID, - auth: MOCK_AUTH, - preview: MOCK_PREVIEW, - }) - - TagManager.disable() - - const path = '/' - const domain = '.localhost' - - expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain }) - expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain }) - expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain }) - }) }) }) From 178e172f3f77c1734965fb68ea4ee41eb9d20432 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 18 Nov 2022 16:35:43 +0100 Subject: [PATCH 10/11] fix: reload instead of using flag --- src/config/constants.ts | 3 - src/definitions.d.ts | 2 - src/services/analytics/TagManager.ts | 68 +++----- .../analytics/__tests__/TagManager.test.ts | 156 +++--------------- 4 files changed, 41 insertions(+), 188 deletions(-) diff --git a/src/config/constants.ts b/src/config/constants.ts index 28828eca8f..a8c5d38ee0 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -45,9 +45,6 @@ export const GOOGLE_TAG_MANAGER_AUTH_LIVE = process.env.NEXT_PUBLIC_GOOGLE_TAG_M export const GOOGLE_TAG_MANAGER_AUTH_LATEST = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_LATEST_AUTH || '' export const GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH = process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_DEVELOPMENT_AUTH || '' -// Google Analytics -export const GOOGLE_ANALYTICS_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID || '' - // Tenderly - API docs: https://www.notion.so/Simulate-API-Documentation-6f7009fe6d1a48c999ffeb7941efc104 export const TENDERLY_SIMULATE_ENDPOINT_URL = process.env.NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL || '' export const TENDERLY_PROJECT_NAME = process.env.NEXT_PUBLIC_TENDERLY_PROJECT_NAME || '' diff --git a/src/definitions.d.ts b/src/definitions.d.ts index 064ab20685..c2945fcc3c 100644 --- a/src/definitions.d.ts +++ b/src/definitions.d.ts @@ -1,6 +1,5 @@ import type React from 'react' import type { BeamerConfig, BeamerMethods } from '@services/beamer/types' -import { GOOGLE_ANALYTICS_MEASUREMENT_ID } from '@/config/constants' declare global { interface Window { @@ -16,7 +15,6 @@ declare global { Beamer?: BeamerMethods dataLayer?: DataLayerArgs['dataLayer'] Cypress? - [`ga-disable-${GOOGLE_ANALYTICS_MEASUREMENT_ID}`]?: boolean } } diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index 3029ca0384..cf33658c63 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -1,6 +1,7 @@ -import { GOOGLE_ANALYTICS_MEASUREMENT_ID, IS_PRODUCTION } from '@/config/constants' import Cookies from 'js-cookie' +import { IS_PRODUCTION } from '@/config/constants' + type DataLayer = Record export type TagManagerArgs = { @@ -38,48 +39,28 @@ export const _getGtmScript = ({ gtmId, auth, preview }: TagManagerArgs) => { return script } -// https://developers.google.com/tag-platform/devguides/privacy#turn_off_google_analytics -const GA_DISABLE_KEY = `ga-disable-${GOOGLE_ANALYTICS_MEASUREMENT_ID}` -const GTM_DISABLE_TRIGGER_COOKIE_NAME = 'google-analytics-opt-out' - -// Injected GTM script singleton -let gtmScriptRef: HTMLScriptElement | null = null - const TagManager = { - isEnabled: () => { - // @ts-expect-error - Element implicitly has an 'any' type because index expression is not of type 'number'. - return Cookies.get(GTM_DISABLE_TRIGGER_COOKIE_NAME) === undefined && window[GA_DISABLE_KEY] === false - }, - isMounted: () => { - return GA_DISABLE_KEY in window && gtmScriptRef + isInitialized: () => { + const GTM_SCRIPT = 'https://www.googletagmanager.com/gtm.js' + + return !!document.querySelector(`[src^="${GTM_SCRIPT}"]`) }, initialize: (args: TagManagerArgs) => { - if (TagManager.isEnabled()) { - return - } - - // Enable GA - // @ts-expect-error - Element implicitly has an 'any' type because index expression is not of type 'number'. - window[GA_DISABLE_KEY] = false - - // Enable GTM triggers - Cookies.remove(GTM_DISABLE_TRIGGER_COOKIE_NAME, { path: '/' }) - - if (gtmScriptRef) { + if (TagManager.isInitialized()) { return } // Initialize dataLayer (with configuration) window[DATA_LAYER_NAME] = args.dataLayer ? [args.dataLayer] : [] - gtmScriptRef = _getGtmScript(args) + const script = _getGtmScript(args) // Initialize GTM. This pushes the default dataLayer event: // { "gtm.start": new Date().getTime(), event: "gtm.js" } - document.head.insertBefore(gtmScriptRef, document.head.childNodes[0]) + document.head.insertBefore(script, document.head.childNodes[0]) }, dataLayer: (dataLayer: DataLayer) => { - if (!TagManager.isEnabled() || !TagManager.isMounted()) { + if (!TagManager.isInitialized()) { return } @@ -90,30 +71,21 @@ const TagManager = { } }, disable: () => { - if (!TagManager.isMounted()) { - const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] - - GTM_COOKIE_LIST.forEach((cookie) => { - Cookies.remove(cookie, { - path: '/', - domain: `.${location.host.split('.').slice(-2).join('.')}`, - }) - }) - } - - if (!TagManager.isEnabled()) { + if (!TagManager.isInitialized()) { return } - // Disable GA - // @ts-expect-error - Element implicitly has an 'any' type because index expression is not of type 'number'. - window[GA_DISABLE_KEY] = true + const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid'] - // Disable GTM triggers - Cookies.set(GTM_DISABLE_TRIGGER_COOKIE_NAME, 'true', { - expires: Number.MAX_SAFE_INTEGER, - path: '/', + GTM_COOKIE_LIST.forEach((cookie) => { + Cookies.remove(cookie, { + path: '/', + domain: `.${location.host.split('.').slice(-2).join('.')}`, + }) }) + + // `gtmScriptRef` will remain in memory until a new session + location.reload() }, } diff --git a/src/services/analytics/__tests__/TagManager.test.ts b/src/services/analytics/__tests__/TagManager.test.ts index b1b9891f00..ba2ba65743 100644 --- a/src/services/analytics/__tests__/TagManager.test.ts +++ b/src/services/analytics/__tests__/TagManager.test.ts @@ -1,37 +1,26 @@ import Cookies from 'js-cookie' -import * as constants from '@/config/constants' +import * as gtm from '../TagManager' + +const { default: TagManager, _getGtmScript } = gtm const MOCK_ID = 'GTM-123456' const MOCK_AUTH = 'key123' const MOCK_PREVIEW = 'env-0' -const MOCK_MEASUREMENT_ID = 'UA-123456-1' - jest.mock('js-cookie', () => ({ remove: jest.fn(), set: jest.fn(), get: jest.fn(), })) -jest.mock('@/config/constants', () => ({ - get GOOGLE_ANALYTICS_MEASUREMENT_ID() { - return MOCK_MEASUREMENT_ID - }, -})) - describe('TagManager', () => { beforeEach(() => { jest.resetModules() - - // @ts-expect-error - delete window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`] - delete window['dataLayer'] }) describe('getGtmScript', () => { it('should use the id, auth and preview', () => { - const { _getGtmScript } = require('../TagManager') const script1 = _getGtmScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) expect(script1.innerHTML).toContain(MOCK_ID) @@ -41,75 +30,20 @@ describe('TagManager', () => { }) }) - describe('TagManager.isEnabled', () => { - it('returns true if the cookie is not set and the GA_DISABLE_KEY is false', () => { - const { TagManager } = require('../TagManager') - ;(Cookies.get as jest.Mock).mockReturnValue(undefined) - // @ts-expect-error - window[constants.GA_DISABLE_KEY] = false - - expect(TagManager.isEnabled()).toBe(true) - }) - - it('returns false if the cookie is set and the GA_DISABLE_KEY is false', () => { - const { TagManager } = require('../TagManager') - ;(Cookies.get as jest.Mock).mockReturnValue('true') - // @ts-expect-error - window[constants.GA_DISABLE_KEY] = false - - expect(TagManager.isEnabled()).toBe(false) - }) - - it('returns false if the cookie is not set and the GA_DISABLE_KEY is true', () => { - const { TagManager } = require('../TagManager') - ;(Cookies.get as jest.Mock).mockReturnValue(undefined) - // @ts-expect-error - window[constants.GA_DISABLE_KEY] = true - - expect(TagManager.isEnabled()).toBe(false) - }) - }) - - describe('TagManager.isMounted', () => { - it('returns true if the GA_DISABLE_KEY is in window and the gtmScriptRef is set', () => { - const { TagManager } = require('../TagManager') - // @ts-expect-error - window[constants.GA_DISABLE_KEY] = true - - expect(TagManager.isMounted()).toBe(true) + describe('TagManager.isInitialized', () => { + it('should return false if no script is found', () => { + expect(TagManager.isInitialized()).toBe(false) }) - it('returns false if the GA_DISABLE_KEY is not in window and the gtmScriptRef is set', () => { - const { TagManager } = require('../TagManager') - // @ts-expect-error - window[constants.GA_DISABLE_KEY] = false - - expect(TagManager.isMounted()).toBe(false) - }) - - it('returns false if the GA_DISABLE_KEY is in window and the gtmScriptRef is not set', () => { - const { TagManager } = require('../TagManager') - // @ts-expect-error - window[constants.GA_DISABLE_KEY] = true + it('should return true if a script is found', () => { + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - expect(TagManager.isMounted()).toBe(false) + expect(TagManager.isInitialized()).toBe(true) }) }) describe('TagManager.initialize', () => { - it('should not initialize TagManager if already initialized', () => { - const { default: TagManager } = require('../TagManager') - - TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - - expect(document.head.childNodes).toHaveLength(2) - }) - it('should initialize TagManager', () => { - const { default: TagManager, _getGtmScript } = require('../TagManager') - TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) expect(document.head.childNodes).toHaveLength(2) @@ -127,16 +61,18 @@ describe('TagManager', () => { expect(window.dataLayer).toHaveLength(1) expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) }) + }) - // @ts-expect-error - expect(window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`]).toBe(false) + it('should not re-initialize the scripts if previously enabled', () => { + const getGtmScriptSpy = jest.spyOn(gtm, '_getGtmScript') + + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) - expect(Cookies.remove).toHaveBeenCalledWith('google-analytics-opt-out', { path: '/' }) + expect(getGtmScriptSpy).toHaveBeenCalledTimes(1) }) it('should push to the dataLayer if povided', () => { - const { default: TagManager } = require('../TagManager') - TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW, dataLayer: { test: '456' } }) expect(window.dataLayer).toHaveLength(2) @@ -147,16 +83,12 @@ describe('TagManager', () => { describe('TagManager.dataLayer', () => { it('should not push to the dataLayer if not initialized', () => { - const { default: TagManager } = require('../TagManager') - TagManager.dataLayer({ test: '456' }) expect(window.dataLayer).toBeUndefined() }) it('should push data to the dataLayer', () => { - const { default: TagManager } = require('../TagManager') - expect(window.dataLayer).toBeUndefined() TagManager.initialize({ @@ -178,9 +110,7 @@ describe('TagManager', () => { }) describe('TagManager.disable', () => { - it('should not remove GA cookies if mounted', () => { - const { default: TagManager } = require('../TagManager') - + it('should not remove GA cookies and reload if mounted', () => { TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, @@ -189,14 +119,11 @@ describe('TagManager', () => { TagManager.disable() - const path = '/' - const domain = '.localhost' - expect(Cookies.remove).not.toHaveBeenCalled() - }) - it('should remove GA cookies if not mounted', () => { - const { default: TagManager } = require('../TagManager') + expect(location.reload).not.toHaveBeenCalled() + }) + it('should remove GA cookies', () => { TagManager.disable() const path = '/' @@ -205,49 +132,8 @@ describe('TagManager', () => { expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain }) expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain }) expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain }) - }) - it('should not disable TagManager if not initialized', () => { - const { default: TagManager } = require('../TagManager') - - TagManager.disable() - - // @ts-expect-error - expect(window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`]).toBeUndefined() - - expect(Cookies.set).not.toHaveBeenCalled() - }) - - it('should disable GA', () => { - const { default: TagManager } = require('../TagManager') - - TagManager.initialize({ - gtmId: MOCK_ID, - auth: MOCK_AUTH, - preview: MOCK_PREVIEW, - }) - - TagManager.disable() - - // @ts-expect-error - expect(window[`ga-disable-${constants.GOOGLE_ANALYTICS_MEASUREMENT_ID}`]).toBe(true) - }) - - it('should disable GTM triggers', () => { - const { default: TagManager } = require('../TagManager') - - TagManager.initialize({ - gtmId: MOCK_ID, - auth: MOCK_AUTH, - preview: MOCK_PREVIEW, - }) - - TagManager.disable() - - expect(Cookies.set).toHaveBeenCalledWith('google-analytics-opt-out', 'true', { - expires: Number.MAX_SAFE_INTEGER, - path: '/', - }) + expect(location.reload).toHaveBeenCalled() }) }) }) From c0ca8bcf312022774ae5229ffb977df7d22c54cd Mon Sep 17 00:00:00 2001 From: iamacook Date: Sun, 20 Nov 2022 16:31:50 +0100 Subject: [PATCH 11/11] fix: tests --- src/services/analytics/TagManager.ts | 22 +++---- .../analytics/__tests__/TagManager.test.ts | 63 ++++++++++++------- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src/services/analytics/TagManager.ts b/src/services/analytics/TagManager.ts index cf33658c63..bbe796dee6 100644 --- a/src/services/analytics/TagManager.ts +++ b/src/services/analytics/TagManager.ts @@ -17,12 +17,12 @@ export type TagManagerArgs = { const DATA_LAYER_NAME = 'dataLayer' -// Initialization scripts - -export const _getGtmScript = ({ gtmId, auth, preview }: TagManagerArgs) => { - const script = document.createElement('script') +const TagManager = { + // `jest.spyOn` is not possible if outside of `TagManager` + _getScript: ({ gtmId, auth, preview }: TagManagerArgs) => { + const script = document.createElement('script') - const gtmScript = ` + const gtmScript = ` (function (w, d, s, l, i) { w[l] = w[l] || []; w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); @@ -34,12 +34,10 @@ export const _getGtmScript = ({ gtmId, auth, preview }: TagManagerArgs) => { f.parentNode.insertBefore(j, f); })(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');` - script.innerHTML = gtmScript - - return script -} + script.innerHTML = gtmScript -const TagManager = { + return script + }, isInitialized: () => { const GTM_SCRIPT = 'https://www.googletagmanager.com/gtm.js' @@ -53,7 +51,7 @@ const TagManager = { // Initialize dataLayer (with configuration) window[DATA_LAYER_NAME] = args.dataLayer ? [args.dataLayer] : [] - const script = _getGtmScript(args) + const script = TagManager._getScript(args) // Initialize GTM. This pushes the default dataLayer event: // { "gtm.start": new Date().getTime(), event: "gtm.js" } @@ -84,7 +82,7 @@ const TagManager = { }) }) - // `gtmScriptRef` will remain in memory until a new session + // Injected script will remain in memory until new session location.reload() }, } diff --git a/src/services/analytics/__tests__/TagManager.test.ts b/src/services/analytics/__tests__/TagManager.test.ts index ba2ba65743..7ff218a23c 100644 --- a/src/services/analytics/__tests__/TagManager.test.ts +++ b/src/services/analytics/__tests__/TagManager.test.ts @@ -2,7 +2,7 @@ import Cookies from 'js-cookie' import * as gtm from '../TagManager' -const { default: TagManager, _getGtmScript } = gtm +const { default: TagManager } = gtm const MOCK_ID = 'GTM-123456' const MOCK_AUTH = 'key123' @@ -10,18 +10,39 @@ const MOCK_PREVIEW = 'env-0' jest.mock('js-cookie', () => ({ remove: jest.fn(), - set: jest.fn(), - get: jest.fn(), })) describe('TagManager', () => { - beforeEach(() => { - jest.resetModules() + const originalLocation = window.location + + // Mock `location.reload` + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + reload: jest.fn(), + }, + }) + }) + + // Remove mock + afterAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) }) - describe('getGtmScript', () => { + // Clear GTM between tests + afterEach(() => { + document.head.innerHTML = '' + delete window.dataLayer + }) + + describe('TagManager._getScript', () => { it('should use the id, auth and preview', () => { - const script1 = _getGtmScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) + const script1 = TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }) expect(script1.innerHTML).toContain(MOCK_ID) expect(script1.innerHTML).toContain(`>m_auth=${MOCK_AUTH}`) @@ -48,7 +69,7 @@ describe('TagManager', () => { expect(document.head.childNodes).toHaveLength(2) - // Script added by `_getGtmScript` + // Script added by `TagManager._getScript` // @ts-expect-error expect(document.head.childNodes[0].src).toBe( `https://www.googletagmanager.com/gtm.js?id=${MOCK_ID}>m_auth=${MOCK_AUTH}>m_preview=${MOCK_PREVIEW}>m_cookies_win=x`, @@ -56,20 +77,20 @@ describe('TagManager', () => { // Manually added script expect(document.head.childNodes[1]).toStrictEqual( - _getGtmScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }), + 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', () => { - const getGtmScriptSpy = jest.spyOn(gtm, '_getGtmScript') + 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(getGtmScriptSpy).toHaveBeenCalledTimes(1) + expect(getScriptSpy).toHaveBeenCalledTimes(1) }) it('should push to the dataLayer if povided', () => { @@ -110,7 +131,14 @@ describe('TagManager', () => { }) describe('TagManager.disable', () => { - it('should not remove GA cookies and reload if mounted', () => { + 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', () => { TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, @@ -119,13 +147,6 @@ describe('TagManager', () => { TagManager.disable() - expect(Cookies.remove).not.toHaveBeenCalled() - - expect(location.reload).not.toHaveBeenCalled() - }) - it('should remove GA cookies', () => { - TagManager.disable() - const path = '/' const domain = '.localhost' @@ -133,7 +154,7 @@ describe('TagManager', () => { expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain }) expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain }) - expect(location.reload).toHaveBeenCalled() + expect(global.location.reload).toHaveBeenCalled() }) }) })