diff --git a/packages/sui-segment-wrapper/CHANGELOG.md b/packages/sui-segment-wrapper/CHANGELOG.md index 1dd7667de..65fd09f34 100644 --- a/packages/sui-segment-wrapper/CHANGELOG.md +++ b/packages/sui-segment-wrapper/CHANGELOG.md @@ -1,56 +1,5 @@ # CHANGELOG -# 4.41.0 (2026-03-30) - - -### Features - -* force bump ([605f536](https://github.com/SUI-Components/sui/commit/605f536a2667aec5230a0369a9a418ee660524a3)) -* wait for GA4 cookie and use correct container when multiple exis ([b46b471](https://github.com/SUI-Components/sui/commit/b46b47153db14c340bd2b6e5f4a517a0a278642f)) - - - -# 4.40.0 (2026-03-30) - - -### Bug Fixes - -* retrieve cookie value by measurement id ([ad91525](https://github.com/SUI-Components/sui/commit/ad9152567164f995d0e0d03f7a5a0ec010c65079)) - - - -# 4.39.0 (2026-03-30) - - -### Bug Fixes - -* fix ga4 session id inconsistencies ([d6664f0](https://github.com/SUI-Components/sui/commit/d6664f079d099e8695199605f873bdd668f57df1)) - - - -# 4.38.0 (2026-03-18) - - -### Features - -* force release ([a1f13f2](https://github.com/SUI-Components/sui/commit/a1f13f2d7439fd2453815ffa81036c8d291cd31b)) - - - -# 4.37.0 (2026-03-16) - - -### Bug Fixes - -* improve session id assignment to all destinations ([dc3653c](https://github.com/SUI-Components/sui/commit/dc3653c48a55b6d2dc0652b94bcfae8f514f6115)) - - -### Features - -* prioritize GA4 cookie over API to fix session ID inconsistencies ([48b2186](https://github.com/SUI-Components/sui/commit/48b2186b1717fa57b7f734799e45634e548612cc)) - - - # 4.36.0 (2025-12-04) diff --git a/packages/sui-segment-wrapper/README.md b/packages/sui-segment-wrapper/README.md index ad55206ae..181f97c5d 100644 --- a/packages/sui-segment-wrapper/README.md +++ b/packages/sui-segment-wrapper/README.md @@ -6,19 +6,12 @@ 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. -- [x] Send anonymous data to AWS to be able to check data integrity with Adobe. **Google Analytics 🔍** - [x] Load GA4 if `googleAnalyticsMeasurementId` is provided. - [x] Retrieve `clientId` and `sessionId` automatically from GA4 and put in Segment tracks. -**Adobe Marketing Cloud Visitor Id ☁️** - -- [x] Load _Adobe Visitor API_ when needed (if flag `importAdobeVisitorId` is set to `true`, otherwise you should load `Visitor API` by your own to get the `mcvid`). -- [x] Fetch `marketingCloudVisitorId` and put in integrations object for every track. -- [x] Monkey patch `track` native Segment method to send `marketingCloudVisitorId` inside `context.integrations`. - **Consent Management Platform 🐾** - [x] Automatic tracking of Consent Management Platform usage. @@ -127,37 +120,15 @@ import analytics from '@s-ui/segment-wrapper' ``` -### Step 3: Configure mandatory Segment Wrapper attributes: - -The following configuration parameters are required and must be set for the system to function correctly: - -- `ADOBE_ORG_ID`: This parameter is the Adobe Organization ID, required for integration with Adobe services. Please make sure that you replace the example value with your actual Adobe Org ID. -- `TRACKING_SERVER`: This specifies the tracking server URL that will be used for sending data and handling tracking requests. - - These parameters need to be defined in the `window._SEGMENT_WRAPPER` object as follows: - -```js -window.__SEGMENT_WRAPPER = { - ADOBE_ORG_ID: '012345678@AdobeOrg', // Mandatory! - TRACKING_SERVER: 'mycompany.test.net' // Mandatory! -} -``` - -Configure both values correctly before running the application to ensure proper tracking and data integration. - -### Step 4: Configure Segment Wrapper (optional) +### Step 3: Configure Segment Wrapper (optional) 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. -- `googleAnalyticsCookiePrefix`: _(optional)_ Cookie prefix for GA4 cookies. Defaults to `'segment'`. Example: if set to `'myprefix'`, will look for `myprefix_ga_` cookies. -- `googleAnalyticsCookieTimeout`: _(optional)_ Maximum time in milliseconds to wait for GA4 cookie creation. Defaults to `5000` (5 seconds). Increase this value for slower networks or devices. - `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`. -- `getCustomAdobeVisitorId`: _(optional)_ If set, the output of this function will be used as `marketingCloudVisitorId` in Adobe Analytics' integration. It must return a promise. -- `importAdobeVisitorId` _(optional)_ If set and `true`, Adobe Visitor API will be fetched from Segment Wrapper instead of relying on being loaded before from Tealium or other services. Right now, by default, this is `false` but in the next major this configuration will be `true` by default. If `getCustomAdobeVisitorId` is being used this will be ignored. - `tcfTrackDefaultProperties` _(optional)_ If set, this property will be merged together with the default properties set to send with every tcf track event - `universalId`: _(optional)_ If set this value will be used for the Visitor API and other services. - `hashedUserEmail`: _(optional)_ If set and not `universalId` is set this value will be used for the Visitor API and other services. @@ -168,25 +139,19 @@ You could put a special config in a the `window.__mpi` to change some behaviour Example: ```js - window.__mpi = { - segmentWrapper: { - googleAnalyticsMeasurementId: 'G-XXXXXXXXXX', - googleAnalyticsCookiePrefix: 'segment', // optional, defaults to 'segment' - googleAnalyticsCookieTimeout: 5000, // optional, defaults to 5000ms (5 seconds) - universalId: '7ab9ddf3281d5d5458a29e8b3ae2864', - defaultContext: { - site: 'comprocasa', - vertical: 'realestate' - }, - getCustomAdobeVisitorId: () => { - const visitorId = // get your visitorId - return Promise.resolve(visitorId) - }, - tcfTrackDefaultProperties: { - tcfSpecialProp: 'anyvalue' - } +window.__mpi = { + segmentWrapper: { + googleAnalyticsMeasurementId: 'GA-123456789', + universalId: '7ab9ddf3281d5d5458a29e8b3ae2864', + defaultContext: { + site: 'comprocasa', + vertical: 'realestate' + }, + tcfTrackDefaultProperties: { + tcfSpecialProp: 'anyvalue' } } +} ``` ### It also provides additional information such as: diff --git a/packages/sui-segment-wrapper/package.json b/packages/sui-segment-wrapper/package.json index 63a954079..4529d0723 100644 --- a/packages/sui-segment-wrapper/package.json +++ b/packages/sui-segment-wrapper/package.json @@ -1,6 +1,6 @@ { "name": "@s-ui/segment-wrapper", - "version": "4.41.0", + "version": "4.36.0", "description": "Abstraction layer on top of the Segment library.", "main": "lib/index.js", "license": "ISC", @@ -9,9 +9,9 @@ "postlib": "npm run set:version", "prepublishOnly": "npm run umd && npm run lib", "set:version": "sed -i.bak \"s/process\\.env\\.VERSION/\\\"$npm_package_version\\\"/g\" lib/segmentWrapper.js && rm lib/segmentWrapper.js.bak", - "test": "npm run test:client", - "test:client": "VERSION=0.0.0 sui-test browser --src-pattern=src/index.js -H", "test:client:watch": "VERSION=0.0.0 npm run test:client -- --watch", + "test:client": "VERSION=0.0.0 sui-test browser --src-pattern=src/index.js -H", + "test": "npm run test:client", "test:umd": "npm run umd && npx servor ./umd", "umd": "VERSION=$npm_package_version sui-bundler lib src-umd/index.js -o umd/ -p --root" }, diff --git a/packages/sui-segment-wrapper/src-umd/index.js b/packages/sui-segment-wrapper/src-umd/index.js index 1100863a7..5758cc209 100644 --- a/packages/sui-segment-wrapper/src-umd/index.js +++ b/packages/sui-segment-wrapper/src-umd/index.js @@ -1,5 +1,4 @@ -import {getAdobeMCVisitorID, getAdobeVisitorData} from '../src/repositories/adobeRepository.js' -import analytics from '../src/index.js' +import analytics, {getAdobeMCVisitorID, getAdobeVisitorData} from '../src/index.js' const w = window diff --git a/packages/sui-segment-wrapper/src/index.js b/packages/sui-segment-wrapper/src/index.js index 868ee3413..890ed85f7 100644 --- a/packages/sui-segment-wrapper/src/index.js +++ b/packages/sui-segment-wrapper/src/index.js @@ -77,7 +77,12 @@ if (isClient && window.analytics) { } export default analytics +export {getGoogleClientId, getGoogleSessionId} from './repositories/googleRepository.js' export {getUniversalId} from './universalId.js' export {EVENTS} from './events.js' -export {getGoogleClientId, getGoogleSessionId} from './repositories/googleRepository.js' -export {getAdobeVisitorData, getAdobeMCVisitorID} from './repositories/adobeRepository.js' + +// Deprecated Adobe Analytics functions - kept for backwards compatibility +// @deprecated Adobe Analytics integration has been removed +export const getAdobeVisitorData = () => Promise.resolve({trackingServer: '', version: ''}) +// @deprecated Adobe Analytics integration has been removed +export const getAdobeMCVisitorID = () => Promise.resolve('') diff --git a/packages/sui-segment-wrapper/src/middlewares/source/pageReferrer.js b/packages/sui-segment-wrapper/src/middlewares/source/pageReferrer.js index ccc96dde8..73333bb82 100644 --- a/packages/sui-segment-wrapper/src/middlewares/source/pageReferrer.js +++ b/packages/sui-segment-wrapper/src/middlewares/source/pageReferrer.js @@ -1,6 +1,24 @@ +/** + * Capture document.referrer at module load time, before Safari ITP can clear it. + * This ensures the first page event has the real referrer from the external source. + */ +export const INITIAL_DOCUMENT_REFERRER = typeof document !== 'undefined' ? document.referrer : '' + +/** + * Capture window.location.search at module load time, before Safari ITP can + * strip tracking parameters via redirects. + */ +export const INITIAL_SEARCH_STRING = typeof window !== 'undefined' ? window.location.search : '' + +/** + * Capture initial URL at module load time + */ +const INITIAL_URL = typeof window !== 'undefined' ? window.location.href : '' + export const referrerState = { spaReferrer: '', - referrer: '' + referrer: INITIAL_DOCUMENT_REFERRER, + isFirstPageViewSent: false } /** @@ -19,11 +37,14 @@ export const utils = { return `${origin}${pathname}` }, /** - * @returns {string} The actual location with protocol, domain and pathname + * @returns {string} The actual query string captured at module load (protected from Safari ITP) */ getActualQueryString: () => { - const {search} = window.location - return search + // In tests using window.history.pushState, return current search if different from initial + if (typeof window !== 'undefined' && window.location.search && window.location.search !== INITIAL_SEARCH_STRING) { + return window.location.search + } + return INITIAL_SEARCH_STRING } } @@ -33,13 +54,15 @@ export const utils = { */ export const getPageReferrer = ({isPageTrack = false} = {}) => { const {referrer, spaReferrer} = referrerState - // if we're a page, we should use the new referrer that was calculated with the previous location - // if we're a track, we should use the previous referrer, as the location hasn't changed yet - const referrerToUse = isPageTrack ? referrer : spaReferrer - // as a fallback for page and tracks, we must use always the document.referrer - // because some sites could not be using `page` or a `track` could be done - // even before the first page - return referrerToUse || utils.getDocumentReferrer() + + if (isPageTrack) { + // For page events, use referrer (initially from INITIAL_DOCUMENT_REFERRER, then from previous page location) + return referrer + } else { + // For track events, use spaReferrer if available, otherwise fall back to referrer + // This handles the case where a track happens before the first page event + return spaReferrer || referrer + } } /** @@ -66,10 +89,21 @@ export const pageReferrer = ({payload, next}) => { const referrer = getPageReferrer({isPageTrack}) + let props = {} + + if (isPageTrack && !referrerState.isFirstPageViewSent) { + referrerState.isFirstPageViewSent = true + props = { + url: INITIAL_URL, + search: INITIAL_SEARCH_STRING + } + } + payload.obj.context = { ...context, page: { ...context.page, + ...props, referrer } } diff --git a/packages/sui-segment-wrapper/src/repositories/adobeRepository.js b/packages/sui-segment-wrapper/src/repositories/adobeRepository.js deleted file mode 100644 index 8f3dd378a..000000000 --- a/packages/sui-segment-wrapper/src/repositories/adobeRepository.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @deprecated Adobe Analytics integration has been removed. - * These functions are kept for backwards compatibility but return empty values. - * Please remove any imports of these functions from your code. - */ - -/** - * @deprecated Returns empty Adobe visitor data - * @returns {Promise<{trackingServer: string, version: string}>} - */ -export const getAdobeVisitorData = () => { - return Promise.resolve({ - trackingServer: '', - version: '' - }) -} - -/** - * @deprecated Returns empty Marketing Cloud Visitor ID - * @returns {Promise} - */ -export const getAdobeMCVisitorID = () => { - return Promise.resolve('') -} diff --git a/packages/sui-segment-wrapper/src/repositories/googleRepository.js b/packages/sui-segment-wrapper/src/repositories/googleRepository.js index 1fa92e9d7..9fc1b6546 100644 --- a/packages/sui-segment-wrapper/src/repositories/googleRepository.js +++ b/packages/sui-segment-wrapper/src/repositories/googleRepository.js @@ -3,7 +3,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' -import * as cookiesUtils from '../utils/cookies.js' const FIELDS = { clientId: 'client_id', @@ -39,7 +38,7 @@ const STC_MEDIUM_TRANSFORMATIONS = { em: 'email', met: 'paid-metasearch', sem: 'paid-search', - rt: 'display', + rt: 'retargeting', sm: 'social-media', sp: 'paid-social', pn: 'push-notification', @@ -60,48 +59,6 @@ const loadScript = async src => document.head.appendChild(script) }) -// Promise that resolves when GA4 is ready and cookie is available -let ga4ReadyPromise = null - -/** - * Waits for GA4 cookie to be created by polling. - * Default max wait time: 5 seconds (configurable via googleAnalyticsCookieTimeout) - * - * @param {string} cookiePrefix - Cookie prefix (e.g., 'segment') - * @param {string} measurementId - Measurement ID (e.g., 'G-6NE7MBSF9K') - * @returns {Promise} - True if cookie was found, false if timeout - */ -const waitForGA4Cookie = (cookiePrefix, measurementId) => { - const timeoutMs = getConfig('googleAnalyticsCookieTimeout') || 5000 // Default 5 seconds - const pollInterval = 100 // Check every 100ms - const maxAttempts = Math.ceil(timeoutMs / pollInterval) - - return new Promise(resolve => { - let attempts = 0 - - const checkCookie = () => { - const cookieExists = cookiesUtils.getGA4SessionIdFromCookie(cookiePrefix, measurementId) - - if (cookieExists) { - resolve(true) - return - } - - attempts++ - if (attempts >= maxAttempts) { - // eslint-disable-next-line no-console - console.warn(`GA4 cookie not created after ${timeoutMs}ms. SessionId will not be sent to Segment.`) - resolve(false) - return - } - - setTimeout(checkCookie, pollInterval) - } - - checkCookie() - }) -} - export const loadGoogleAnalytics = async () => { const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId') const dataLayerName = getConfig('googleAnalyticsDataLayer') || DEFAULT_DATA_LAYER_NAME @@ -110,42 +67,8 @@ export const loadGoogleAnalytics = async () => { if (!googleAnalyticsMeasurementId) return Promise.resolve(false) // Create the `gtag` script const gtagScript = `https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsMeasurementId}&l=${dataLayerName}` - - // Create a promise that resolves when gtag is loaded + cookie is created - ga4ReadyPromise = loadScript(gtagScript).then(async () => { - const cookiePrefix = getConfig('googleAnalyticsCookiePrefix') || 'segment' - // Wait for the cookie to actually exist (with timeout) - await waitForGA4Cookie(cookiePrefix, googleAnalyticsMeasurementId) - }) - - return ga4ReadyPromise -} - -/** - * Waits for GA4 to be ready (only on first call). - * Subsequent calls return immediately. - * - * @returns {Promise} - */ -const waitForGA4Ready = async () => { - if (ga4ReadyPromise) { - await ga4ReadyPromise - ga4ReadyPromise = null // Only wait once - } -} - -/** - * Check if the given session ID is new (not in localStorage). - * @param {string} sessionId - The session ID to check - * @returns {{isNewSession: boolean, eventKey: string}} - Whether it's a new session and the storage key - */ -const checkNewSession = sessionId => { - const eventName = getConfig('googleAnalyticsInitEvent') ?? DEFAULT_GA_INIT_EVENT - const eventPrefix = `ga_event_${eventName}_` - const eventKey = `${eventPrefix}${sessionId}` - const isNewSession = !localStorage.getItem(eventKey) - - return {isNewSession, eventKey} + // Load it and retrieve the `clientId` from Google + return loadScript(gtagScript) } // Trigger GA init event just once per session. @@ -261,53 +184,12 @@ function readFromUtm(searchParams) { } export const getGoogleClientId = async () => getGoogleField(FIELDS.clientId) - -/** - * Gets GA4 session ID from cookie ONLY. - * - * CRITICAL BEHAVIOR: - * - Waits for GA4 to be ready on first call (ensures cookie exists) - * - Returns sessionId ONLY if available in cookie (reliable source) - * - "sui" event is triggered ONLY when sessionId is available and on new sessions - * - Both "sui" event and Segment events use the SAME sessionId from cookie - * - * This ensures: - * 1. No session mismatches between client and server-side tracking - * 2. No events sent to Segment without valid sessionId - * 3. "sui" event only sent on new sessions with correct sessionId - * 4. First track waits ~100ms for GA4, subsequent tracks are instant - * - * @returns {Promise} Session ID from cookie, or null if not ready - */ export const getGoogleSessionId = async () => { - const cookiePrefix = getConfig('googleAnalyticsCookiePrefix') || 'segment' - const measurementId = getConfig('googleAnalyticsMeasurementId') - - // Wait for GA4 to be ready (only on first call) - await waitForGA4Ready() + const sessionId = await getGoogleField(FIELDS.sessionId) - // ONLY use cookie value - this is the source of truth - // Pass measurementId to ensure we read the correct container's cookie - const cookieSessionId = cookiesUtils.getGA4SessionIdFromCookie(cookiePrefix, measurementId) - - // If cookie is available, trigger "sui" event on new sessions - if (cookieSessionId) { - const {isNewSession} = checkNewSession(cookieSessionId) - - if (isNewSession) { - triggerGoogleAnalyticsInitEvent(cookieSessionId, true) - // eslint-disable-next-line no-console - console.log(`New GA4 session started: ${cookieSessionId} (Source: Cookie)`) - } - } else { - // Cookie still not available even after waiting - // eslint-disable-next-line no-console - console.warn('GA4 cookie not available after waiting. SessionId will not be sent to Segment.') - } + triggerGoogleAnalyticsInitEvent(sessionId) - // Return cookie sessionId (or null if not ready) - // When null, Segment events will NOT include sessionId - return cookieSessionId + return sessionId } // Unified consent state getter. diff --git a/packages/sui-segment-wrapper/src/segmentWrapper.js b/packages/sui-segment-wrapper/src/segmentWrapper.js index b0e29c230..07c6d502e 100644 --- a/packages/sui-segment-wrapper/src/segmentWrapper.js +++ b/packages/sui-segment-wrapper/src/segmentWrapper.js @@ -61,15 +61,15 @@ const getTrackIntegrations = async ({gdprPrivacyValue, event}) => { sessionId = await getGoogleSessionId() clientId = await getGoogleClientId() } catch (error) { - console.error(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 - // CRITICAL: Only enable GA4 destination if we have BOTH clientId AND sessionId from cookie - // This prevents session mismatches and "Others" in GA4 reports - // When sessionId is not ready (null), we disable GA4 destination entirely + // If we don't have the user consents we remove all the integrations but GA4 return { ...restOfIntegrations, 'Google Analytics 4': @@ -78,7 +78,7 @@ const getTrackIntegrations = async ({gdprPrivacyValue, event}) => { clientId, sessionId } - : false // Disable GA4 if no sessionId available + : true } } diff --git a/packages/sui-segment-wrapper/src/utils/cookies.js b/packages/sui-segment-wrapper/src/utils/cookies.js index 2e21e25ba..0f731ad0b 100644 --- a/packages/sui-segment-wrapper/src/utils/cookies.js +++ b/packages/sui-segment-wrapper/src/utils/cookies.js @@ -4,38 +4,6 @@ export function readCookie(cookieName) { return value !== null ? unescape(value[1]) : null } -/** - * Reads the GA4 session ID directly from the cookie. - * The cookie format is: _ga_=GS1.1.s$... - * Example: segment_ga_6NE7MBSF9K=GS2.1.s1774864422$o1$g0$t1774864422$j60$l0$h0 - * - * @param {string} cookiePrefix - Cookie prefix configured in GA4 (e.g., 'segment') - * @param {string} measurementId - GA4 Measurement ID (e.g., 'G-6NE7MBSF9K'). If provided, looks for the specific container cookie. - * @returns {string|null} The session ID or null if not found - */ -export function getGA4SessionIdFromCookie(cookiePrefix = 'segment', measurementId = null) { - const cookies = document.cookie.split(';') - const sessionRegex = /\.s(\d+)/ - - // Build search string: if measurementId provided, search for specific cookie, otherwise search by prefix - const containerId = measurementId ? measurementId.replace(/^G-/, '') : '' - const searchStr = containerId - ? `${cookiePrefix ? `${cookiePrefix}_ga_${containerId}` : `_ga_${containerId}`}=` - : `${cookiePrefix ? `${cookiePrefix}_ga_` : '_ga_'}` - - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim() - if (cookie.indexOf(searchStr) === 0) { - const match = cookie.match(sessionRegex) - if (match && match[1]) { - return match[1] - } - } - } - - return null -} - const ONE_YEAR = 31_536_000 const DEFAULT_PATH = '/' const DEFAULT_SAME_SITE = 'Lax' diff --git a/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js b/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js index 328c640e1..131a53448 100644 --- a/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js +++ b/packages/sui-segment-wrapper/test/repositories/googleRepositorySpec.js @@ -1,8 +1,7 @@ import {expect} from 'chai' import sinon from 'sinon' -import {getCampaignDetails, getGoogleConsentValue, getGoogleSessionId} from '../../src/repositories/googleRepository.js' - +import {getCampaignDetails, getGoogleConsentValue} from '../../src/repositories/googleRepository.js' describe('GoogleRepository', () => { let initialTrackingTagsType @@ -60,6 +59,17 @@ describe('GoogleRepository', () => { expect(details.campaign).to.not.have.property('content') }) + it('should transform rt medium to retargeting', async () => { + setupLocation('stc=rt-source_type-campaign_name') + + const details = await getCampaignDetails() + + expect(details).to.not.be.null + expect(details.campaign).to.have.property('medium', 'retargeting') + expect(details.campaign).to.have.property('source', 'source_type') + expect(details.campaign).to.have.property('name', 'campaign_name') + }) + it('should return null when no STC param present', async () => { setupLocation('') @@ -234,107 +244,4 @@ describe('GoogleRepository', () => { expect(consentValue).to.be.undefined }) }) - - describe('getGoogleSessionId', () => { - let localStorageMock - - beforeEach(() => { - // Setup localStorage mock - localStorageMock = { - store: {}, - getItem: function (key) { - return this.store[key] || null - }, - setItem: function (key, value) { - this.store[key] = value.toString() - }, - removeItem: function (key) { - delete this.store[key] - } - } - Object.defineProperty(window, 'localStorage', { - value: localStorageMock, - writable: true, - configurable: true - }) - - // Setup window.gtag mock - window.gtag = sinon.stub() - window.__mpi = window.__mpi || {} - window.__mpi.segmentWrapper = window.__mpi.segmentWrapper || {} - window.__mpi.segmentWrapper.googleAnalyticsMeasurementId = 'G-TEST123' - - // Mock gtag to return session ID - window.gtag.callsFake((command, target, field, callback) => { - if (command === 'get' && field === 'session_id') { - const sessionId = '9999999999' - // eslint-disable-next-line n/no-callback-literal - callback(sessionId) - } - }) - - // Clear cookies between tests - document.cookie.split(';').forEach(cookie => { - const name = cookie.split('=')[0].trim() - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;` - }) - }) - - afterEach(() => { - delete window.gtag - delete window.__mpi.segmentWrapper.googleAnalyticsMeasurementId - delete window.__mpi.segmentWrapper.googleAnalyticsCookiePrefix - // Clear localStorage between tests - if (localStorageMock) { - localStorageMock.store = {} - } - }) - - it('should return cookie sessionId when available', async () => { - // Given - set actual cookie instead of stubbing - const cookieSessionId = '1234567890' - document.cookie = `segment_ga_TEST123=GS1.1.s${cookieSessionId}.1.0.${Date.now()}.0.0.0; path=/` - - // When - const result = await getGoogleSessionId() - - // Then - expect(result).to.equal(cookieSessionId) - }) - - it('should return null when cookie not available yet', async () => { - // Given - no cookie set - - // When - const result = await getGoogleSessionId() - - // Then - expect(result).to.be.null - }) - - it('should call cookie function with configured prefix', async () => { - // Given - const cookieSessionId = '5555555555' - window.__mpi.segmentWrapper.googleAnalyticsCookiePrefix = 'custom' - document.cookie = `custom_ga_TEST123=GS1.1.s${cookieSessionId}.1.0.${Date.now()}.0.0.0; path=/` - - // When - const result = await getGoogleSessionId() - - // Then - expect(result).to.equal(cookieSessionId) - }) - - it('should use default prefix when not configured', async () => { - // Given - const cookieSessionId = '7777777777' - document.cookie = `segment_ga_TEST123=GS1.1.s${cookieSessionId}.1.0.${Date.now()}.0.0.0; path=/` - - // When - const result = await getGoogleSessionId() - - // Then - expect(result).to.equal(cookieSessionId) - }) - }) }) diff --git a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js index 3bfcaf25a..274ddd561 100644 --- a/packages/sui-segment-wrapper/test/segmentWrapperSpec.js +++ b/packages/sui-segment-wrapper/test/segmentWrapperSpec.js @@ -42,6 +42,8 @@ describe('Segment Wrapper', function () { stubWindowObjects() stubGoogleAnalytics() + window.__SEGMENT_WRAPPER = window.__SEGMENT_WRAPPER || {} + window.analytics.addSourceMiddleware(userTraits) window.analytics.addSourceMiddleware(defaultContextProperties) window.analytics.addSourceMiddleware(campaignContext) @@ -147,34 +149,6 @@ describe('Segment Wrapper', function () { expect(secondPageContext.page.referrer).to.equal(initialInternalLocation) expect(secondTrackContext.page.referrer).to.equal(initialInternalLocation) }) - - it('after calling page event more than once and not referrer set', async function () { - const firstReferrer = '' - const initialInternalLocation = 'https://internal-page.com/another' - - locationStub = stubActualLocation(initialInternalLocation) - referrerStub = stubReferrer(firstReferrer, locationStub) - - const spy = sinon.stub() - - await suiAnalytics.page('Home Page', undefined, undefined, spy) - const {context: firstPageContext} = spy.lastCall.firstArg.obj - - await suiAnalytics.track('First Track', undefined, undefined, spy) - const {context: firstTrackContext} = spy.lastCall.firstArg.obj - - expect(firstPageContext.page.referrer).to.equal(firstReferrer) - expect(firstTrackContext.page.referrer).to.equal(firstReferrer) - - await suiAnalytics.page('Second Page', undefined, undefined, spy) - const {context: secondPageContext} = spy.lastCall.firstArg.obj - - await suiAnalytics.track('First Track', undefined, undefined, spy) - const {context: secondTrackContext} = spy.lastCall.firstArg.obj - - expect(secondPageContext.page.referrer).to.equal(initialInternalLocation) - expect(secondTrackContext.page.referrer).to.equal(initialInternalLocation) - }) }) describe('when the track event is called', () => { @@ -192,7 +166,7 @@ describe('Segment Wrapper', function () { 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', 'G-G123456') + setConfig('googleAnalyticsMeasurementId', 123) await simulateUserDeclinedConsents() @@ -208,13 +182,13 @@ describe('Segment Wrapper', function () { expect(context.integrations).to.deep.includes({ fakeIntegrationKey: 'fakeIntegrationValue', - 'Google Analytics 4': {clientId: 'fakeClientId', sessionId: '1234567890'} + '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', 'G-G123456') + setConfig('googleAnalyticsMeasurementId', 123) await simulateUserAcceptConsents() @@ -232,7 +206,7 @@ describe('Segment Wrapper', function () { fakeIntegrationKey: 'fakeIntegrationValue', 'Google Analytics 4': { clientId: 'fakeClientId', - sessionId: '1234567890' + sessionId: 'fakeSessionId' } }) }) @@ -724,10 +698,6 @@ describe('Segment Wrapper', function () { describe('context integrations', () => { before(() => { stubWindowObjects() - - window.__mpi = { - segmentWrapper: {} - } }) it('sends an event with the actual context and traits when the consents are declined', async () => { @@ -755,7 +725,7 @@ describe('Segment Wrapper', function () { const {context} = getDataFromLastTrack() const integrations = { All: false, - 'Google Analytics 4': false, + 'Google Analytics 4': true, Personas: false, Webhooks: true, Webhook: true @@ -1021,4 +991,89 @@ describe('Segment Wrapper', function () { }) }) }) + + describe('Safari ITP Protection', function () { + let locationStub + let referrerStub + + afterEach(function () { + referrerStub?.restore?.() + locationStub?.restore?.() + }) + + describe('referrer capture at module load', function () { + it('should capture document.referrer at module load time', async function () { + // Import the module to get the captured constants + const {INITIAL_DOCUMENT_REFERRER} = await import('../src/middlewares/source/pageReferrer.js') + + // INITIAL_DOCUMENT_REFERRER should be whatever document.referrer was when the module loaded + // In test environment it will be the karma test runner URL or empty + expect(INITIAL_DOCUMENT_REFERRER).to.be.a('string') + }) + + it('should use captured referrer for first page event even if document.referrer is empty', async function () { + // Simulate scenario where module loaded with referrer, but Safari ITP cleared it + const externalReferrer = 'https://www.google.com/search' + + locationStub = stubActualLocation('https://mysite.com') + referrerStub = stubReferrer(externalReferrer, locationStub) + + await simulateUserAcceptConsents() + + const spy = sinon.stub() + await suiAnalytics.page('Home Page', undefined, undefined, spy) + + const {context} = spy.lastCall.firstArg.obj + + // Should use the referrer set via stub (simulating captured value) + expect(context.page.referrer).to.equal(externalReferrer) + }) + }) + + describe('search params capture at module load', function () { + it('should capture window.location.search at module load time', async function () { + const {INITIAL_SEARCH_STRING} = await import('../src/middlewares/source/pageReferrer.js') + + // INITIAL_SEARCH_STRING should be whatever window.location.search was when module loaded + expect(INITIAL_SEARCH_STRING).to.be.a('string') + }) + + it('should use captured search params via getCampaignDetails', async function () { + // getCampaignDetails uses utils.getActualQueryString which returns INITIAL_SEARCH_STRING + // This test verifies the integration works (actual campaign parsing is tested elsewhere) + const {utils} = await import('../src/middlewares/source/pageReferrer.js') + + const queryString = utils.getActualQueryString() + + // Should return a string (the captured value) + expect(queryString).to.be.a('string') + }) + }) + + describe('initial URL and search capture for first page event', function () { + it('should include initial URL and search only in the first page event', async function () { + resetReferrerState() + await simulateUserAcceptConsents() + + const spy = sinon.stub() + + // First page event + await suiAnalytics.page('Home Page', undefined, undefined, spy) + const firstPageContext = spy.firstCall.firstArg.obj.context + + // Second page event + await suiAnalytics.page('Second Page', undefined, undefined, spy) + const secondPageContext = spy.secondCall.firstArg.obj.context + + // First page should have url and search from INITIAL values + expect(firstPageContext.page.url).to.be.a('string') + expect(firstPageContext.page.url.length).to.be.greaterThan(0) + expect(firstPageContext.page.search).to.be.a('string') + + // Second page should NOT have url and search properties added by pageReferrer middleware + expect(secondPageContext.page.url).to.be.undefined + expect(secondPageContext.page.search).to.be.undefined + }) + }) + }) }) diff --git a/packages/sui-segment-wrapper/test/stubs.js b/packages/sui-segment-wrapper/test/stubs.js index d17c6ab04..94d9540f8 100644 --- a/packages/sui-segment-wrapper/test/stubs.js +++ b/packages/sui-segment-wrapper/test/stubs.js @@ -43,29 +43,11 @@ export const stubFetch = ({responses = [{urlRe: /^http/, fetchResponse: {}}]} = } export const stubGoogleAnalytics = () => { - // Use numeric session ID to match real GA4 behavior (timestamps) - const fakeSessionId = '1234567890' - const fakeFields = { client_id: 'fakeClientId', - session_id: fakeSessionId + session_id: 'fakeSessionId' } - // Mock GA4 cookie with matching session ID to simulate real scenario - // Production cookie format: segment_ga_=GS2.1.s$o1$g0$t$j60$l0$h0 - // Test uses simplified format: segment_ga_=GS1.1.s.... - // Clear ALL existing GA4 cookies first (from any previous test) - document.cookie.split(';').forEach(cookie => { - const name = cookie.split('=')[0].trim() - if (name.includes('_ga_')) { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;` - } - }) - - // Set the test cookie - const cookieName = 'segment_ga_G123456' - document.cookie = `${cookieName}=GS1.1.s${fakeSessionId}.1.0.${Date.now()}.0.0.0; path=/` - window.gtag = (key, id, field, done) => { if (key === 'get') { return done(fakeFields?.[field]) @@ -188,6 +170,7 @@ export const stubDocumentCookie = (value = '') => { export const resetReferrerState = () => { referrerState.spaReferrer = '' referrerState.referrer = '' + referrerState.isFirstPageViewSent = false } export const stubActualLocation = location => sinon.stub(referrerUtils, 'getActualLocation').returns(location) @@ -197,6 +180,9 @@ export const stubActualQueryString = queryString => export const stubReferrer = (referrer, stubLocation) => { const stubDocumentReferrer = sinon.stub(referrerUtils, 'getDocumentReferrer').returns(referrer) + // Manually set referrer state since initialization happens at module load + resetReferrerState() + referrerState.referrer = referrer return { restore: () => { diff --git a/packages/sui-segment-wrapper/test/utils/cookiesSpec.js b/packages/sui-segment-wrapper/test/utils/cookiesSpec.js deleted file mode 100644 index 6cbebb9c5..000000000 --- a/packages/sui-segment-wrapper/test/utils/cookiesSpec.js +++ /dev/null @@ -1,107 +0,0 @@ -import {expect} from 'chai' - -import {getGA4SessionIdFromCookie} from '../../src/utils/cookies.js' - -describe('Cookies Utils', () => { - describe('getGA4SessionIdFromCookie', () => { - let originalCookie - - beforeEach(() => { - originalCookie = document.cookie - // Clear all cookies - document.cookie.split(';').forEach(cookie => { - const [name] = cookie.split('=') - document.cookie = `${name.trim()}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/` - }) - }) - - afterEach(() => { - // Restore original cookies (best effort) - document.cookie = originalCookie - }) - - it('should return null when GA4 cookie does not exist', () => { - const sessionId = getGA4SessionIdFromCookie('segment') - expect(sessionId).to.be.null - }) - - it('should extract session ID from GA4 cookie with default prefix', () => { - // Simulate GA4 cookie: segment_ga_CONTAINERID=GS1.1.1234567890.1.0.1234567890.0.0.0 - document.cookie = 'segment_ga_G123456=GS1.1.s1234567890.1.0.1234567890.0.0.0' - - const sessionId = getGA4SessionIdFromCookie('segment') - expect(sessionId).to.equal('1234567890') - }) - - it('should extract session ID from GA4 cookie with custom prefix', () => { - document.cookie = 'custom_ga_G123456=GS1.1.s9876543210.1.0.1234567890.0.0.0' - - const sessionId = getGA4SessionIdFromCookie('custom') - expect(sessionId).to.equal('9876543210') - }) - - it('should extract session ID from GA4 cookie with no prefix', () => { - document.cookie = '_ga_G123456=GS1.1.s5555555555.1.0.1234567890.0.0.0' - - const sessionId = getGA4SessionIdFromCookie('') - expect(sessionId).to.equal('5555555555') - }) - - it('should return null when cookie format is invalid', () => { - document.cookie = 'segment_ga_G123456=invalid_format' - - const sessionId = getGA4SessionIdFromCookie('segment') - expect(sessionId).to.be.null - }) - - it('should return null when cookie has no session marker', () => { - document.cookie = 'segment_ga_G123456=GS1.1.1234567890.1.0.1234567890.0.0.0' - - const sessionId = getGA4SessionIdFromCookie('segment') - expect(sessionId).to.be.null - }) - - it('should handle multiple cookies and find the right one', () => { - document.cookie = 'other_cookie=value123' - document.cookie = 'segment_ga_G123456=GS1.1.s7777777777.1.0.1234567890.0.0.0' - document.cookie = 'another_cookie=value456' - - const sessionId = getGA4SessionIdFromCookie('segment') - expect(sessionId).to.equal('7777777777') - }) - - it('should work with production cookie format using $ separator', () => { - // Real production format: GS2.1.s$o1$g0$t$j60$l0$h0 - document.cookie = 'segment_ga_6NE7MBSF9K=GS2.1.s1774864422$o1$g0$t1774864422$j60$l0$h0' - - const sessionId = getGA4SessionIdFromCookie('segment') - expect(sessionId).to.equal('1774864422') - }) - - it('should find specific cookie when measurementId is provided', () => { - // Set up multiple GA4 cookies (simulating multiple domains/containers) - document.cookie = 'segment_ga_6NE7MBSF9K=GS2.1.s1111111111$o1$g0$t1774864422$j60$l0$h0' - document.cookie = 'segment_ga_L1KP423S8T=GS2.1.s2222222222$o1$g0$t1774864422$j60$l0$h0' - document.cookie = 'segment_ga_YL86FK3DFK=GS2.1.s3333333333$o1$g0$t1774864422$j60$l0$h0' - - // Should find the specific container's session ID - const sessionId = getGA4SessionIdFromCookie('segment', 'G-L1KP423S8T') - expect(sessionId).to.equal('2222222222') - }) - - it('should return null if specific measurementId cookie does not exist', () => { - document.cookie = 'segment_ga_6NE7MBSF9K=GS2.1.s1111111111$o1$g0$t1774864422$j60$l0$h0' - - const sessionId = getGA4SessionIdFromCookie('segment', 'G-NONEXISTENT') - expect(sessionId).to.be.null - }) - - it('should handle measurementId without G- prefix', () => { - document.cookie = 'segment_ga_6NE7MBSF9K=GS2.1.s1111111111$o1$g0$t1774864422$j60$l0$h0' - - // Should work even if G- prefix is already removed - const sessionId = getGA4SessionIdFromCookie('segment', 'G-6NE7MBSF9K') - expect(sessionId).to.equal('1111111111') - }) - }) -})