diff --git a/packages/sui-segment-wrapper/README.md b/packages/sui-segment-wrapper/README.md index 181f97c5d..b74b99202 100644 --- a/packages/sui-segment-wrapper/README.md +++ b/packages/sui-segment-wrapper/README.md @@ -7,11 +7,6 @@ This package adds an abstraction layer on top of [segment.com](https://segment.c - [x] Add `page` method that internally uses `track` but with the correct `referrer` property. - [x] Send `user.id` and `anonymousId` on every track. -**Google Analytics 🔍** - -- [x] Load GA4 if `googleAnalyticsMeasurementId` is provided. -- [x] Retrieve `clientId` and `sessionId` automatically from GA4 and put in Segment tracks. - **Consent Management Platform 🐾** - [x] Automatic tracking of Consent Management Platform usage. @@ -124,9 +119,6 @@ import analytics from '@s-ui/segment-wrapper' You could put a special config in a the `window.__mpi` to change some behaviour of the wrapper. This config MUST somewhere before using the Segment Wrapper. -- `googleAnalyticsMeasurementId`: _(optional)_ If set, this value will be used for the Google Analytics Measurement API. It will load `gtag` to get the client id. -- `googleAnalyticsConfig`: _(optional)_ If set, this config will be passed when initializing the Google Analytics Measurement API. -- `googleAnalyticsInitEvent`: _(optional)_ If set, an event will be sent in order to initialize all the Google Analytics data. - `defaultContext`: _(optional)_ If set, properties will be merged and sent with every `track` and `page` in the **context object**. It's the ideal place to put the `site` and `vertical` info to make sure that static info will be sent along with all the tracking. - `defaultProperties`: _(optional)_ If set, properties will be merged and sent with every `track` and `page`. - `tcfTrackDefaultProperties` _(optional)_ If set, this property will be merged together with the default properties set to send with every tcf track event @@ -141,7 +133,6 @@ Example: ```js window.__mpi = { segmentWrapper: { - googleAnalyticsMeasurementId: 'GA-123456789', universalId: '7ab9ddf3281d5d5458a29e8b3ae2864', defaultContext: { site: 'comprocasa', diff --git a/packages/sui-segment-wrapper/src/index.js b/packages/sui-segment-wrapper/src/index.js index 890ed85f7..5e489f74a 100644 --- a/packages/sui-segment-wrapper/src/index.js +++ b/packages/sui-segment-wrapper/src/index.js @@ -13,6 +13,7 @@ import { sendGoogleConsents } from './repositories/googleRepository.js' import {checkAnonymousId} from './utils/checkAnonymousId.js' +import {isGA4DestinationEnabled} from './utils/ga4Detection.js' import {getConfig, isClient} from './config.js' import analytics from './segmentWrapper.js' import initTcfTracking from './tcf.js' @@ -44,33 +45,43 @@ const addMiddlewares = () => { } if (isClient && window.analytics) { - // Initialize Google Analtyics if needed - const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') - const dataLayerName = getConfig('googleAnalyticsDataLayer') || DEFAULT_DATA_LAYER_NAME - const needsConsentManagement = getConfig('googleAnalyticsConsentManagement') + // Pre-cache GA4 destination detection as early as possible + // This ensures the first track has the correct integration mode + window.analytics.ready(() => { + // Force detection to cache the result + const useSegmentGA4Destination = isGA4DestinationEnabled() - if (googleAnalyticsMeasurementId) { - const googleAnalyticsConfig = getConfig('googleAnalyticsConfig') + // Only initialize manual GA4 if destination is not enabled (legacy mode) + if (!useSegmentGA4Destination) { + // Legacy behavior: Initialize Google Analytics manually + const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + const dataLayerName = getConfig('googleAnalyticsDataLayer') || DEFAULT_DATA_LAYER_NAME + const needsConsentManagement = getConfig('googleAnalyticsConsentManagement') - window[dataLayerName] = window[dataLayerName] || [] - window.gtag = - window.gtag || - function gtag() { - window[dataLayerName].push(arguments) - } + if (googleAnalyticsMeasurementId) { + const googleAnalyticsConfig = getConfig('googleAnalyticsConfig') - window.gtag('js', new Date()) - if (needsConsentManagement) sendGoogleConsents() - window.gtag('config', googleAnalyticsMeasurementId, { - cookie_prefix: 'segment', - send_page_view: false, - ...googleAnalyticsConfig, - ...getCampaignDetails() - }) - loadGoogleAnalytics().catch(error => { - console.error(error) - }) - } + window[dataLayerName] = window[dataLayerName] || [] + window.gtag = + window.gtag || + function gtag() { + window[dataLayerName].push(arguments) + } + + window.gtag('js', new Date()) + if (needsConsentManagement) sendGoogleConsents() + window.gtag('config', googleAnalyticsMeasurementId, { + cookie_prefix: 'segment', + send_page_view: false, + ...googleAnalyticsConfig, + ...getCampaignDetails() + }) + loadGoogleAnalytics().catch(error => { + console.error(error) + }) + } + } + }) window.analytics.ready(checkAnonymousId) window.analytics.addSourceMiddleware ? addMiddlewares() : window.analytics.ready(addMiddlewares) diff --git a/packages/sui-segment-wrapper/src/middlewares/source/campaignContext.js b/packages/sui-segment-wrapper/src/middlewares/source/campaignContext.js index c3c1a5afe..91fd5f288 100644 --- a/packages/sui-segment-wrapper/src/middlewares/source/campaignContext.js +++ b/packages/sui-segment-wrapper/src/middlewares/source/campaignContext.js @@ -1,16 +1,11 @@ -import {getConfig} from '../../config.js' import {getCampaignDetails} from '../../repositories/googleRepository.js' export const campaignContext = ({payload, next}) => { - const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + const campaignDetails = getCampaignDetails({needsTransformation: false}) - if (googleAnalyticsMeasurementId) { - const campaignDetails = getCampaignDetails({needsTransformation: false}) - - payload.obj.context = { - ...payload.obj.context, - ...campaignDetails - } + payload.obj.context = { + ...payload.obj.context, + ...campaignDetails } next(payload) diff --git a/packages/sui-segment-wrapper/src/repositories/googleRepository.js b/packages/sui-segment-wrapper/src/repositories/googleRepository.js index 9fc1b6546..c3dde908f 100644 --- a/packages/sui-segment-wrapper/src/repositories/googleRepository.js +++ b/packages/sui-segment-wrapper/src/repositories/googleRepository.js @@ -3,6 +3,7 @@ import {dispatchEvent} from '@s-ui/js/lib/events' import {getConfig} from '../config.js' import {EVENTS} from '../events.js' import {utils} from '../middlewares/source/pageReferrer.js' +import {isGA4DestinationEnabled} from '../utils/ga4Detection.js' const FIELDS = { clientId: 'client_id', @@ -72,7 +73,11 @@ export const loadGoogleAnalytics = async () => { } // Trigger GA init event just once per session. +// Only in LEGACY mode (manual GA4 initialization) const triggerGoogleAnalyticsInitEvent = sessionId => { + // Don't trigger if using Segment destination (new mode) + if (isGA4DestinationEnabled()) return + const eventName = getConfig('googleAnalyticsInitEvent') ?? DEFAULT_GA_INIT_EVENT const eventPrefix = `ga_event_${eventName}_` const eventKey = `${eventPrefix}${sessionId}` diff --git a/packages/sui-segment-wrapper/src/segmentWrapper.js b/packages/sui-segment-wrapper/src/segmentWrapper.js index 07c6d502e..437e990f6 100644 --- a/packages/sui-segment-wrapper/src/segmentWrapper.js +++ b/packages/sui-segment-wrapper/src/segmentWrapper.js @@ -3,15 +3,16 @@ import { CONSENT_STATES, getConsentState, - getGoogleConsentValue, getGoogleClientId, + getGoogleConsentValue, getGoogleSessionId, - setGoogleUserId, - sendGoogleConsents + sendGoogleConsents, + setGoogleUserId } from './repositories/googleRepository.js' import {getXandrId} from './repositories/xandrRepository.js' import {getConfig} from './config.js' import {USER_GDPR, CMP_TRACK_EVENT, checkAnalyticsGdprIsAccepted, getGdprPrivacyValue} from './tcf.js' +import {isGA4DestinationEnabled} from './utils/ga4Detection.js' /* Default properties to be sent on all trackings */ const DEFAULT_PROPERTIES = {platform: 'web'} @@ -54,6 +55,20 @@ export const getDefaultProperties = () => ({ */ const getTrackIntegrations = async ({gdprPrivacyValue, event}) => { const isGdprAccepted = checkAnalyticsGdprIsAccepted(gdprPrivacyValue) + const restOfIntegrations = getRestOfIntegrations({isGdprAccepted, event}) + + // Check if we should use manual GA4 (legacy) or Segment destination (new) + const useSegmentGA4Destination = isGA4DestinationEnabled() + + if (useSegmentGA4Destination) { + // New behavior: let Segment handle GA4 + return { + ...restOfIntegrations, + 'Google Analytics 4 Web': true + } + } + + // Legacy behavior: manual GA4 with clientId/sessionId let sessionId let clientId @@ -67,9 +82,6 @@ const getTrackIntegrations = async ({gdprPrivacyValue, event}) => { ) } - const restOfIntegrations = getRestOfIntegrations({isGdprAccepted, event}) - - // If we don't have the user consents we remove all the integrations but GA4 return { ...restOfIntegrations, 'Google Analytics 4': @@ -142,19 +154,18 @@ const getConsentValue = gdprValue => (gdprValue === USER_GDPR.ACCEPTED ? CONSENT * @param {object} context Context object with all the actual info * @returns {Promise} New context with all the previous info and the new one */ -export const decorateContextWithNeededData = async ({event = '', context = {}}) => { +export const decorateContextWithNeededData = async ({event = '', context = {}, properties = {}}) => { const gdprPrivacyValue = await getGdprPrivacyValue() const {analytics: gdprPrivacyValueAnalytics, advertising: gdprPrivacyValueAdvertising} = gdprPrivacyValue || {} const isGdprAccepted = checkAnalyticsGdprIsAccepted(gdprPrivacyValue) + + // Check if we should use Segment destination or legacy mode + const useSegmentGA4Destination = isGA4DestinationEnabled() + const [integrations, xandrId] = await Promise.all([ getTrackIntegrations({gdprPrivacyValue, event}), getXandrId({gdprPrivacyValueAdvertising}) ]) - const analyticsConsentValue = getGoogleConsentValue('analytics_storage') ?? getConsentValue(gdprPrivacyValueAnalytics) - const adUserDataConsentValue = getGoogleConsentValue('ad_user_data') ?? getConsentValue(gdprPrivacyValueAdvertising) - const adPersonalizationConsentValue = - getGoogleConsentValue('ad_personalization') ?? getConsentValue(gdprPrivacyValueAdvertising) - const adStorageConsentValue = getGoogleConsentValue('ad_storage') ?? getConsentValue(gdprPrivacyValueAdvertising) if (!isGdprAccepted) { context.integrations = { @@ -165,25 +176,51 @@ export const decorateContextWithNeededData = async ({event = '', context = {}}) } } - return { + // Build Google Consent Mode object + const googleConsents = { + analytics_storage: useSegmentGA4Destination + ? getConsentValue(gdprPrivacyValueAnalytics) + : getGoogleConsentValue('analytics_storage') ?? getConsentValue(gdprPrivacyValueAnalytics), + ad_storage: useSegmentGA4Destination + ? getConsentValue(gdprPrivacyValueAdvertising) + : getGoogleConsentValue('ad_storage') ?? getConsentValue(gdprPrivacyValueAdvertising), + ad_user_data: useSegmentGA4Destination + ? getConsentValue(gdprPrivacyValueAdvertising) + : getGoogleConsentValue('ad_user_data') ?? getConsentValue(gdprPrivacyValueAdvertising), + ad_personalization: useSegmentGA4Destination + ? getConsentValue(gdprPrivacyValueAdvertising) + : getGoogleConsentValue('ad_personalization') ?? getConsentValue(gdprPrivacyValueAdvertising) + } + + const baseContext = { ...context, ...(!isGdprAccepted && {ip: '0.0.0.0'}), ...getExternalIds({context, xandrId}), - analytics_storage: getConsentState(), clientVersion: `segment-wrapper@${process.env.VERSION ?? '0.0.0'}`, gdpr_privacy: gdprPrivacyValueAnalytics, gdpr_privacy_advertising: gdprPrivacyValueAdvertising, - google_consents: { - analytics_storage: analyticsConsentValue, - ad_user_data: adUserDataConsentValue, - ad_personalization: adPersonalizationConsentValue, - ad_storage: adStorageConsentValue - }, integrations: { ...context.integrations, ...integrations } } + + // Legacy behavior: add analytics_storage and google_consents to context + if (!useSegmentGA4Destination) { + baseContext.analytics_storage = getConsentState() + baseContext.google_consents = googleConsents + } + + return { + context: baseContext, + properties: useSegmentGA4Destination + ? { + ...properties, + // New mode: add google_consents to properties + google_consents: googleConsents + } + : properties + } } /** @@ -197,17 +234,21 @@ export const decorateContextWithNeededData = async ({event = '', context = {}}) const track = (event, properties, context = {}, callback) => new Promise(resolve => { const initTrack = async () => { - const newContext = await decorateContextWithNeededData({context, event}) - /** * @deprecated Now we use `defaultContextProperties` middleware * and put the info on the context object */ - const newProperties = { + const baseProperties = { ...getDefaultProperties(), ...properties } + const {context: newContext, properties: decoratedProperties} = await decorateContextWithNeededData({ + context, + event, + properties: baseProperties + }) + const newCallback = async (...args) => { if (callback) callback(...args) // eslint-disable-line n/no-callback-literal const [gdprPrivacyValue] = await Promise.all([getGdprPrivacyValue()]) @@ -219,17 +260,20 @@ const track = (event, properties, context = {}, callback) => } } + const useSegmentGA4Destination = isGA4DestinationEnabled() const needsConsentManagement = getConfig('googleAnalyticsConsentManagement') - if (needsConsentManagement && event === CMP_TRACK_EVENT) { + // Legacy behavior: send consents to GTM + if (!useSegmentGA4Destination && needsConsentManagement && event === CMP_TRACK_EVENT) { sendGoogleConsents('update', newContext.google_consents) } window.analytics.track( event, - newProperties, + decoratedProperties, { ...newContext, + integrations: newContext.integrations, context: { integrations: { ...newContext.integrations @@ -256,7 +300,10 @@ const identify = async (userIdParam, traits, options, callback) => { const userId = getUserId(userIdParam) - setGoogleUserId(userId) + // Legacy behavior: set user ID in gtag + if (!isGA4DestinationEnabled()) { + setGoogleUserId(userId) + } return window.analytics.identify( userId, diff --git a/packages/sui-segment-wrapper/src/tcf.js b/packages/sui-segment-wrapper/src/tcf.js index 2c06af803..480680bd2 100644 --- a/packages/sui-segment-wrapper/src/tcf.js +++ b/packages/sui-segment-wrapper/src/tcf.js @@ -132,6 +132,7 @@ const trackTcf = ({eventId, gdprPrivacy}) => export const getGdprPrivacyValue = () => { // try to get the actual gdprPrivacyValue and just return it const gdprPrivacyValue = gdprState.get() + if (gdprPrivacyValue !== undefined) return Promise.resolve(gdprPrivacyValue) // // if we don't have a gdprPrivacyValue, then subscribe to it until we have a value diff --git a/packages/sui-segment-wrapper/src/utils/ga4Detection.js b/packages/sui-segment-wrapper/src/utils/ga4Detection.js new file mode 100644 index 000000000..66ff0c000 --- /dev/null +++ b/packages/sui-segment-wrapper/src/utils/ga4Detection.js @@ -0,0 +1,22 @@ +import {getConfig} from '../config.js' + +/** + * Check if Google Analytics 4 Web destination is enabled in Segment + * Simple detection based on googleAnalyticsMeasurementId presence: + * - If googleAnalyticsMeasurementId is configured → LEGACY mode (manual GA4) + * - If NOT configured → NEW mode (Segment destination handles GA4) + * @returns {boolean} + */ +export const isGA4DestinationEnabled = () => { + const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + // If no measurement ID configured, assume they're using Segment destination + return !googleAnalyticsMeasurementId +} + +/** + * Reset the cached detection (useful for testing) + * @deprecated No longer needed as detection is now stateless + */ +export const resetGA4DetectionCache = () => { + // No-op: detection is now stateless, no cache to reset +} diff --git a/packages/sui-segment-wrapper/test/assertions.js b/packages/sui-segment-wrapper/test/assertions.js index 4e8749637..2263fd677 100644 --- a/packages/sui-segment-wrapper/test/assertions.js +++ b/packages/sui-segment-wrapper/test/assertions.js @@ -1,7 +1,6 @@ import {expect} from 'chai' import sinon from 'sinon' -import {setConfig} from '../src/config.js' import suiAnalytics from '../src/index.js' import {getCampaignDetails} from '../src/repositories/googleRepository.js' import {stubActualQueryString} from './stubs.js' @@ -12,7 +11,6 @@ export const assertCampaignDetails = async ({queryString, expectation}) => { try { const spy = sinon.stub() - setConfig('googleAnalyticsMeasurementId', 123) await simulateUserAcceptConsents() await suiAnalytics.track( 'fakeEvent', diff --git a/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js b/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js index 131a53448..6d15f8db9 100644 --- a/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js +++ b/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js @@ -1,7 +1,6 @@ import {expect} from 'chai' -import sinon from 'sinon' -import {getCampaignDetails, getGoogleConsentValue} from '../../src/repositories/googleRepository.js' +import {getCampaignDetails} from '../../src/repositories/googleRepository.js' describe('GoogleRepository', () => { let initialTrackingTagsType @@ -172,76 +171,4 @@ describe('GoogleRepository', () => { expect(details.campaign).to.have.property('source', 'stc_source') expect(details.campaign).to.have.property('name', 'stc_campaign') }) - - describe('getGoogleConsentValue', () => { - let getConsentStateStub - - beforeEach(() => { - // Ensure the nested structure exists before stubbing - if (!window.google_tag_data) { - window.google_tag_data = {ics: {}} - } else if (!window.google_tag_data.ics) { - window.google_tag_data.ics = {} - } - // Ensure the property exists so sinon.stub doesn't throw - window.google_tag_data.ics.getConsentState = function () {} - getConsentStateStub = sinon.stub(window.google_tag_data.ics, 'getConsentState') - }) - - afterEach(() => { - if (getConsentStateStub && typeof getConsentStateStub.restore === 'function') { - getConsentStateStub.restore() - } - delete window.google_tag_data - }) - - it("should return 'granted' when GTM consent state is 1", () => { - // Given - getConsentStateStub.returns(1) - // When - const consentValue = getGoogleConsentValue('analytics_storage') - // Then - expect(consentValue).to.equal('granted') - expect(getConsentStateStub.calledOnceWith('analytics_storage')).to.be.true - }) - - it("should return 'denied' when GTM consent state is 2", () => { - // Given - getConsentStateStub.returns(2) - // When - const consentValue = getGoogleConsentValue('ad_storage') - // Then - expect(consentValue).to.equal('denied') - expect(getConsentStateStub.calledOnceWith('ad_storage')).to.be.true - }) - - it('should return undefined if the GTM API returns a value other than 1 or 2', () => { - // Given - getConsentStateStub.returns(0) // An unexpected value - // When - const consentValue = getGoogleConsentValue('ad_user_data') - // Then - expect(consentValue).to.be.undefined - }) - - it('should return undefined if the GTM API is not available', () => { - // Given - getConsentStateStub.restore() // Remove the stub to simulate the API not being there - delete window.google_tag_data - // When - const consentValue = getGoogleConsentValue('ad_personalization') - // Then - expect(consentValue).to.be.undefined - }) - - it('should return undefined if getConsentState is not a function', () => { - // Given - getConsentStateStub.restore() // Remove the stub - window.google_tag_data.ics = {} // getConsentState is missing - // When - const consentValue = getGoogleConsentValue('analytics_storage') - // Then - expect(consentValue).to.be.undefined - }) - }) }) diff --git a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js index 274ddd561..0d4918b4e 100644 --- a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js +++ b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js @@ -1,7 +1,7 @@ import {expect} from 'chai' import sinon from 'sinon' -import {getConfig, setConfig} from '../src/config.js' +import {setConfig} from '../src/config.js' import suiAnalytics from '../src/index.js' import {campaignContext} from '../src/middlewares/source/campaignContext.js' import {defaultContextProperties} from '../src/middlewares/source/defaultContextProperties.js' @@ -10,7 +10,6 @@ import {userScreenInfo} from '../src/middlewares/source/userScreenInfo.js' import {userTraits} from '../src/middlewares/source/userTraits.js' import {INTEGRATIONS_WHEN_NO_CONSENTS} from '../src/segmentWrapper.js' import initTcfTracking, {getGdprPrivacyValue, USER_GDPR} from '../src/tcf.js' -import {assertCampaignDetails} from './assertions.js' import { cleanWindowStubs, resetReferrerState, @@ -25,7 +24,6 @@ import { simulateUserAcceptAdvertisingConsents, simulateUserAcceptAnalyticsConsents, simulateUserAcceptConsents, - simulateUserDeclinedAnalyticsConsentsAndAcceptedAdvertisingConsents, simulateUserDeclinedConsents } from './tcf.js' import {getDataFromLastTrack, waitUntil} from './utils.js' @@ -163,183 +161,13 @@ describe('Segment Wrapper', function () { expect(context.traits.anonymousId).to.deep.equal('fakeAnonymousId') }) - describe('and gtag has been configured properly', () => { - it('should send Google Analytics integration with true if user declined consents', async () => { - // Add the needed config to enable Google Analytics - setConfig('googleAnalyticsMeasurementId', 123) - - await simulateUserDeclinedConsents() - - await suiAnalytics.track( - 'fakeEvent', - {}, - { - integrations: {fakeIntegrationKey: 'fakeIntegrationValue'} - } - ) - - const {context} = getDataFromLastTrack() - - expect(context.integrations).to.deep.includes({ - fakeIntegrationKey: 'fakeIntegrationValue', - 'Google Analytics 4': {clientId: 'fakeClientId', sessionId: 'fakeSessionId'} - }) - }) - - it('should send ClientId on Google Analytics integration if user accepted consents', async () => { - // add needed config to enable Google Analytics - setConfig('googleAnalyticsMeasurementId', 123) - - await simulateUserAcceptConsents() - - await suiAnalytics.track( - 'fakeEvent', - {}, - { - integrations: {fakeIntegrationKey: 'fakeIntegrationValue'} - } - ) - - const {context} = getDataFromLastTrack() - - expect(context.integrations).to.deep.includes({ - fakeIntegrationKey: 'fakeIntegrationValue', - 'Google Analytics 4': { - clientId: 'fakeClientId', - sessionId: 'fakeSessionId' - } - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=em-mail-winter%20promo-honda`', async () => { - await assertCampaignDetails({ - queryString: '?stc=em-mail-winter%20promo-honda', - expectation: { - campaign: { - medium: 'email', - name: 'winter promo', - source: 'mail', - content: 'honda' - } - } - }) - }) - - it('should not send mapped campaing details when stc param is invalid', async () => { - await assertCampaignDetails({ - queryString: - '?stc=IJ_PUSH%7Celement~50220488692%7Cversion~pushmssearch_jobtitle_normalized&id_push=50220488692 ', - expectation: null - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=sm-google-1234%3Aspring%20sale-aprilia-logolink`', async () => { - await assertCampaignDetails({ - queryString: '?stc=sm-google-1234%3Aspring%20sale-aprilia-logolink', - expectation: { - campaign: { - medium: 'social-media', - id: '1234', - name: 'spring sale', - source: 'google', - content: 'aprilia', - term: 'logolink' - } - } - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=sem-google-autumn%20sale`', async () => { - await assertCampaignDetails({ - queryString: '?stc=sem-google-autumn%20sale', - expectation: { - campaign: { - medium: 'paid-search', - name: 'autumn sale', - source: 'google' - } - } - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=sem-google-autumn sale`', async () => { - await assertCampaignDetails({ - queryString: '?stc=sem-google-autumn sale', - expectation: { - campaign: { - medium: 'paid-search', - name: 'autumn sale', - source: 'google' - } - } - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=sem-google-1234%3Aautumn%20sale`', async () => { - await assertCampaignDetails({ - queryString: '?stc=sem-google-1234%3Aautumn%20sale', - expectation: { - campaign: { - medium: 'paid-search', - id: '1234', - name: 'autumn sale', - source: 'google' - } - } - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=sem-google-1234:autumn sale`', async () => { - await assertCampaignDetails({ - queryString: '?stc=sem-google-1234:autumn sale', - expectation: { - campaign: { - medium: 'paid-search', - id: '1234', - name: 'autumn sale', - source: 'google' - } - } - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=sem-google-autumn sale-aprilia`', async () => { - await assertCampaignDetails({ - queryString: '?stc=sem-google-autumn sale-aprilia', - expectation: { - campaign: { - medium: 'paid-search', - name: 'autumn sale', - source: 'google', - content: 'aprilia' - } - } - }) - }) - - it('should send mapped campaign details when the url query string is `?stc=sem-google-autumn sale-na-logolink`', async () => { - await assertCampaignDetails({ - queryString: '?stc=sem-google-autumn sale-na-logolink', - expectation: { - campaign: { - medium: 'paid-search', - name: 'autumn sale', - source: 'google', - term: 'logolink' - } - } - }) - }) - }) - it('should add always the platform as web and the language', async () => { await suiAnalytics.track('fakeEvent', {fakePropKey: 'fakePropValue'}) const {properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - fakePropKey: 'fakePropValue', - platform: 'web' - }) + expect(properties.fakePropKey).to.equal('fakePropValue') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') }) it('should send defaultProperties if provided', async () => { @@ -349,11 +177,10 @@ describe('Segment Wrapper', function () { const {properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - site: 'mysite', - vertical: 'myvertical', - platform: 'web' - }) + expect(properties.site).to.equal('mysite') + expect(properties.vertical).to.equal('myvertical') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') }) describe('and the TCF is handled', () => { @@ -456,30 +283,6 @@ describe('Segment Wrapper', function () { expect(spy.callCount).to.equal(1) }) - - describe('and GA Measurment ID is set', () => { - beforeEach(() => { - setConfig('googleAnalyticsMeasurementId', 123) - }) - - it('should set the user id into `gtag` properly', async function () { - await simulateUserAcceptConsents() - await suiAnalytics.identify('myTestUserId') - - const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') - - const getGaUserId = async () => - new Promise(resolve => { - window.gtag('get', googleAnalyticsMeasurementId, 'user_id', userId => { - resolve(userId) - }) - }) - - const gaUserId = await getGaUserId() - - expect(gaUserId).to.equal('myTestUserId') - }) - }) }) describe('when TCF is present on the page', () => { @@ -488,10 +291,9 @@ describe('Segment Wrapper', function () { const {context, properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - channel: 'GDPR', - platform: 'web' - }) + expect(properties.channel).to.equal('GDPR') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') expect(context).to.deep.include({ gdpr_privacy: 'declined', gdpr_privacy_advertising: 'declined' @@ -502,10 +304,9 @@ describe('Segment Wrapper', function () { await simulateUserAcceptAnalyticsConsents() const {context, properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - channel: 'GDPR', - platform: 'web' - }) + expect(properties.channel).to.equal('GDPR') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') expect(context).to.deep.include({ gdpr_privacy: 'accepted', @@ -518,10 +319,9 @@ describe('Segment Wrapper', function () { const {context, properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - channel: 'GDPR', - platform: 'web' - }) + expect(properties.channel).to.equal('GDPR') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') expect(context).to.deep.include({ gdpr_privacy: 'declined', gdpr_privacy_advertising: 'accepted' @@ -533,10 +333,9 @@ describe('Segment Wrapper', function () { const {context, properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - channel: 'GDPR', - platform: 'web' - }) + expect(properties.channel).to.equal('GDPR') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') expect(context).to.deep.include({ gdpr_privacy: 'declined', @@ -569,10 +368,9 @@ describe('Segment Wrapper', function () { const {context, properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - channel: 'GDPR', - platform: 'web' - }) + expect(properties.channel).to.equal('GDPR') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') expect(context).to.deep.include({ gdpr_privacy: 'accepted', @@ -586,10 +384,9 @@ describe('Segment Wrapper', function () { await suiAnalytics.track('fakeEvent', {fakePropKey: 'fakePropValue'}) const {context, properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - fakePropKey: 'fakePropValue', - platform: 'web' - }) + expect(properties.fakePropKey).to.equal('fakePropValue') + expect(properties.platform).to.equal('web') + expect(properties.google_consents).to.be.an('object') expect(context).to.deep.include({ gdpr_privacy: 'accepted' @@ -605,35 +402,6 @@ describe('Segment Wrapper', function () { expect(context.integrations).to.deep.include(INTEGRATIONS_WHEN_NO_CONSENTS) }) - it('should grant Google Analytics consents properties if user analytics consents are accepted', async () => { - await simulateUserAcceptAnalyticsConsents() - await suiAnalytics.track('fakeEvent', {fakePropKey: 'fakePropValue'}) - - const {context} = getDataFromLastTrack() - - expect(context.google_consents).to.include({ - analytics_storage: 'granted', - ad_user_data: 'denied', - ad_personalization: 'denied', - ad_storage: 'denied' - }) - }) - - it('should deny Google Analytics consents properties if user analytics consents are declined', async () => { - await simulateUserDeclinedAnalyticsConsentsAndAcceptedAdvertisingConsents() - - await suiAnalytics.track('fakeEvent', {fakePropKey: 'fakePropValue'}) - - const {context} = getDataFromLastTrack() - - expect(context.google_consents).to.include({ - analytics_storage: 'denied', - ad_user_data: 'granted', - ad_personalization: 'granted', - ad_storage: 'granted' - }) - }) - describe('for recurrent users', () => { let cookiesStub @@ -676,37 +444,30 @@ describe('Segment Wrapper', function () { const {properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - channel: 'GDPR', - platform: 'web', - vertical: 'fakeVertical' - }) + expect(properties.channel).to.equal('GDPR') + expect(properties.platform).to.equal('web') + expect(properties.vertical).to.equal('fakeVertical') + expect(properties.google_consents).to.be.an('object') }) it('should add the defined custom property to the track event when CONSENTS are DECLINED', async () => { await simulateUserDeclinedConsents() const {properties} = getDataFromLastTrack() - expect(properties).to.deep.equal({ - channel: 'GDPR', - platform: 'web', - vertical: 'fakeVertical' - }) + expect(properties.channel).to.equal('GDPR') + expect(properties.platform).to.equal('web') + expect(properties.vertical).to.equal('fakeVertical') + expect(properties.google_consents).to.be.an('object') }) }) describe('context integrations', () => { - before(() => { - stubWindowObjects() + beforeEach(() => { + setConfig('googleAnalyticsMeasurementId', 'G-XXXXXXXXXX') }) it('sends an event with the actual context and traits when the consents are declined', async () => { const spy = sinon.stub() - window.google_tag_data = { - ics: { - getConsentState: () => 2 - } - } await simulateUserDeclinedConsents() await suiAnalytics.track( @@ -725,7 +486,10 @@ describe('Segment Wrapper', function () { const {context} = getDataFromLastTrack() const integrations = { All: false, - 'Google Analytics 4': true, + 'Google Analytics 4': { + clientId: 'fakeClientId', + sessionId: 'fakeSessionId' + }, Personas: false, Webhooks: true, Webhook: true @@ -741,9 +505,9 @@ describe('Segment Wrapper', function () { analytics_storage: 'denied', google_consents: { analytics_storage: 'denied', + ad_storage: 'denied', ad_user_data: 'denied', - ad_personalization: 'denied', - ad_storage: 'denied' + ad_personalization: 'denied' }, context: { integrations @@ -759,6 +523,116 @@ describe('Segment Wrapper', function () { }) }) + describe('GA4 integration modes', () => { + afterEach(() => cleanWindowStubs()) + + describe('Legacy mode (without Segment destination)', () => { + beforeEach(() => { + stubWindowObjects({ga4DestinationEnabled: false}) + stubGoogleAnalytics() + setConfig('googleAnalyticsMeasurementId', 'G-123456789') + + window.analytics.addSourceMiddleware(userTraits) + window.analytics.addSourceMiddleware(defaultContextProperties) + window.analytics.addSourceMiddleware(campaignContext) + window.analytics.addSourceMiddleware(userScreenInfo) + window.analytics.addSourceMiddleware(pageReferrer) + }) + + it('should use "Google Analytics 4" integration with clientId and sessionId', async () => { + await simulateUserAcceptConsents() + + const spy = sinon.stub() + await suiAnalytics.track('fakeEvent', {}, {}, spy) + + const {context} = spy.firstCall.firstArg.obj + + expect(context.integrations['Google Analytics 4']).to.deep.equal({ + clientId: 'fakeClientId', + sessionId: 'fakeSessionId' + }) + expect(context.integrations['Google Analytics 4 Web']).to.be.undefined + }) + + it('should add analytics_storage to context', async () => { + await simulateUserAcceptConsents() + + const spy = sinon.stub() + await suiAnalytics.track('fakeEvent', {}, {}, spy) + + const {context} = spy.firstCall.firstArg.obj + + // In legacy mode, analytics_storage is added to context + // Value is 'denied' because GTM API is not available in tests (no window.google_tag_data) + expect(context.analytics_storage).to.be.a('string') + expect(['granted', 'denied']).to.include(context.analytics_storage) + }) + + it('should add google_consents to context', async () => { + await simulateUserAcceptAnalyticsConsents() + + const spy = sinon.stub() + await suiAnalytics.track('fakeEvent', {}, {}, spy) + + const {context} = spy.firstCall.firstArg.obj + + expect(context.google_consents).to.deep.include({ + analytics_storage: 'granted', + ad_storage: 'denied' + }) + }) + }) + + describe('New mode (with Segment destination)', () => { + beforeEach(() => { + stubWindowObjects({ga4DestinationEnabled: true}) + stubGoogleAnalytics() + + window.analytics.addSourceMiddleware(userTraits) + window.analytics.addSourceMiddleware(defaultContextProperties) + window.analytics.addSourceMiddleware(campaignContext) + window.analytics.addSourceMiddleware(userScreenInfo) + window.analytics.addSourceMiddleware(pageReferrer) + }) + + it('should use "Google Analytics 4 Web" integration with true', async () => { + await simulateUserAcceptConsents() + + const spy = sinon.stub() + await suiAnalytics.track('fakeEvent', {}, {}, spy) + + const {context} = spy.firstCall.firstArg.obj + + expect(context.integrations['Google Analytics 4 Web']).to.equal(true) + expect(context.integrations['Google Analytics 4']).to.be.undefined + }) + + it('should NOT add analytics_storage to context', async () => { + await simulateUserAcceptConsents() + + const spy = sinon.stub() + await suiAnalytics.track('fakeEvent', {}, {}, spy) + + const {context} = spy.firstCall.firstArg.obj + + expect(context.analytics_storage).to.be.undefined + }) + + it('should add google_consents to properties', async () => { + await simulateUserAcceptAnalyticsConsents() + + await suiAnalytics.track('fakeEvent', {}) + + const {properties} = getDataFromLastTrack() + + expect(properties.google_consents).to.deep.include({ + analytics_storage: 'granted', + ad_storage: 'denied' + }) + }) + }) + }) + describe('isFirstVisit flag', () => { let cookiesStub @@ -868,36 +742,34 @@ describe('Segment Wrapper', function () { stubDocumentCookie(`${XANDR_ID_COOKIE}=${givenXandrId}`) }) - it('should send analytics storage GRANTED if user has accepted consent', async () => { - await simulateUserAcceptConsents() - window.google_tag_data = { - ics: { - getConsentState: () => 1 - } - } + it('should send google_consents in properties if user has accepted consent', async () => { + await simulateUserAcceptAnalyticsConsents() - await suiAnalytics.track('fakeEvent') + await suiAnalytics.track('fakeEvent', {custom_prop: 'value'}) - const {context} = getDataFromLastTrack() + const {properties} = getDataFromLastTrack() - expect(context.analytics_storage).to.equal('granted') + expect(properties.google_consents).to.include({ + analytics_storage: 'granted', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied' + }) }) - it('should send analytics storage `denied` if fail to read it', async () => { - await simulateUserAcceptConsents() - window.google_tag_data = { - ics: { - getConsentState: () => { - throw new Error("ERROR: Couldn't read the consent state") - } - } - } + it('should send google_consents in properties with denied values if user declined', async () => { + await simulateUserDeclinedConsents() await suiAnalytics.track('fakeEvent') - const {context} = getDataFromLastTrack() + const {properties} = getDataFromLastTrack() - expect(context.analytics_storage).to.equal('denied') + expect(properties.google_consents).to.include({ + analytics_storage: 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied' + }) }) it('should send the xandrId as externalId, that where stored in a cookie', async () => { @@ -941,57 +813,6 @@ describe('Segment Wrapper', function () { }) }) - describe('google_consents', () => { - it('should be populated with GTM API values when available', async () => { - // Given - await simulateUserAcceptConsents() - const getConsentState = sinon.stub() - getConsentState.withArgs('analytics_storage').returns(1) // GRANTED - getConsentState.withArgs('ad_storage').returns(2) // DENIED - getConsentState.withArgs('ad_user_data').returns(1) // GRANTED - getConsentState.withArgs('ad_personalization').returns(2) // DENIED - - window.google_tag_data = { - ics: { - getConsentState - } - } - - // When - await suiAnalytics.track('fakeEvent') - const {context} = getDataFromLastTrack() - - // Then - expect(context.google_consents).to.deep.equal({ - analytics_storage: 'granted', - ad_storage: 'denied', - ad_user_data: 'granted', - ad_personalization: 'denied' - }) - - delete window.google_tag_data - }) - - it('should fallback to CMP values when GTM API is not available', async () => { - // Given - await simulateUserDeclinedAnalyticsConsentsAndAcceptedAdvertisingConsents() - // window.google_tag_data is undefined - - // When - await suiAnalytics.track('fakeEvent') - const {context} = getDataFromLastTrack() - - // Then - // Fallback logic derives from CMP consent, which is what we simulated - expect(context.google_consents).to.deep.equal({ - analytics_storage: 'denied', - ad_storage: 'granted', - ad_user_data: 'granted', - ad_personalization: 'granted' - }) - }) - }) - describe('Safari ITP Protection', function () { let locationStub let referrerStub diff --git a/packages/sui-segment-wrapper/test/stubs.js b/packages/sui-segment-wrapper/test/stubs.js index 94d9540f8..63258f9e1 100644 --- a/packages/sui-segment-wrapper/test/stubs.js +++ b/packages/sui-segment-wrapper/test/stubs.js @@ -59,7 +59,34 @@ export const stubGoogleAnalytics = () => { } } -export const stubWindowObjects = ({borosMock = true, borosSuccess = true, isDmpAccepted = true} = {}) => { +export const stubGA4DestinationEnabled = () => { + if (!window.analytics.settings) { + window.analytics.settings = {cdnSettings: {integrations: {}}} + } + if (!window.analytics.settings.cdnSettings) { + window.analytics.settings.cdnSettings = {integrations: {}} + } + if (!window.analytics.settings.cdnSettings.integrations) { + window.analytics.settings.cdnSettings.integrations = {} + } + window.analytics.settings.cdnSettings.integrations['Google Analytics 4 Web'] = { + name: 'Google Analytics 4 Web', + versionSettings: {} + } +} + +export const stubGA4DestinationDisabled = () => { + if (window.analytics?.settings?.cdnSettings?.integrations) { + delete window.analytics.settings.cdnSettings.integrations['Google Analytics 4 Web'] + } +} + +export const stubWindowObjects = ({ + borosMock = true, + borosSuccess = true, + isDmpAccepted = true, + ga4DestinationEnabled = false +} = {}) => { stubTcfApi() const _middlewares = [] @@ -84,6 +111,13 @@ export const stubWindowObjects = ({borosMock = true, borosSuccess = true, isDmpA // saved locally so it mantains updated values when // executing test successively which changes the value _testAnonymousId: 'fakeAnonymousId', + settings: { + cdnSettings: { + integrations: ga4DestinationEnabled + ? {'Google Analytics 4 Web': {name: 'Google Analytics 4 Web', versionSettings: {}}} + : {} + } + }, _stubUser: () => { if (window.analytics.user) return