diff --git a/common/changes/@snowplow/browser-plugin-ga-cookies/feature-ga-cookies-plugin-ga4-cookies-addition_2023-08-08-14-04.json b/common/changes/@snowplow/browser-plugin-ga-cookies/feature-ga-cookies-plugin-ga4-cookies-addition_2023-08-08-14-04.json new file mode 100644 index 000000000..7273f3046 --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-ga-cookies/feature-ga-cookies-plugin-ga4-cookies-addition_2023-08-08-14-04.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-plugin-ga-cookies", + "comment": "Update to include options for Google Analytics 4 cookies", + "type": "none" + } + ], + "packageName": "@snowplow/browser-plugin-ga-cookies" +} \ No newline at end of file diff --git a/common/changes/@snowplow/javascript-tracker/feature-1225-ga-cookies-plugin-ga4-cookies-addition_2023-08-14-14-18.json b/common/changes/@snowplow/javascript-tracker/feature-1225-ga-cookies-plugin-ga4-cookies-addition_2023-08-14-14-18.json new file mode 100644 index 000000000..e3ff286e4 --- /dev/null +++ b/common/changes/@snowplow/javascript-tracker/feature-1225-ga-cookies-plugin-ga4-cookies-addition_2023-08-14-14-18.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/javascript-tracker", + "comment": "Add further options for the gaCookies context", + "type": "none" + } + ], + "packageName": "@snowplow/javascript-tracker" +} \ No newline at end of file diff --git a/plugins/browser-plugin-ga-cookies/README.md b/plugins/browser-plugin-ga-cookies/README.md index 098e8283b..14c4571ce 100644 --- a/plugins/browser-plugin-ga-cookies/README.md +++ b/plugins/browser-plugin-ga-cookies/README.md @@ -5,7 +5,7 @@ Browser Plugin to be used with `@snowplow/browser-tracker`. -Adds GA Cookies to your Snowplow tracking. +Adds Universal Analytics and Google Analytics 4 cookies to your Snowplow tracking. ## Maintainer quick start @@ -36,14 +36,26 @@ Initialize your tracker with the GaCookiesPlugin: import { newTracker } from '@snowplow/browser-tracker'; import { GaCookiesPlugin } from '@snowplow/browser-plugin-ga-cookies'; -newTracker('sp1', '{{collector}}', { plugins: [ GaCookiesPlugin() ] }); // Also stores reference at module level +newTracker('sp1', '{{collector}}', { plugins: [ GaCookiesPlugin( + /* pluginOptions */ +) ] }); + +/* + * Available plugin options `GACookiesPluginOptions`: + * { + * ua: Send Universal Analytics specific cookie values. Defaults to true. + * ga4: Send Google Analytics 4 specific cookie values. Defaults to true + * ga4MeasurementId: Measurement id/ids to search the Google Analytics 4 session cookie. Can be a single measurement id as a string or an array of measurement id strings. The cookie has the form of _ga_ where is the data stream container id. + * cookiePrefix: Cookie prefix set on the Google Analytics 4 cookies. + * } + */ ``` ## Copyright and license Licensed and distributed under the [BSD 3-Clause License](LICENSE) ([An OSI Approved License][osi]). -Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang. +Copyright (c) 2023 Snowplow Analytics Ltd. All rights reserved. diff --git a/plugins/browser-plugin-ga-cookies/jest.config.js b/plugins/browser-plugin-ga-cookies/jest.config.js new file mode 100644 index 000000000..bd3ea4e2a --- /dev/null +++ b/plugins/browser-plugin-ga-cookies/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-ga-cookies/package.json b/plugins/browser-plugin-ga-cookies/package.json index dbc3d46fe..7f11e80a5 100644 --- a/plugins/browser-plugin-ga-cookies/package.json +++ b/plugins/browser-plugin-ga-cookies/package.json @@ -19,7 +19,7 @@ ], "scripts": { "build": "rollup -c --silent --failAfterWarnings", - "test": "" + "test": "jest" }, "dependencies": { "@snowplow/browser-tracker-core": "workspace:*", diff --git a/plugins/browser-plugin-ga-cookies/src/contexts.ts b/plugins/browser-plugin-ga-cookies/src/contexts.ts deleted file mode 100644 index 044e41eaf..000000000 --- a/plugins/browser-plugin-ga-cookies/src/contexts.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Schema for a context of Google Analytics cookie values - */ -export interface Cookies { - __utma?: string; - __utmb?: string; - __utmc?: string; - __utmv?: string; - __utmz?: string; - _ga?: string; - [key: string]: string | undefined; -} diff --git a/plugins/browser-plugin-ga-cookies/src/index.ts b/plugins/browser-plugin-ga-cookies/src/index.ts index 4e695ec1c..ba9876d27 100644 --- a/plugins/browser-plugin-ga-cookies/src/index.ts +++ b/plugins/browser-plugin-ga-cookies/src/index.ts @@ -1,54 +1,85 @@ -/* - * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -import { SelfDescribingJson } from '@snowplow/tracker-core'; +import { LOG, SelfDescribingJson } from '@snowplow/tracker-core'; import { BrowserPlugin, cookie } from '@snowplow/browser-tracker-core'; -import { Cookies } from './contexts'; +import { GA4Cookies, UACookies } from './types'; +import { GOOGLE_ANALYTICS_4_COOKIES_SCHEMA, UNIVERSAL_ANALYTICS_COOKIES_SCHEMA } from './schemata'; + +interface GACookiesPluginOptions { + ua?: boolean; + ga4: boolean; + ga4MeasurementId: string | string[]; + cookiePrefix?: string; +} + +const defaultPluginOptions: GACookiesPluginOptions = { + ua: true, + ga4: false, + ga4MeasurementId: '', + cookiePrefix: '', +}; /** * Captures the GA cookies on a page and sends as context on each event + * @param pluginOptions.ua - Send Universal Analytics specific cookie values. + * @param pluginOptions.ga4 - Send Google Analytics 4 specific cookie values. + * @param pluginOptions.ga4MeasurementId - Measurement id/ids to search the Google Analytics 4 session cookie. Can be a single measurement id as a string or an array of measurement id strings. The cookie has the form of _ga_ where is the data stream container id. + * @param pluginOptions.cookiePrefix - Cookie prefix set on the Google Analytics 4 cookies. */ -export function GaCookiesPlugin(): BrowserPlugin { +export function GaCookiesPlugin(pluginOptions: GACookiesPluginOptions = defaultPluginOptions): BrowserPlugin { return { contexts: () => { - const gaCookieData: SelfDescribingJson = { - schema: 'iglu:com.google.analytics/cookies/jsonschema/1-0-0', - data: {}, - }; - ['__utma', '__utmb', '__utmc', '__utmv', '__utmz', '_ga'].forEach(function (cookieType) { - var value = cookie(cookieType); - if (value) { - gaCookieData.data[cookieType] = value; + const contexts: SelfDescribingJson>[] = []; + const { ga4, ga4MeasurementId, ua, cookiePrefix } = { ...defaultPluginOptions, ...pluginOptions }; + const GA_USER_COOKIE = '_ga'; + const GA4_MEASUREMENT_ID_PREFIX = 'G-'; + const GA4_COOKIE_PREFIX = '_ga_'; + + if (ua) { + const uaCookiesContext: SelfDescribingJson = { + schema: UNIVERSAL_ANALYTICS_COOKIES_SCHEMA, + data: {}, + }; + ['__utma', '__utmb', '__utmc', '__utmv', '__utmz', GA_USER_COOKIE].forEach(function (cookieType) { + var value = cookie(cookieType); + if (value) { + uaCookiesContext.data[cookieType] = value; + } + }); + contexts.push(uaCookiesContext); + } + + if (ga4) { + if (!ga4MeasurementId) { + LOG.warn('"ga4MeasurementId" option not provided in browser-plugin-ga-cookies.'); + return contexts; } - }); - return [gaCookieData]; + const ga4CookiesContext: SelfDescribingJson = { + schema: GOOGLE_ANALYTICS_4_COOKIES_SCHEMA, + data: { + _ga: '', + session_cookies: [], + }, + }; + + const measurementIdentifiers = Array.isArray(ga4MeasurementId) ? [...ga4MeasurementId] : [ga4MeasurementId]; + + [...measurementIdentifiers, GA_USER_COOKIE].forEach(function (cookieIdentifier) { + if (cookieIdentifier === GA_USER_COOKIE) { + ga4CookiesContext.data._ga = cookie(cookiePrefix + cookieIdentifier); + } else { + /* Measurement id/s */ + const sessionCookieValue = cookie( + cookiePrefix + cookieIdentifier.replace(GA4_MEASUREMENT_ID_PREFIX, GA4_COOKIE_PREFIX) + ); + ga4CookiesContext.data.session_cookies?.push({ + measurement_id: cookieIdentifier, + session_cookie: sessionCookieValue, + }); + } + }); + contexts.push(ga4CookiesContext); + } + + return contexts; }, }; } diff --git a/plugins/browser-plugin-ga-cookies/src/schemata.ts b/plugins/browser-plugin-ga-cookies/src/schemata.ts new file mode 100644 index 000000000..39e923674 --- /dev/null +++ b/plugins/browser-plugin-ga-cookies/src/schemata.ts @@ -0,0 +1,2 @@ +export const UNIVERSAL_ANALYTICS_COOKIES_SCHEMA = 'iglu:com.google.analytics/cookies/jsonschema/1-0-0'; +export const GOOGLE_ANALYTICS_4_COOKIES_SCHEMA = 'iglu:com.google.ga4/cookies/jsonschema/1-0-0'; diff --git a/plugins/browser-plugin-ga-cookies/src/types.ts b/plugins/browser-plugin-ga-cookies/src/types.ts new file mode 100644 index 000000000..2db96ff6d --- /dev/null +++ b/plugins/browser-plugin-ga-cookies/src/types.ts @@ -0,0 +1,18 @@ +export interface UACookies { + __utma?: string; + __utmb?: string; + __utmc?: string; + __utmv?: string; + __utmz?: string; + _ga?: string; + [key: string]: string | undefined; +} + +export interface GA4Cookies { + _ga?: string; + session_cookies?: { + measurement_id: string; + session_cookie: string; + }[]; + [key: string]: unknown; +} diff --git a/plugins/browser-plugin-ga-cookies/test/__snapshots__/ga-cookies.test.ts.snap b/plugins/browser-plugin-ga-cookies/test/__snapshots__/ga-cookies.test.ts.snap new file mode 100644 index 000000000..a2bc04b10 --- /dev/null +++ b/plugins/browser-plugin-ga-cookies/test/__snapshots__/ga-cookies.test.ts.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GA Cookies plugin Correctly returns values for Google Analytics 4 cookies for multiple measurement ids 1`] = ` +Array [ + Object { + "data": Object { + "_ga": "1234", + "session_cookies": Array [ + Object { + "measurement_id": "G-1234", + "session_cookie": "567", + }, + Object { + "measurement_id": "G-5678", + "session_cookie": "789", + }, + ], + }, + "schema": "iglu:com.google.ga4/cookies/jsonschema/1-0-0", + }, +] +`; + +exports[`GA Cookies plugin Correctly returns values for Google Analytics 4 cookies with cookie prefix 1`] = ` +Array [ + Object { + "data": Object { + "_ga": "1234", + "session_cookies": Array [ + Object { + "measurement_id": "G-1234", + "session_cookie": "567", + }, + ], + }, + "schema": "iglu:com.google.ga4/cookies/jsonschema/1-0-0", + }, +] +`; + +exports[`GA Cookies plugin Returns values for Google Analytics 4 cookies and Universal Analytics cookies 1`] = ` +Array [ + Object { + "data": Object { + "__utma": "567", + "_ga": "1234", + }, + "schema": "iglu:com.google.analytics/cookies/jsonschema/1-0-0", + }, + Object { + "data": Object { + "_ga": "1234", + "session_cookies": Array [ + Object { + "measurement_id": "G-1234", + "session_cookie": "567", + }, + ], + }, + "schema": "iglu:com.google.ga4/cookies/jsonschema/1-0-0", + }, +] +`; + +exports[`GA Cookies plugin Returns values for Google Analytics 4 cookies and not Universal Analytics cookies 1`] = ` +Array [ + Object { + "data": Object { + "_ga": "1234", + "session_cookies": Array [ + Object { + "measurement_id": "G-1234", + "session_cookie": "567", + }, + ], + }, + "schema": "iglu:com.google.ga4/cookies/jsonschema/1-0-0", + }, +] +`; + +exports[`GA Cookies plugin Returns values for Universal Analytics cookies by default 1`] = ` +Array [ + Object { + "data": Object { + "__utma": "567", + "_ga": "1234", + }, + "schema": "iglu:com.google.analytics/cookies/jsonschema/1-0-0", + }, +] +`; diff --git a/plugins/browser-plugin-ga-cookies/test/ga-cookies.test.ts b/plugins/browser-plugin-ga-cookies/test/ga-cookies.test.ts new file mode 100644 index 000000000..c3519b948 --- /dev/null +++ b/plugins/browser-plugin-ga-cookies/test/ga-cookies.test.ts @@ -0,0 +1,120 @@ +import { buildLinkClick, trackerCore } from '@snowplow/tracker-core'; +import { GaCookiesPlugin } from '../src'; + +describe('GA Cookies plugin', () => { + let cookieJar: string; + + beforeAll(() => { + cookieJar = ''; + jest.spyOn(document, 'cookie', 'set').mockImplementation((cookie) => { + cookieJar += cookie; + }); + jest.spyOn(document, 'cookie', 'get').mockImplementation(() => cookieJar); + }); + + afterEach(() => { + cookieJar = ''; + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('Returns values for Universal Analytics cookies by default', (done) => { + const containerId = '1234'; + document.cookie = `_ga=1234; __utma=567; _ga_${containerId}=567;`; + const core = trackerCore({ + corePlugins: [GaCookiesPlugin()], + callback: (payloadBuilder) => { + const { data } = payloadBuilder.getJson()[1].json; + expect(data).toMatchSnapshot(); + done(); + }, + }); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Returns values for Google Analytics 4 cookies and Universal Analytics cookies', (done) => { + const containerId = '1234'; + const measurementId = `G-${containerId}`; + document.cookie = `_ga=1234; __utma=567; _ga_${containerId}=567;`; + const core = trackerCore({ + corePlugins: [GaCookiesPlugin({ ga4: true, ga4MeasurementId: measurementId })], + callback: (payloadBuilder) => { + const { data } = payloadBuilder.getJson()[1].json; + expect(data).toMatchSnapshot(); + done(); + }, + }); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Returns values for Google Analytics 4 cookies and not Universal Analytics cookies', (done) => { + const containerId = '1234'; + const measurementId = `G-${containerId}`; + document.cookie = `_ga=1234; __utma=567; _ga_${containerId}=567;`; + const core = trackerCore({ + corePlugins: [GaCookiesPlugin({ ua: false, ga4: true, ga4MeasurementId: measurementId })], + callback: (payloadBuilder) => { + const { data } = payloadBuilder.getJson()[1].json; + expect(data).toMatchSnapshot(); + done(); + }, + }); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Correctly returns values for Google Analytics 4 cookies for multiple measurement ids', (done) => { + const containerIdFoo = '1234'; + const containerIdBar = '5678'; + const measurementIds = [`G-${containerIdFoo}`, `G-${containerIdBar}`]; + document.cookie = `_ga=1234; __utma=567; _ga_${containerIdFoo}=567; _ga_${containerIdBar}=789;`; + const core = trackerCore({ + corePlugins: [GaCookiesPlugin({ ua: false, ga4: true, ga4MeasurementId: measurementIds })], + callback: (payloadBuilder) => { + const { data } = payloadBuilder.getJson()[1].json; + expect(data).toMatchSnapshot(); + done(); + }, + }); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Correctly returns values for Google Analytics 4 cookies with cookie prefix', (done) => { + const containerId = '1234'; + const measurementId = `G-${containerId}`; + const cookiePrefix = 'test'; + document.cookie = `${cookiePrefix}_ga=1234; __utma=567; ${cookiePrefix}_ga_${containerId}=567;`; + const core = trackerCore({ + corePlugins: [GaCookiesPlugin({ ua: false, ga4: true, ga4MeasurementId: measurementId, cookiePrefix })], + callback: (payloadBuilder) => { + const { data } = payloadBuilder.getJson()[1].json; + expect(data).toMatchSnapshot(); + done(); + }, + }); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Does not return values for Google Analytics 4 cookies without measurement id/s', (done) => { + const containerId = '1234'; + document.cookie = `_ga=1234; __utma=567; _ga_${containerId}=567;`; + const core = trackerCore({ + // @ts-expect-error + corePlugins: [GaCookiesPlugin({ ua: false, ga4: true })], + callback: (payloadBuilder) => { + const expectedGAContextPayload = payloadBuilder.getJson()[1]; + expect(expectedGAContextPayload).toBe(undefined); + done(); + }, + }); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); +}); diff --git a/trackers/javascript-tracker/src/features.ts b/trackers/javascript-tracker/src/features.ts index 6a2932a75..c576696ce 100644 --- a/trackers/javascript-tracker/src/features.ts +++ b/trackers/javascript-tracker/src/features.ts @@ -121,7 +121,8 @@ export function Plugins(configuration: JavaScriptTrackerConfiguration) { if (plugins.gaCookies && gaCookies) { const { GaCookiesPlugin, ...apiMethods } = GaCookies; - activatedPlugins.push([GaCookiesPlugin(), apiMethods]); + const gaCookiesPlugin = typeof gaCookies === 'object' ? GaCookiesPlugin(gaCookies) : GaCookiesPlugin(); + activatedPlugins.push([gaCookiesPlugin, apiMethods]); } if (plugins.consent) { @@ -208,7 +209,7 @@ export function Plugins(configuration: JavaScriptTrackerConfiguration) { const { VimeoTrackingPlugin, ...apiMethods } = VimeoTracking; activatedPlugins.push([VimeoTrackingPlugin(), apiMethods]); } - + if (plugins.privacySandbox) { const { PrivacySandboxPlugin, ...apiMethods } = PrivacySandbox; activatedPlugins.push([PrivacySandboxPlugin(), apiMethods]);