From 17943a7ad82c3928e477945268eb56fb10dcdb56 Mon Sep 17 00:00:00 2001 From: Kiko Ruiz Date: Tue, 28 Apr 2026 08:06:54 +0200 Subject: [PATCH 1/5] feat(packages/sui-segment-wrapper): remove manual ga4 implementation BREAKING CHANGES: ga4 web mode destinations needs to be used on segment --- packages/sui-segment-wrapper/README.md | 9 - packages/sui-segment-wrapper/src/index.js | 35 -- .../src/middlewares/source/campaignContext.js | 13 +- .../src/repositories/googleRepository.js | 145 ------- .../sui-segment-wrapper/src/segmentWrapper.js | 98 ++--- packages/sui-segment-wrapper/src/tcf.js | 1 + .../sui-segment-wrapper/test/assertions.js | 2 - .../test/repositories/googleRepositorySpec.js | 75 +--- .../test/segmentWrapperSpec.js | 407 +++--------------- 9 files changed, 94 insertions(+), 691 deletions(-) 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..51a0a7934 100644 --- a/packages/sui-segment-wrapper/src/index.js +++ b/packages/sui-segment-wrapper/src/index.js @@ -6,12 +6,6 @@ import {defaultContextProperties} from './middlewares/source/defaultContextPrope import {pageReferrer} from './middlewares/source/pageReferrer.js' import {userScreenInfo} from './middlewares/source/userScreenInfo.js' import {userTraits} from './middlewares/source/userTraits.js' -import { - DEFAULT_DATA_LAYER_NAME, - getCampaignDetails, - loadGoogleAnalytics, - sendGoogleConsents -} from './repositories/googleRepository.js' import {checkAnonymousId} from './utils/checkAnonymousId.js' import {getConfig, isClient} from './config.js' import analytics from './segmentWrapper.js' @@ -44,40 +38,11 @@ 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') - - if (googleAnalyticsMeasurementId) { - const googleAnalyticsConfig = getConfig('googleAnalyticsConfig') - - 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) } export default analytics -export {getGoogleClientId, getGoogleSessionId} from './repositories/googleRepository.js' export {getUniversalId} from './universalId.js' export {EVENTS} from './events.js' 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..026c6b6f0 100644 --- a/packages/sui-segment-wrapper/src/repositories/googleRepository.js +++ b/packages/sui-segment-wrapper/src/repositories/googleRepository.js @@ -1,24 +1,6 @@ -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' -const FIELDS = { - clientId: 'client_id', - sessionId: 'session_id' -} - -export const DEFAULT_DATA_LAYER_NAME = 'dataLayer' - -export const CONSENT_STATES = { - granted: 'granted', - denied: 'denied' -} - -const CONSENT_STATE_GRANTED_VALUE = 1 -const CONSENT_STATE_DENIED_VALUE = 2 - const STC = { QUERY: 'stc', SPLIT_SYMBOL: '-', @@ -45,73 +27,8 @@ const STC_MEDIUM_TRANSFORMATIONS = { cs: 'cross-sites' } const STC_INVALID_CONTENT = 'na' -const DEFAULT_GA_INIT_EVENT = 'sui' - const EMPTY_STC = {medium: null, source: null, campaign: null} -const loadScript = async src => - new Promise(function (resolve, reject) { - const script = document.createElement('script') - - script.src = src - script.onload = resolve - script.onerror = reject - document.head.appendChild(script) - }) - -export const loadGoogleAnalytics = async () => { - const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') - const dataLayerName = getConfig('googleAnalyticsDataLayer') || DEFAULT_DATA_LAYER_NAME - - // Check we have the needed config to load the script - if (!googleAnalyticsMeasurementId) return Promise.resolve(false) - // Create the `gtag` script - const gtagScript = `https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsMeasurementId}&l=${dataLayerName}` - // Load it and retrieve the `clientId` from Google - return loadScript(gtagScript) -} - -// Trigger GA init event just once per session. -const triggerGoogleAnalyticsInitEvent = sessionId => { - const eventName = getConfig('googleAnalyticsInitEvent') ?? DEFAULT_GA_INIT_EVENT - const eventPrefix = `ga_event_${eventName}_` - const eventKey = `${eventPrefix}${sessionId}` - - if (typeof window.gtag === 'undefined') return - - // Check if the event has already been sent in this session. - if (!localStorage.getItem(eventKey)) { - // If not, send it. - window.gtag('event', eventName) - - // eslint-disable-next-line no-console - console.log(`Sending GA4 event "${eventName}" for the session "${sessionId}"`) - - // And then save a new GA session hit in local storage. - localStorage.setItem(eventKey, 'true') - dispatchEvent({eventName: EVENTS.GA4_INIT_EVENT_SENT, detail: {eventName, sessionId}}) - } - - // Clean old GA sessions hits from the storage. - Object.keys(localStorage).forEach(key => { - if (key.startsWith(eventPrefix) && key !== eventKey) { - localStorage.removeItem(key) - } - }) -} - -const getGoogleField = async field => { - const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') - - // If `googleAnalyticsMeasurementId` is not present, don't load anything. - if (!googleAnalyticsMeasurementId) return Promise.resolve() - - return new Promise(resolve => { - // If it is, get it from `gtag`. - window.gtag?.('get', googleAnalyticsMeasurementId, field, resolve) - }) -} - export const trackingTagsTypes = { STC: 'stc', UTM: 'utm' @@ -182,65 +99,3 @@ function readFromUtm(searchParams) { term } } - -export const getGoogleClientId = async () => getGoogleField(FIELDS.clientId) -export const getGoogleSessionId = async () => { - const sessionId = await getGoogleField(FIELDS.sessionId) - - triggerGoogleAnalyticsInitEvent(sessionId) - - return sessionId -} - -// Unified consent state getter. -// Returns GRANTED, DENIED or undefined (default / unknown / unavailable). -export function getGoogleConsentValue(consentType = 'analytics_storage') { - try { - const value = window.google_tag_data?.ics?.getConsentState?.(consentType) - - if (value === CONSENT_STATE_GRANTED_VALUE) return CONSENT_STATES.granted - if (value === CONSENT_STATE_DENIED_VALUE) return CONSENT_STATES.denied - - return undefined - } catch (error) { - // GTM bug when first rejection happens: keep default undefined instead of forcing DENIED - return undefined - } -} - -// Backwards compatibility alias (previous getConsentState behavior now unified but default is undefined) -export const getConsentState = () => getGoogleConsentValue() ?? CONSENT_STATES.denied - -export const setGoogleUserId = userId => { - const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') - - if (!googleAnalyticsMeasurementId || !userId) return - - window.gtag?.('set', 'user_id', userId) -} - -/** - * Send consents to Google Consent Mode. - * - * @param {'default' | 'update'} mode Mode for the consent update - * @param {object} consents Consents object to be sent to Google Consent Mode. - * Defaults used when not provided: - * { - * analytics_storage: 'denied', - * ad_user_data: 'denied', - * ad_personalization: 'denied', - * ad_storage: 'denied' - * } - */ -export const sendGoogleConsents = (mode = 'default', consents) => { - window.gtag?.( - 'consent', - mode, - consents || { - analytics_storage: CONSENT_STATES.denied, - ad_user_data: CONSENT_STATES.denied, - ad_personalization: CONSENT_STATES.denied, - ad_storage: CONSENT_STATES.denied - } - ) -} diff --git a/packages/sui-segment-wrapper/src/segmentWrapper.js b/packages/sui-segment-wrapper/src/segmentWrapper.js index 07c6d502e..87f85d6dc 100644 --- a/packages/sui-segment-wrapper/src/segmentWrapper.js +++ b/packages/sui-segment-wrapper/src/segmentWrapper.js @@ -1,17 +1,8 @@ // @ts-check -import { - CONSENT_STATES, - getConsentState, - getGoogleConsentValue, - getGoogleClientId, - getGoogleSessionId, - setGoogleUserId, - sendGoogleConsents -} 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 {USER_GDPR, checkAnalyticsGdprIsAccepted, getGdprPrivacyValue} from './tcf.js' /* Default properties to be sent on all trackings */ const DEFAULT_PROPERTIES = {platform: 'web'} @@ -54,31 +45,12 @@ export const getDefaultProperties = () => ({ */ const getTrackIntegrations = async ({gdprPrivacyValue, event}) => { const isGdprAccepted = checkAnalyticsGdprIsAccepted(gdprPrivacyValue) - let sessionId - let clientId - - try { - sessionId = await getGoogleSessionId() - clientId = await getGoogleClientId() - } catch (error) { - console.error( - '[segment-wrapper] Failed to retrieve GA4 session/client IDs. Events will be sent without session attribution.', - error - ) - } - 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': - clientId && sessionId - ? { - clientId, - sessionId - } - : true + 'Google Analytics 4 Web': true } } @@ -135,14 +107,14 @@ const getExternalIds = ({context, xandrId}) => { * @param {string} gdprValue * @returns {string} consent value */ -const getConsentValue = gdprValue => (gdprValue === USER_GDPR.ACCEPTED ? CONSENT_STATES.granted : CONSENT_STATES.denied) +const getConsentValue = gdprValue => (gdprValue === USER_GDPR.ACCEPTED ? 'granted' : 'denied') /** * Get data like traits and integrations to be added to the context object * @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) @@ -150,11 +122,6 @@ export const decorateContextWithNeededData = async ({event = '', context = {}}) 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,23 +132,30 @@ export const decorateContextWithNeededData = async ({event = '', context = {}}) } } + // Add Google Consent Mode to properties + const googleConsents = { + analytics_storage: getConsentValue(gdprPrivacyValueAnalytics), + ad_storage: getConsentValue(gdprPrivacyValueAdvertising), + ad_user_data: getConsentValue(gdprPrivacyValueAdvertising), + ad_personalization: getConsentValue(gdprPrivacyValueAdvertising) + } + return { - ...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 + context: { + ...context, + ...(!isGdprAccepted && {ip: '0.0.0.0'}), + ...getExternalIds({context, xandrId}), + clientVersion: `segment-wrapper@${process.env.VERSION ?? '0.0.0'}`, + gdpr_privacy: gdprPrivacyValueAnalytics, + gdpr_privacy_advertising: gdprPrivacyValueAdvertising, + integrations: { + ...context.integrations, + ...integrations + } }, - integrations: { - ...context.integrations, - ...integrations + properties: { + ...properties, + google_consents: googleConsents } } } @@ -197,17 +171,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,15 +197,9 @@ const track = (event, properties, context = {}, callback) => } } - const needsConsentManagement = getConfig('googleAnalyticsConsentManagement') - - if (needsConsentManagement && event === CMP_TRACK_EVENT) { - sendGoogleConsents('update', newContext.google_consents) - } - window.analytics.track( event, - newProperties, + decoratedProperties, { ...newContext, context: { @@ -256,8 +228,6 @@ const identify = async (userIdParam, traits, options, callback) => { const userId = getUserId(userIdParam) - setGoogleUserId(userId) - return window.analytics.identify( userId, checkAnalyticsGdprIsAccepted(gdprPrivacyValue) ? traits : {}, 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/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..c664527b4 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,22 +444,20 @@ 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') }) }) @@ -702,11 +468,6 @@ describe('Segment Wrapper', function () { 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,7 @@ describe('Segment Wrapper', function () { const {context} = getDataFromLastTrack() const integrations = { All: false, - 'Google Analytics 4': true, + 'Google Analytics 4 Web': true, Personas: false, Webhooks: true, Webhook: true @@ -738,13 +499,6 @@ describe('Segment Wrapper', function () { protocols: {event_version: 3}, gdpr_privacy: 'declined', gdpr_privacy_advertising: 'declined', - analytics_storage: 'denied', - google_consents: { - analytics_storage: 'denied', - ad_user_data: 'denied', - ad_personalization: 'denied', - ad_storage: 'denied' - }, context: { integrations }, @@ -868,36 +622,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 +693,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 From b91b925947a58a394d6a42922047856f36a194ef Mon Sep 17 00:00:00 2001 From: Kiko Ruiz Date: Mon, 11 May 2026 15:29:23 +0200 Subject: [PATCH 2/5] feat(packages/sui-segment-wrapper): add backwards-compatible GA4 integration with automatic destinat --- packages/sui-segment-wrapper/src/index.js | 55 +++++++ .../src/repositories/googleRepository.js | 145 ++++++++++++++++++ .../sui-segment-wrapper/src/segmentWrapper.js | 126 ++++++++++++--- .../test/segmentWrapperSpec.js | 112 +++++++++++++- packages/sui-segment-wrapper/test/stubs.js | 21 ++- 5 files changed, 436 insertions(+), 23 deletions(-) diff --git a/packages/sui-segment-wrapper/src/index.js b/packages/sui-segment-wrapper/src/index.js index 51a0a7934..7c347cef1 100644 --- a/packages/sui-segment-wrapper/src/index.js +++ b/packages/sui-segment-wrapper/src/index.js @@ -6,6 +6,12 @@ import {defaultContextProperties} from './middlewares/source/defaultContextPrope import {pageReferrer} from './middlewares/source/pageReferrer.js' import {userScreenInfo} from './middlewares/source/userScreenInfo.js' import {userTraits} from './middlewares/source/userTraits.js' +import { + DEFAULT_DATA_LAYER_NAME, + getCampaignDetails, + loadGoogleAnalytics, + sendGoogleConsents +} from './repositories/googleRepository.js' import {checkAnonymousId} from './utils/checkAnonymousId.js' import {getConfig, isClient} from './config.js' import analytics from './segmentWrapper.js' @@ -37,12 +43,61 @@ const addMiddlewares = () => { } } +/** + * Check if Google Analytics 4 Web destination is enabled in Segment + * @returns {boolean} + */ +const isGA4DestinationEnabled = () => { + try { + const destinations = window.analytics?._integrations?.['Google Analytics 4 Web'] + return !!destinations + } catch (error) { + return false + } +} + if (isClient && window.analytics) { + // Check if we need to manually initialize GA4 (legacy mode) + window.analytics.ready(() => { + const useSegmentGA4Destination = isGA4DestinationEnabled() + + if (!useSegmentGA4Destination) { + // Legacy behavior: Initialize Google Analytics manually + const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + const dataLayerName = getConfig('googleAnalyticsDataLayer') || DEFAULT_DATA_LAYER_NAME + const needsConsentManagement = getConfig('googleAnalyticsConsentManagement') + + if (googleAnalyticsMeasurementId) { + const googleAnalyticsConfig = getConfig('googleAnalyticsConfig') + + 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) } export default analytics +export {getGoogleClientId, getGoogleSessionId} from './repositories/googleRepository.js' export {getUniversalId} from './universalId.js' export {EVENTS} from './events.js' diff --git a/packages/sui-segment-wrapper/src/repositories/googleRepository.js b/packages/sui-segment-wrapper/src/repositories/googleRepository.js index 026c6b6f0..9fc1b6546 100644 --- a/packages/sui-segment-wrapper/src/repositories/googleRepository.js +++ b/packages/sui-segment-wrapper/src/repositories/googleRepository.js @@ -1,6 +1,24 @@ +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' +const FIELDS = { + clientId: 'client_id', + sessionId: 'session_id' +} + +export const DEFAULT_DATA_LAYER_NAME = 'dataLayer' + +export const CONSENT_STATES = { + granted: 'granted', + denied: 'denied' +} + +const CONSENT_STATE_GRANTED_VALUE = 1 +const CONSENT_STATE_DENIED_VALUE = 2 + const STC = { QUERY: 'stc', SPLIT_SYMBOL: '-', @@ -27,8 +45,73 @@ const STC_MEDIUM_TRANSFORMATIONS = { cs: 'cross-sites' } const STC_INVALID_CONTENT = 'na' +const DEFAULT_GA_INIT_EVENT = 'sui' + const EMPTY_STC = {medium: null, source: null, campaign: null} +const loadScript = async src => + new Promise(function (resolve, reject) { + const script = document.createElement('script') + + script.src = src + script.onload = resolve + script.onerror = reject + document.head.appendChild(script) + }) + +export const loadGoogleAnalytics = async () => { + const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + const dataLayerName = getConfig('googleAnalyticsDataLayer') || DEFAULT_DATA_LAYER_NAME + + // Check we have the needed config to load the script + if (!googleAnalyticsMeasurementId) return Promise.resolve(false) + // Create the `gtag` script + const gtagScript = `https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsMeasurementId}&l=${dataLayerName}` + // Load it and retrieve the `clientId` from Google + return loadScript(gtagScript) +} + +// Trigger GA init event just once per session. +const triggerGoogleAnalyticsInitEvent = sessionId => { + const eventName = getConfig('googleAnalyticsInitEvent') ?? DEFAULT_GA_INIT_EVENT + const eventPrefix = `ga_event_${eventName}_` + const eventKey = `${eventPrefix}${sessionId}` + + if (typeof window.gtag === 'undefined') return + + // Check if the event has already been sent in this session. + if (!localStorage.getItem(eventKey)) { + // If not, send it. + window.gtag('event', eventName) + + // eslint-disable-next-line no-console + console.log(`Sending GA4 event "${eventName}" for the session "${sessionId}"`) + + // And then save a new GA session hit in local storage. + localStorage.setItem(eventKey, 'true') + dispatchEvent({eventName: EVENTS.GA4_INIT_EVENT_SENT, detail: {eventName, sessionId}}) + } + + // Clean old GA sessions hits from the storage. + Object.keys(localStorage).forEach(key => { + if (key.startsWith(eventPrefix) && key !== eventKey) { + localStorage.removeItem(key) + } + }) +} + +const getGoogleField = async field => { + const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + + // If `googleAnalyticsMeasurementId` is not present, don't load anything. + if (!googleAnalyticsMeasurementId) return Promise.resolve() + + return new Promise(resolve => { + // If it is, get it from `gtag`. + window.gtag?.('get', googleAnalyticsMeasurementId, field, resolve) + }) +} + export const trackingTagsTypes = { STC: 'stc', UTM: 'utm' @@ -99,3 +182,65 @@ function readFromUtm(searchParams) { term } } + +export const getGoogleClientId = async () => getGoogleField(FIELDS.clientId) +export const getGoogleSessionId = async () => { + const sessionId = await getGoogleField(FIELDS.sessionId) + + triggerGoogleAnalyticsInitEvent(sessionId) + + return sessionId +} + +// Unified consent state getter. +// Returns GRANTED, DENIED or undefined (default / unknown / unavailable). +export function getGoogleConsentValue(consentType = 'analytics_storage') { + try { + const value = window.google_tag_data?.ics?.getConsentState?.(consentType) + + if (value === CONSENT_STATE_GRANTED_VALUE) return CONSENT_STATES.granted + if (value === CONSENT_STATE_DENIED_VALUE) return CONSENT_STATES.denied + + return undefined + } catch (error) { + // GTM bug when first rejection happens: keep default undefined instead of forcing DENIED + return undefined + } +} + +// Backwards compatibility alias (previous getConsentState behavior now unified but default is undefined) +export const getConsentState = () => getGoogleConsentValue() ?? CONSENT_STATES.denied + +export const setGoogleUserId = userId => { + const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + + if (!googleAnalyticsMeasurementId || !userId) return + + window.gtag?.('set', 'user_id', userId) +} + +/** + * Send consents to Google Consent Mode. + * + * @param {'default' | 'update'} mode Mode for the consent update + * @param {object} consents Consents object to be sent to Google Consent Mode. + * Defaults used when not provided: + * { + * analytics_storage: 'denied', + * ad_user_data: 'denied', + * ad_personalization: 'denied', + * ad_storage: 'denied' + * } + */ +export const sendGoogleConsents = (mode = 'default', consents) => { + window.gtag?.( + 'consent', + mode, + consents || { + analytics_storage: CONSENT_STATES.denied, + ad_user_data: CONSENT_STATES.denied, + ad_personalization: CONSENT_STATES.denied, + ad_storage: CONSENT_STATES.denied + } + ) +} diff --git a/packages/sui-segment-wrapper/src/segmentWrapper.js b/packages/sui-segment-wrapper/src/segmentWrapper.js index 87f85d6dc..199f9c55e 100644 --- a/packages/sui-segment-wrapper/src/segmentWrapper.js +++ b/packages/sui-segment-wrapper/src/segmentWrapper.js @@ -1,8 +1,17 @@ // @ts-check +import { + CONSENT_STATES, + getConsentState, + getGoogleClientId, + getGoogleConsentValue, + getGoogleSessionId, + sendGoogleConsents, + setGoogleUserId +} from './repositories/googleRepository.js' import {getXandrId} from './repositories/xandrRepository.js' import {getConfig} from './config.js' -import {USER_GDPR, checkAnalyticsGdprIsAccepted, getGdprPrivacyValue} from './tcf.js' +import {USER_GDPR, CMP_TRACK_EVENT, checkAnalyticsGdprIsAccepted, getGdprPrivacyValue} from './tcf.js' /* Default properties to be sent on all trackings */ const DEFAULT_PROPERTIES = {platform: 'web'} @@ -47,10 +56,40 @@ const getTrackIntegrations = async ({gdprPrivacyValue, event}) => { const isGdprAccepted = checkAnalyticsGdprIsAccepted(gdprPrivacyValue) const restOfIntegrations = getRestOfIntegrations({isGdprAccepted, event}) - // If we don't have the user consents we remove all the integrations but GA4 + // 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 + + try { + sessionId = await getGoogleSessionId() + clientId = await getGoogleClientId() + } catch (error) { + console.error( + '[segment-wrapper] Failed to retrieve GA4 session/client IDs. Events will be sent without session attribution.', + error + ) + } + return { ...restOfIntegrations, - 'Google Analytics 4 Web': true + 'Google Analytics 4': + clientId && sessionId + ? { + clientId, + sessionId + } + : true } } @@ -102,12 +141,26 @@ const getExternalIds = ({context, xandrId}) => { return {externalIds: uniqueExternalIds} } +/** + * Check if Google Analytics 4 Web destination is enabled in Segment + * @returns {boolean} + */ +const isGA4DestinationEnabled = () => { + try { + // Check if analytics.js has loaded and has the destination + const destinations = window.analytics?._integrations?.['Google Analytics 4 Web'] + return !!destinations + } catch (error) { + return false + } +} + /** * Get consent value for Google Consent Mode * @param {string} gdprValue * @returns {string} consent value */ -const getConsentValue = gdprValue => (gdprValue === USER_GDPR.ACCEPTED ? 'granted' : 'denied') +const getConsentValue = gdprValue => (gdprValue === USER_GDPR.ACCEPTED ? CONSENT_STATES.granted : CONSENT_STATES.denied) /** * Get data like traits and integrations to be added to the context object @@ -123,6 +176,8 @@ export const decorateContextWithNeededData = async ({event = '', context = {}, p getXandrId({gdprPrivacyValueAdvertising}) ]) + const useSegmentGA4Destination = isGA4DestinationEnabled() + if (!isGdprAccepted) { context.integrations = { ...(context.integrations ?? {}), @@ -132,29 +187,45 @@ export const decorateContextWithNeededData = async ({event = '', context = {}, p } } - // Add Google Consent Mode to properties + // Add Google Consent Mode to properties (new behavior with Segment destination) const googleConsents = { - analytics_storage: getConsentValue(gdprPrivacyValueAnalytics), - ad_storage: getConsentValue(gdprPrivacyValueAdvertising), - ad_user_data: getConsentValue(gdprPrivacyValueAdvertising), - ad_personalization: getConsentValue(gdprPrivacyValueAdvertising) + 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}), + clientVersion: `segment-wrapper@${process.env.VERSION ?? '0.0.0'}`, + gdpr_privacy: gdprPrivacyValueAnalytics, + gdpr_privacy_advertising: gdprPrivacyValueAdvertising, + integrations: { + ...context.integrations, + ...integrations + } + } + + // Legacy behavior: add analytics_storage to context + if (!useSegmentGA4Destination) { + baseContext.analytics_storage = getConsentState() } return { - context: { - ...context, - ...(!isGdprAccepted && {ip: '0.0.0.0'}), - ...getExternalIds({context, xandrId}), - clientVersion: `segment-wrapper@${process.env.VERSION ?? '0.0.0'}`, - gdpr_privacy: gdprPrivacyValueAnalytics, - gdpr_privacy_advertising: gdprPrivacyValueAdvertising, - integrations: { - ...context.integrations, - ...integrations - } - }, + context: baseContext, properties: { ...properties, + // Always add google_consents to properties for consistency google_consents: googleConsents } } @@ -197,6 +268,14 @@ const track = (event, properties, context = {}, callback) => } } + const useSegmentGA4Destination = isGA4DestinationEnabled() + const needsConsentManagement = getConfig('googleAnalyticsConsentManagement') + + // Legacy behavior: send consents to GTM + if (!useSegmentGA4Destination && needsConsentManagement && event === CMP_TRACK_EVENT) { + sendGoogleConsents('update', newContext.google_consents) + } + window.analytics.track( event, decoratedProperties, @@ -228,6 +307,11 @@ const identify = async (userIdParam, traits, options, callback) => { const userId = getUserId(userIdParam) + // Legacy behavior: set user ID in gtag + if (!isGA4DestinationEnabled()) { + setGoogleUserId(userId) + } + return window.analytics.identify( userId, checkAnalyticsGdprIsAccepted(gdprPrivacyValue) ? traits : {}, diff --git a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js index c664527b4..e5c7fdade 100644 --- a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js +++ b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js @@ -486,7 +486,7 @@ describe('Segment Wrapper', function () { const {context} = getDataFromLastTrack() const integrations = { All: false, - 'Google Analytics 4 Web': true, + 'Google Analytics 4': true, Personas: false, Webhooks: true, Webhook: true @@ -499,6 +499,7 @@ describe('Segment Wrapper', function () { protocols: {event_version: 3}, gdpr_privacy: 'declined', gdpr_privacy_advertising: 'declined', + analytics_storage: 'denied', context: { integrations }, @@ -513,6 +514,115 @@ 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 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('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 diff --git a/packages/sui-segment-wrapper/test/stubs.js b/packages/sui-segment-wrapper/test/stubs.js index 94d9540f8..8bf1b84fa 100644 --- a/packages/sui-segment-wrapper/test/stubs.js +++ b/packages/sui-segment-wrapper/test/stubs.js @@ -59,7 +59,25 @@ export const stubGoogleAnalytics = () => { } } -export const stubWindowObjects = ({borosMock = true, borosSuccess = true, isDmpAccepted = true} = {}) => { +export const stubGA4DestinationEnabled = () => { + if (!window.analytics._integrations) { + window.analytics._integrations = {} + } + window.analytics._integrations['Google Analytics 4 Web'] = {name: 'Google Analytics 4 Web'} +} + +export const stubGA4DestinationDisabled = () => { + if (window.analytics._integrations) { + delete window.analytics._integrations['Google Analytics 4 Web'] + } +} + +export const stubWindowObjects = ({ + borosMock = true, + borosSuccess = true, + isDmpAccepted = true, + ga4DestinationEnabled = false +} = {}) => { stubTcfApi() const _middlewares = [] @@ -84,6 +102,7 @@ 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', + _integrations: ga4DestinationEnabled ? {'Google Analytics 4 Web': {name: 'Google Analytics 4 Web'}} : {}, _stubUser: () => { if (window.analytics.user) return From 39caed6997c963f485bfc506663e0ade872eb4c6 Mon Sep 17 00:00:00 2001 From: Kiko Ruiz Date: Mon, 11 May 2026 23:44:34 +0200 Subject: [PATCH 3/5] fix(packages/sui-segment-wrapper): fix ga4 web detection --- packages/sui-segment-wrapper/src/index.js | 14 +--------- .../sui-segment-wrapper/src/segmentWrapper.js | 15 +---------- .../src/utils/ga4Detection.js | 18 +++++++++++++ packages/sui-segment-wrapper/test/stubs.js | 27 ++++++++++++++----- 4 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 packages/sui-segment-wrapper/src/utils/ga4Detection.js diff --git a/packages/sui-segment-wrapper/src/index.js b/packages/sui-segment-wrapper/src/index.js index 7c347cef1..7719ebd0c 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' @@ -43,19 +44,6 @@ const addMiddlewares = () => { } } -/** - * Check if Google Analytics 4 Web destination is enabled in Segment - * @returns {boolean} - */ -const isGA4DestinationEnabled = () => { - try { - const destinations = window.analytics?._integrations?.['Google Analytics 4 Web'] - return !!destinations - } catch (error) { - return false - } -} - if (isClient && window.analytics) { // Check if we need to manually initialize GA4 (legacy mode) window.analytics.ready(() => { diff --git a/packages/sui-segment-wrapper/src/segmentWrapper.js b/packages/sui-segment-wrapper/src/segmentWrapper.js index 199f9c55e..c9649c48b 100644 --- a/packages/sui-segment-wrapper/src/segmentWrapper.js +++ b/packages/sui-segment-wrapper/src/segmentWrapper.js @@ -12,6 +12,7 @@ import { 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'} @@ -141,20 +142,6 @@ const getExternalIds = ({context, xandrId}) => { return {externalIds: uniqueExternalIds} } -/** - * Check if Google Analytics 4 Web destination is enabled in Segment - * @returns {boolean} - */ -const isGA4DestinationEnabled = () => { - try { - // Check if analytics.js has loaded and has the destination - const destinations = window.analytics?._integrations?.['Google Analytics 4 Web'] - return !!destinations - } catch (error) { - return false - } -} - /** * Get consent value for Google Consent Mode * @param {string} gdprValue 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..f8c32d9ef --- /dev/null +++ b/packages/sui-segment-wrapper/src/utils/ga4Detection.js @@ -0,0 +1,18 @@ +/** + * Check if Google Analytics 4 Web destination is enabled in Segment + * @returns {boolean} + */ +export const isGA4DestinationEnabled = () => { + try { + // Check if the destination is configured in Segment settings + const integrations = window.analytics?.settings?.cdnSettings?.integrations + if (!integrations || typeof integrations !== 'object') return false + + // Look for 'Google Analytics 4 Web' in the integrations object + return Object.keys(integrations).some( + key => key === 'Google Analytics 4 Web' || integrations[key]?.name === 'Google Analytics 4 Web' + ) + } catch (error) { + return false + } +} diff --git a/packages/sui-segment-wrapper/test/stubs.js b/packages/sui-segment-wrapper/test/stubs.js index 8bf1b84fa..63258f9e1 100644 --- a/packages/sui-segment-wrapper/test/stubs.js +++ b/packages/sui-segment-wrapper/test/stubs.js @@ -60,15 +60,24 @@ export const stubGoogleAnalytics = () => { } export const stubGA4DestinationEnabled = () => { - if (!window.analytics._integrations) { - window.analytics._integrations = {} + 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: {} } - window.analytics._integrations['Google Analytics 4 Web'] = {name: 'Google Analytics 4 Web'} } export const stubGA4DestinationDisabled = () => { - if (window.analytics._integrations) { - delete window.analytics._integrations['Google Analytics 4 Web'] + if (window.analytics?.settings?.cdnSettings?.integrations) { + delete window.analytics.settings.cdnSettings.integrations['Google Analytics 4 Web'] } } @@ -102,7 +111,13 @@ export const stubWindowObjects = ({ // saved locally so it mantains updated values when // executing test successively which changes the value _testAnonymousId: 'fakeAnonymousId', - _integrations: ga4DestinationEnabled ? {'Google Analytics 4 Web': {name: 'Google Analytics 4 Web'}} : {}, + settings: { + cdnSettings: { + integrations: ga4DestinationEnabled + ? {'Google Analytics 4 Web': {name: 'Google Analytics 4 Web', versionSettings: {}}} + : {} + } + }, _stubUser: () => { if (window.analytics.user) return From c3a56f1ae2bf75fb3dd6609070e2b1589275c8c3 Mon Sep 17 00:00:00 2001 From: Kiko Ruiz Date: Tue, 12 May 2026 09:35:43 +0200 Subject: [PATCH 4/5] feat(packages/sui-segment-wrapper): make ga4 web detection simpler --- packages/sui-segment-wrapper/src/index.js | 5 +++- .../src/repositories/googleRepository.js | 5 ++++ .../sui-segment-wrapper/src/segmentWrapper.js | 6 +++-- .../src/utils/ga4Detection.js | 26 +++++++++++-------- .../test/segmentWrapperSpec.js | 9 ++++--- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/sui-segment-wrapper/src/index.js b/packages/sui-segment-wrapper/src/index.js index 7719ebd0c..5e489f74a 100644 --- a/packages/sui-segment-wrapper/src/index.js +++ b/packages/sui-segment-wrapper/src/index.js @@ -45,10 +45,13 @@ const addMiddlewares = () => { } if (isClient && window.analytics) { - // Check if we need to manually initialize GA4 (legacy mode) + // 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() + // Only initialize manual GA4 if destination is not enabled (legacy mode) if (!useSegmentGA4Destination) { // Legacy behavior: Initialize Google Analytics manually const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') 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 c9649c48b..9585ce49e 100644 --- a/packages/sui-segment-wrapper/src/segmentWrapper.js +++ b/packages/sui-segment-wrapper/src/segmentWrapper.js @@ -158,13 +158,15 @@ export const decorateContextWithNeededData = async ({event = '', context = {}, p 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 useSegmentGA4Destination = isGA4DestinationEnabled() - if (!isGdprAccepted) { context.integrations = { ...(context.integrations ?? {}), diff --git a/packages/sui-segment-wrapper/src/utils/ga4Detection.js b/packages/sui-segment-wrapper/src/utils/ga4Detection.js index f8c32d9ef..66ff0c000 100644 --- a/packages/sui-segment-wrapper/src/utils/ga4Detection.js +++ b/packages/sui-segment-wrapper/src/utils/ga4Detection.js @@ -1,18 +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 = () => { - try { - // Check if the destination is configured in Segment settings - const integrations = window.analytics?.settings?.cdnSettings?.integrations - if (!integrations || typeof integrations !== 'object') return false + const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') + // If no measurement ID configured, assume they're using Segment destination + return !googleAnalyticsMeasurementId +} - // Look for 'Google Analytics 4 Web' in the integrations object - return Object.keys(integrations).some( - key => key === 'Google Analytics 4 Web' || integrations[key]?.name === 'Google Analytics 4 Web' - ) - } catch (error) { - return false - } +/** + * 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/segmentWrapperSpec.js b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js index e5c7fdade..81d27a609 100644 --- a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js +++ b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js @@ -462,8 +462,8 @@ describe('Segment Wrapper', function () { }) 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 () => { @@ -486,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 From 9dbb6f30bd54bdba983473dc0d71527d59767349 Mon Sep 17 00:00:00 2001 From: Kiko Ruiz Date: Tue, 12 May 2026 11:00:41 +0200 Subject: [PATCH 5/5] fix(packages/sui-segment-wrapper): fix legacy mode --- .../sui-segment-wrapper/src/segmentWrapper.js | 18 +++++++++++------- .../test/segmentWrapperSpec.js | 15 +++++++++++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/sui-segment-wrapper/src/segmentWrapper.js b/packages/sui-segment-wrapper/src/segmentWrapper.js index 9585ce49e..437e990f6 100644 --- a/packages/sui-segment-wrapper/src/segmentWrapper.js +++ b/packages/sui-segment-wrapper/src/segmentWrapper.js @@ -176,7 +176,7 @@ export const decorateContextWithNeededData = async ({event = '', context = {}, p } } - // Add Google Consent Mode to properties (new behavior with Segment destination) + // Build Google Consent Mode object const googleConsents = { analytics_storage: useSegmentGA4Destination ? getConsentValue(gdprPrivacyValueAnalytics) @@ -205,18 +205,21 @@ export const decorateContextWithNeededData = async ({event = '', context = {}, p } } - // Legacy behavior: add analytics_storage to context + // Legacy behavior: add analytics_storage and google_consents to context if (!useSegmentGA4Destination) { baseContext.analytics_storage = getConsentState() + baseContext.google_consents = googleConsents } return { context: baseContext, - properties: { - ...properties, - // Always add google_consents to properties for consistency - google_consents: googleConsents - } + properties: useSegmentGA4Destination + ? { + ...properties, + // New mode: add google_consents to properties + google_consents: googleConsents + } + : properties } } @@ -270,6 +273,7 @@ const track = (event, properties, context = {}, callback) => decoratedProperties, { ...newContext, + integrations: newContext.integrations, context: { integrations: { ...newContext.integrations diff --git a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js index 81d27a609..0d4918b4e 100644 --- a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js +++ b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js @@ -503,6 +503,12 @@ describe('Segment Wrapper', function () { gdpr_privacy: 'declined', gdpr_privacy_advertising: 'declined', analytics_storage: 'denied', + google_consents: { + analytics_storage: 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied' + }, context: { integrations }, @@ -562,14 +568,15 @@ describe('Segment Wrapper', function () { expect(['granted', 'denied']).to.include(context.analytics_storage) }) - it('should add google_consents to properties', async () => { + it('should add google_consents to context', async () => { await simulateUserAcceptAnalyticsConsents() - await suiAnalytics.track('fakeEvent', {}) + const spy = sinon.stub() + await suiAnalytics.track('fakeEvent', {}, {}, spy) - const {properties} = getDataFromLastTrack() + const {context} = spy.firstCall.firstArg.obj - expect(properties.google_consents).to.deep.include({ + expect(context.google_consents).to.deep.include({ analytics_storage: 'granted', ad_storage: 'denied' })