Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/sui-segment-wrapper/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ if (isClient && window.analytics) {
}

export default analytics
export {getAdobeVisitorData, getAdobeMCVisitorID} from './repositories/adobeRepository.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'
88 changes: 20 additions & 68 deletions packages/sui-segment-wrapper/src/repositories/adobeRepository.js
Original file line number Diff line number Diff line change
@@ -1,72 +1,24 @@
import {getConfig} from '../config.js'

let mcvid

const getGlobalConfig = () => {
return {
ADOBE_ORG_ID: window.__SEGMENT_WRAPPER?.ADOBE_ORG_ID,
DEFAULT_DEMDEX_VERSION: window.__SEGMENT_WRAPPER?.DEFAULT_DEMDEX_VERSION ?? '3.3.0',
TIME_BETWEEN_RETRIES: window.__SEGMENT_WRAPPER?.TIME_BETWEEN_RETRIES ?? 15,
TIMES_TO_RETRY: window.__SEGMENT_WRAPPER?.TIMES_TO_RETRY ?? 80,
SERVERS: {
trackingServer: window.__SEGMENT_WRAPPER?.TRACKING_SERVER,
trackingServerSecure: window.__SEGMENT_WRAPPER?.TRACKING_SERVER
}
}
}

const getDemdex = () => {
const config = getGlobalConfig()

return window.Visitor && window.Visitor.getInstance(config.ADOBE_ORG_ID, config.SERVERS)
}

const getMarketingCloudVisitorID = demdex => {
const mcvid = demdex && demdex.getMarketingCloudVisitorID()
return mcvid
}

const getAdobeVisitorData = () => {
const demdex = getDemdex() || {}
const config = getGlobalConfig()
const {version = config.DEFAULT_DEMDEX_VERSION} = demdex
const {trackingServer} = config.SERVERS

return Promise.resolve({trackingServer, version})
}

export const getAdobeMarketingCloudVisitorIdFromWindow = () => {
if (mcvid) return Promise.resolve(mcvid)

const config = getGlobalConfig()

return new Promise(resolve => {
function retry(retries) {
if (retries === 0) return resolve('')

const demdex = getDemdex()
mcvid = getMarketingCloudVisitorID(demdex)
return mcvid ? resolve(mcvid) : window.setTimeout(() => retry(--retries), config.TIME_BETWEEN_RETRIES)
}
retry(config.TIMES_TO_RETRY)
/**
* @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: ''
})
}

const importVisitorApiAndGetAdobeMCVisitorID = () =>
import('../scripts/adobeVisitorApi.js').then(() => {
mcvid = getAdobeMarketingCloudVisitorIdFromWindow()
return mcvid
})

const getAdobeMCVisitorID = () => {
const getCustomAdobeVisitorId = getConfig('getCustomAdobeVisitorId')
if (typeof getCustomAdobeVisitorId === 'function') {
return getCustomAdobeVisitorId()
}

return getConfig('importAdobeVisitorId') === true
? importVisitorApiAndGetAdobeMCVisitorID()
: getAdobeMarketingCloudVisitorIdFromWindow()
/**
* @deprecated Returns empty Marketing Cloud Visitor ID
* @returns {Promise<string>}
*/
export const getAdobeMCVisitorID = () => {
return Promise.resolve('')
}

export {getAdobeVisitorData, getAdobeMCVisitorID}
Original file line number Diff line number Diff line change
Expand Up @@ -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 * as cookiesUtils from '../utils/cookies.js'

const FIELDS = {
clientId: 'client_id',
Expand Down Expand Up @@ -59,6 +60,9 @@ const loadScript = async src =>
document.head.appendChild(script)
})

// Promise that resolves when GA4 is ready and cookie is available
let ga4ReadyPromise = null

export const loadGoogleAnalytics = async () => {
const googleAnalyticsMeasurementId = getConfig('googleAnalyticsMeasurementId')
const dataLayerName = getConfig('googleAnalyticsDataLayer') || DEFAULT_DATA_LAYER_NAME
Expand All @@ -67,8 +71,41 @@ 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}`
// Load it and retrieve the `clientId` from Google
return loadScript(gtagScript)

// Create a promise that resolves when gtag is loaded + cookie is ready
ga4ReadyPromise = loadScript(gtagScript).then(() => {
// Wait a tick for gtag to process config and create cookie
return new Promise(resolve => setTimeout(resolve, 100))
})

return ga4ReadyPromise
}

/**
* Waits for GA4 to be ready (only on first call).
* Subsequent calls return immediately.
*
* @returns {Promise<void>}
*/
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}
}

// Trigger GA init event just once per session.
Expand Down Expand Up @@ -184,12 +221,51 @@ 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<string|null>} Session ID from cookie, or null if not ready
*/
export const getGoogleSessionId = async () => {
const sessionId = await getGoogleField(FIELDS.sessionId)
const cookiePrefix = getConfig('googleAnalyticsCookiePrefix') || 'segment'

// Wait for GA4 to be ready (only on first call)
await waitForGA4Ready()

// ONLY use cookie value - this is the source of truth
const cookieSessionId = cookiesUtils.getGA4SessionIdFromCookie(cookiePrefix)

// If cookie is available, trigger "sui" event on new sessions
if (cookieSessionId) {
const {isNewSession} = checkNewSession(cookieSessionId)

triggerGoogleAnalyticsInitEvent(sessionId)
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.')
}

return sessionId
// Return cookie sessionId (or null if not ready)
// When null, Segment events will NOT include sessionId
return cookieSessionId
}

// Unified consent state getter.
Expand Down
12 changes: 0 additions & 12 deletions packages/sui-segment-wrapper/src/scripts/adobeVisitorApi.js

This file was deleted.

14 changes: 5 additions & 9 deletions packages/sui-segment-wrapper/src/segmentWrapper.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// @ts-check

import {getAdobeMCVisitorID} from './repositories/adobeRepository.js'
import {
CONSENT_STATES,
getConsentState,
Expand Down Expand Up @@ -51,19 +50,14 @@ export const getDefaultProperties = () => ({

/**
* Get all needed integrations depending on the gdprPrivacy value.
* One of them is the AdobeMarketingCloudVisitorId for Adobe Analytics integration.
* @param {object} param - Object with the gdprPrivacyValue and if it's a CMP Submitted event
*/
const getTrackIntegrations = async ({gdprPrivacyValue, event}) => {
const isGdprAccepted = checkAnalyticsGdprIsAccepted(gdprPrivacyValue)
let marketingCloudVisitorId
let sessionId
let clientId

try {
if (isGdprAccepted) {
marketingCloudVisitorId = await getAdobeMCVisitorID()
}
sessionId = await getGoogleSessionId()
clientId = await getGoogleClientId()
} catch (error) {
Expand All @@ -72,17 +66,19 @@ const getTrackIntegrations = async ({gdprPrivacyValue, event}) => {

const restOfIntegrations = getRestOfIntegrations({isGdprAccepted, event})

// If we don't have the user consents we remove all the integrations but Adobe Analytics nor GA4
// 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
return {
...restOfIntegrations,
'Adobe Analytics': marketingCloudVisitorId ? {marketingCloudVisitorId} : true,
'Google Analytics 4':
clientId && sessionId
? {
clientId,
sessionId
}
: true
: false // Disable GA4 if no sessionId available
}
}

Expand Down
26 changes: 26 additions & 0 deletions packages/sui-segment-wrapper/src/utils/cookies.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ 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_<CONTAINER_ID>=GS1.1.s<sessionId>$...
* Example: segment_ga_6NE7MBSF9K=GS2.1.s1774864422$o1$g0$t1774864422$j60$l0$h0
*
* @param {string} cookiePrefix - Cookie prefix configured in GA4 (e.g., 'segment')
* @returns {string|null} The session ID or null if not found
*/
export function getGA4SessionIdFromCookie(cookiePrefix = 'segment') {
const cookies = document.cookie.split(';')
const sessionRegex = /\.s(\d+)/
const searchStr = 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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ describe('optimizely middlewares integration', () => {
context: {
site: 'fakesite.fake'
},
integrations: {
'Adobe Analytics': {
mcvid: 'fakeMcvid'
}
}
integrations: {}
}
}

Expand All @@ -39,9 +35,6 @@ describe('optimizely middlewares integration', () => {
attributes: {
site: 'fakesite.fake'
}
},
'Adobe Analytics': {
mcvid: 'fakeMcvid'
}
}
}
Expand Down Expand Up @@ -88,9 +81,6 @@ describe('optimizely middlewares integration', () => {
site: 'fakesite.fake'
},
integrations: {
'Adobe Analytics': {
mcvid: 'fakeMcvid'
},
Optimizely: {
attributes: {
myAttribute: 'attributeValue'
Expand All @@ -106,9 +96,6 @@ describe('optimizely middlewares integration', () => {
site: 'fakesite.fake'
},
integrations: {
'Adobe Analytics': {
mcvid: 'fakeMcvid'
},
Optimizely: {
attributes: {
site: 'fakesite.fake',
Expand Down
Loading
Loading