diff --git a/packages/sui-segment-wrapper/src/index.js b/packages/sui-segment-wrapper/src/index.js index 75d89163c..83411be8e 100644 --- a/packages/sui-segment-wrapper/src/index.js +++ b/packages/sui-segment-wrapper/src/index.js @@ -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 = @@ -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() @@ -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' diff --git a/packages/sui-segment-wrapper/src/repositories/googleRepository.js b/packages/sui-segment-wrapper/src/repositories/googleRepository.js index 343fb2650..ab5c9097e 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 {getGA4SessionIdFromCookie} from '../utils/cookies.js' const FIELDS = { clientId: 'client_id', @@ -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 => { @@ -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} 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') { diff --git a/packages/sui-segment-wrapper/src/utils/cookies.js b/packages/sui-segment-wrapper/src/utils/cookies.js index 0f731ad0b..f9a6380bc 100644 --- a/packages/sui-segment-wrapper/src/utils/cookies.js +++ b/packages/sui-segment-wrapper/src/utils/cookies.js @@ -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_=GS1.1..... + * + * @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'