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
4 changes: 3 additions & 1 deletion packages/sui-segment-wrapper/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ if (isClient && window.analytics) {

if (googleAnalyticsMeasurementId) {
const googleAnalyticsConfig = getConfig('googleAnalyticsConfig')
const cookiePrefix = getConfig('googleAnalyticsCookiePrefix') || 'segment'

window[dataLayerName] = window[dataLayerName] || []
window.gtag =
Expand All @@ -62,7 +63,7 @@ if (isClient && window.analytics) {
window.gtag('js', new Date())
if (needsConsentManagement) sendGoogleConsents()
window.gtag('config', googleAnalyticsMeasurementId, {
cookie_prefix: 'segment',
cookie_prefix: cookiePrefix,
send_page_view: false,
...googleAnalyticsConfig,
...getCampaignDetails()
Expand All @@ -80,3 +81,4 @@ export default analytics
export {getAdobeVisitorData, getAdobeMCVisitorID} from './repositories/adobeRepository.js'
export {getUniversalId} from './universalId.js'
export {EVENTS} from './events.js'
export {getGA4Data, getGoogleClientId, getGoogleSessionId} from './repositories/googleRepository.js'
158 changes: 139 additions & 19 deletions packages/sui-segment-wrapper/src/repositories/googleRepository.js
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 {getGA4SessionIdFromCookie} from '../utils/cookies.js'

const FIELDS = {
clientId: 'client_id',
Expand Down Expand Up @@ -71,33 +72,77 @@ export const loadGoogleAnalytics = async () => {
return loadScript(gtagScript)
}

// Trigger GA init event just once per session.
const triggerGoogleAnalyticsInitEvent = sessionId => {
/**
* Checks if a session is new by comparing with localStorage.
* This function is idempotent and safe to call multiple times with the same sessionId.
*
* @param {string} sessionId - Current session ID
* @returns {{isNewSession: boolean, cachedSessionId: string|null}}
*/
const checkNewSession = sessionId => {
const storageKey = 'ga_session_id'
let cachedSessionId = null

try {
cachedSessionId = window.localStorage.getItem(storageKey)
} catch (e) {
// localStorage might not be available
return {isNewSession: false, cachedSessionId: null}
}

const isNewSession = String(cachedSessionId) !== String(sessionId)

// Only update localStorage if it's actually a new session
if (isNewSession && sessionId) {
try {
window.localStorage.setItem(storageKey, sessionId)
} catch (e) {
// localStorage might not be available
}
}

return {isNewSession, cachedSessionId}
}

/**
* Trigger GA init event just once per session.
* Uses checkNewSession to detect if it's a new session before sending the event.
*
* @param {string} sessionId - Current session ID
* @param {boolean} isNewSession - Whether this is a new session (from checkNewSession)
*/
const triggerGoogleAnalyticsInitEvent = (sessionId, isNewSession) => {
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)
// Only send event if it's a new session and we haven't sent it yet
try {
const alreadySent = localStorage.getItem(eventKey)

// eslint-disable-next-line no-console
console.log(`Sending GA4 event "${eventName}" for the session "${sessionId}"`)
if (isNewSession && !alreadySent && sessionId) {
// Send the event
window.gtag('event', eventName)

// 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}})
}
// eslint-disable-next-line no-console
console.log(`Sending GA4 event "${eventName}" for the session "${sessionId}"`)

// Mark as sent
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)
// Clean old GA sessions hits from the storage
Object.keys(localStorage).forEach(key => {
if (key.startsWith(eventPrefix) && key !== eventKey) {
localStorage.removeItem(key)
}
})
}
})
} catch (e) {
// localStorage might not be available
}
}

const getGoogleField = async field => {
Expand Down Expand Up @@ -184,14 +229,89 @@ function readFromUtm(searchParams) {
}

export const getGoogleClientId = async () => getGoogleField(FIELDS.clientId)

/**
* Exposes GA4 data to window for debugging and compatibility.
* Also resolves a global promise if available (window.resolveGAData).
*
* @param {object} gaData - GA4 data object
*/
const exposeGA4Data = gaData => {
window.__GA4_DATA = gaData

if (typeof window.resolveGAData === 'function') {
window.resolveGAData(gaData)
}
}

// Cache to track if we've already logged the new session
let hasLoggedNewSession = false

/**
* Gets the Google Analytics session ID, prioritizing the cookie value over the API.
* This avoids race conditions where gtag.get('session_id') returns an incorrect value
* in the first hits before the cookie is fully written.
*
* Also detects and stores new sessions in localStorage for tracking purposes.
* Safe to call multiple times - will only log once per session.
*
* @returns {Promise<string>} The session ID
*/
export const getGoogleSessionId = async () => {
const sessionId = await getGoogleField(FIELDS.sessionId)
const cookiePrefix = getConfig('googleAnalyticsCookiePrefix') || 'segment'

// First, get the session ID from gtag API (may be incorrect in first hits)
const apiSessionId = await getGoogleField(FIELDS.sessionId)

// Try to read the session ID directly from the cookie (more reliable)
const cookieSessionId = getGA4SessionIdFromCookie(cookiePrefix)

// Prioritize cookie value if available, fallback to API
const sessionId = cookieSessionId || apiSessionId

// Check if this is a new session and store it
const {isNewSession} = checkNewSession(sessionId)

triggerGoogleAnalyticsInitEvent(sessionId)
// Only log once per session to avoid spam in console
if (isNewSession && sessionId && !hasLoggedNewSession) {
hasLoggedNewSession = true
// eslint-disable-next-line no-console
console.log(`New GA4 session started: ${sessionId} (Source: ${cookieSessionId ? 'Cookie' : 'API'})`)
} else if (!isNewSession) {
// Reset flag if we're back to the same session
hasLoggedNewSession = false
}

// Trigger GA4 init event if it's a new session
triggerGoogleAnalyticsInitEvent(sessionId, isNewSession)

return sessionId
}

/**
* Gets both client ID and session ID from GA4 and exposes them globally.
* This is useful for debugging and ensures data consistency.
*
* @returns {Promise<{clientId: string, sessionId: string, cachedSessionId: string, isNewSession: boolean}>}
*/
export const getGA4Data = async () => {
const [clientId, sessionId] = await Promise.all([getGoogleClientId(), getGoogleSessionId()])

// Reuse the session check logic
const {isNewSession, cachedSessionId} = checkNewSession(sessionId)

const gaData = {
clientId,
sessionId,
cachedSessionId,
isNewSession
}

exposeGA4Data(gaData)

return gaData
}

// Unified consent state getter.
// Returns GRANTED, DENIED or undefined (default / unknown / unavailable).
export function getGoogleConsentValue(consentType = 'analytics_storage') {
Expand Down
25 changes: 25 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,31 @@ export function readCookie(cookieName) {
return value !== null ? unescape(value[1]) : null
}

/**
* Reads the GA4 session ID directly from the cookie to avoid race conditions with gtag API.
* The cookie format is: _ga_<CONTAINER_ID>=GS1.1.<sessionId>.<timestamp>...
*
* @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
Loading