UNPKG

@xsolla/metrika

Version:

A lightweight integration library for Xsolla Metrics (XMTS) that simplifies user tracking and analytics setup.

1,488 lines (1,467 loc) 56.9 kB
/*! * @xsolla/metrika v2.11.0 * (c) 2026 Xsolla * Released under the MIT License */ 'use strict'; var thumbmarkjs = require('@thumbmarkjs/thumbmarkjs'); var webVitals = require('web-vitals'); var uaParserJs = require('ua-parser-js'); var sonyflake$1 = require('sonyflake'); var EVENT_TYPE; (function (EVENT_TYPE) { EVENT_TYPE[EVENT_TYPE["HIT"] = 1] = "HIT"; EVENT_TYPE[EVENT_TYPE["EXTERNAL_LINK"] = 2] = "EXTERNAL_LINK"; EVENT_TYPE[EVENT_TYPE["ELEMENT_CLICK"] = 3] = "ELEMENT_CLICK"; EVENT_TYPE[EVENT_TYPE["FORM_DATA"] = 4] = "FORM_DATA"; EVENT_TYPE[EVENT_TYPE["CUSTOM_EVENT"] = 5] = "CUSTOM_EVENT"; EVENT_TYPE[EVENT_TYPE["LCP"] = 6] = "LCP"; EVENT_TYPE[EVENT_TYPE["SCROLL_TOP"] = 10] = "SCROLL_TOP"; EVENT_TYPE[EVENT_TYPE["SCROLL_MIDDLE"] = 11] = "SCROLL_MIDDLE"; EVENT_TYPE[EVENT_TYPE["SCROLL_BOTTOM"] = 12] = "SCROLL_BOTTOM"; })(EVENT_TYPE || (EVENT_TYPE = {})); const utmKeys = [ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', ]; const openstatKeys = [ 'openstat_service', 'openstat_campaign', 'openstat_ad', 'openstat_source', ]; const adClickParams = [ 'gclid', 'gbraid', 'wbraid', 'dclid', 'fbclid', 'yclid', 'msclkid', 'ttclid', 'twclid', 'li_fat_id', 'epik', 'sccid', 'irclickid', '_kx', '_openstat', ]; const DEFAULT_SERVER = 'https://minimetrika.xsolla.com'; const DEFAULT_HIT_ENDPOINT = 'hit'; class BaseTracker { constructor(analytics) { Object.defineProperty(this, "analytics", { enumerable: true, configurable: true, writable: true, value: void 0 }); this.analytics = analytics; } } const prepareName = (name, counterId) => `_mm_${name}_${counterId}`; const calculatePageLoadTime = () => { // PerformanceNavigationTiming (Navigation Timing Level 2) // Baseline Widely Available since Oct 2021: Chrome 57+, Firefox 58+, Safari 15+, Edge 79+. // Safari 13.1-14.x and iOS Safari 13.4-14.x do NOT support this. const navEntry = performance.getEntriesByType('navigation')[0]; if (navEntry && navEntry.loadEventEnd > 0) { return navEntry.loadEventEnd - navEntry.startTime; } // Fallback: performance.now() returns milliseconds since timeOrigin (navigation start). // When called after the load event (guaranteed by PageLoadTracker), this approximates // the total page load time. Available in all target browsers. return performance.now(); }; const isInternal = (url, domains) => { if (!url) return false; try { const parsedUrl = new URL(url, window.location.origin); const host = parsedUrl.host; return domains.includes(host); } catch { return false; } }; const formDataToObject = (formData) => { const object = {}; formData.forEach((value, key) => { // Reflect.has in favor of: object.hasOwnProperty(key) if (!Reflect.has(object, key)) { object[key] = value; return; } if (!Array.isArray(object[key])) { object[key] = [object[key]]; } object[key].push(value); }); return object; }; const getElementIdentifier = (element) => { const text = element.textContent?.trim() || ''; const shortText = text.length > 50 ? text.slice(0, 47) + '…' : text; const firstClass = element.classList.item(0) || ''; const getDataAttribute = () => { const dataAttrs = ['data-testid', 'data-qa', 'data-cy', 'data-id', 'data-name']; for (const attr of dataAttrs) { const value = element.getAttribute(attr); if (value) return value; } return ''; }; return (getDataAttribute() || element.id || element.getAttribute('name') || element.getAttribute('aria-label') || firstClass || shortText || element.tagName.toLowerCase()); }; const getSeconds = () => Math.round(+new Date() / 1e3); const getRandomNumber = (a = 0, b = 1073741824) => { return Math.floor(Math.random() * (b - a)) + a; }; class ClickTracker extends BaseTracker { constructor() { super(...arguments); Object.defineProperty(this, "selector", { enumerable: true, configurable: true, writable: true, value: '[data-analytics="true"]' }); Object.defineProperty(this, "listenerOptions", { enumerable: true, configurable: true, writable: true, value: { capture: true, passive: true } }); Object.defineProperty(this, "handleClick", { enumerable: true, configurable: true, writable: true, value: (event) => { const target = event.target; if (!target) return; const trackedElement = target.closest(this.selector); if (!trackedElement) return; const url = this.extractUrl(trackedElement); const referer = url ? location.href : undefined; const eventData = { type: EVENT_TYPE.ELEMENT_CLICK, data: { en: getElementIdentifier(trackedElement), url, referer, }, useBeacon: true, }; this.analytics.sendEvent(eventData); } }); } init() { if (typeof document === 'undefined') return; document.addEventListener('click', this.handleClick, this.listenerOptions); } destroy() { if (typeof document === 'undefined') return; document.removeEventListener('click', this.handleClick, this.listenerOptions); } extractUrl(element) { if (element instanceof HTMLAnchorElement && element.href) { return element.href; } const hrefAttr = element.getAttribute('href'); return hrefAttr && !hrefAttr.startsWith('javascript:') ? hrefAttr : undefined; } } const CROSS_DOMAIN_PARAMS = ['_xm', '_xsollauid', '_xmvid']; class CrossDomainTracker extends BaseTracker { constructor() { super(...arguments); Object.defineProperty(this, "allowedDomains", { enumerable: true, configurable: true, writable: true, value: new Set() }); Object.defineProperty(this, "listenerOptions", { enumerable: true, configurable: true, writable: true, value: { capture: true, passive: true } }); Object.defineProperty(this, "handleMouseDown", { enumerable: true, configurable: true, writable: true, value: (event) => { const ids = this.getIds(); if (!ids) return; const link = event.target.closest('a'); if (!link) return; if (!this.allowedDomains.has(link.host)) return; this.decorateUrl(link, ids.xsollauid, ids.visitorId); } }); Object.defineProperty(this, "handleKeyUp", { enumerable: true, configurable: true, writable: true, value: (event) => { if (event.key !== 'Enter') return; const ids = this.getIds(); if (!ids) return; const link = event.target.closest('a'); if (!link) return; if (!this.allowedDomains.has(link.host)) return; this.decorateUrl(link, ids.xsollauid, ids.visitorId); } }); Object.defineProperty(this, "handleSubmit", { enumerable: true, configurable: true, writable: true, value: (event) => { const ids = this.getIds(); if (!ids) return; const form = event.target; if (!form.action) return; try { const actionUrl = new URL(form.action, window.location.origin); if (!this.allowedDomains.has(actionUrl.host)) return; if (form.method.toUpperCase() === 'GET') { // GET forms: add hidden input (values become query params on submit) this.addHiddenInput(form, '_xm', ids.xsollauid); if (ids.visitorId) { this.addHiddenInput(form, '_xmvid', ids.visitorId); } } else { // POST forms: decorate action URL (params must be in URL, not body) this.decorateFormAction(form, actionUrl, ids.xsollauid, ids.visitorId); } } catch { // Ignore invalid action URLs } } }); } init() { const { siteDomains } = this.analytics['config']; siteDomains.forEach((d) => this.allowedDomains.add(d)); // mousedown fires before navigation for any mouse button (left, middle, right) document.addEventListener('mousedown', this.handleMouseDown, this.listenerOptions); // keyup catches Enter key on focused links (keyboard navigation) document.addEventListener('keyup', this.handleKeyUp, this.listenerOptions); // submit catches form submissions to cross-domain targets document.addEventListener('submit', this.handleSubmit, this.listenerOptions); } destroy() { if (typeof document === 'undefined') return; document.removeEventListener('mousedown', this.handleMouseDown, this.listenerOptions); document.removeEventListener('keyup', this.handleKeyUp, this.listenerOptions); document.removeEventListener('submit', this.handleSubmit, this.listenerOptions); } getIds() { const { xsollauid, visitorId } = this.analytics['config']; return xsollauid ? { xsollauid, visitorId } : null; } decorateUrl(link, uid, visitorId) { try { const url = new URL(link.href, window.location.origin); if (url.searchParams.get('_xm') === uid || url.searchParams.get('_xsollauid') === uid) return; if (url.searchParams.has('_xsollauid')) { url.searchParams.set('_xsollauid', uid); } else { url.searchParams.set('_xm', uid); } if (visitorId && !url.searchParams.has('_xmvid')) { url.searchParams.set('_xmvid', visitorId); } link.href = url.toString(); } catch { // Ignore errors when creating URL } } decorateFormAction(form, actionUrl, uid, visitorId) { if (actionUrl.searchParams.has('_xsollauid')) { actionUrl.searchParams.set('_xsollauid', uid); } else { actionUrl.searchParams.set('_xm', uid); } if (visitorId && !actionUrl.searchParams.has('_xmvid')) { actionUrl.searchParams.set('_xmvid', visitorId); } form.action = actionUrl.toString(); } addHiddenInput(form, name, value) { // Reuse existing hidden input if present, otherwise create new let input = form.querySelector(`input[type="hidden"][name="${name}"]`); if (input) { input.value = value; } else { input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = value; form.appendChild(input); } } /** * Removes cross-domain tracking parameters from the current URL. * Should be called after the SDK has read the parameters. */ static cleanUrl() { try { const url = new URL(window.location.href); let changed = false; for (const param of CROSS_DOMAIN_PARAMS) { if (url.searchParams.has(param)) { url.searchParams.delete(param); changed = true; } } if (changed) { // replaceState (not pushState) to avoid creating a back-button entry history.replaceState(history.state, '', url.toString()); } } catch { // Ignore errors } } } class ExternalLinkTracker extends BaseTracker { constructor() { super(...arguments); Object.defineProperty(this, "selector", { enumerable: true, configurable: true, writable: true, value: 'a' }); Object.defineProperty(this, "allowedDomains", { enumerable: true, configurable: true, writable: true, value: [] }); Object.defineProperty(this, "listenerOptions", { enumerable: true, configurable: true, writable: true, value: { capture: true, passive: true } }); Object.defineProperty(this, "handleClick", { enumerable: true, configurable: true, writable: true, value: (event) => { const target = event.target; if (!target) return; const link = target.closest(this.selector); if (!link) return; const linkHost = link.host; if (this.allowedDomains.includes(linkHost)) return; const eventData = { type: EVENT_TYPE.EXTERNAL_LINK, data: { url: link.href, referer: location.href, }, useBeacon: true, }; this.analytics.sendEvent(eventData); } }); } init() { const config = this.analytics['config']; document.addEventListener('click', this.handleClick, this.listenerOptions); this.allowedDomains = config.siteDomains; } destroy() { if (typeof document === 'undefined') return; document.removeEventListener('click', this.handleClick, this.listenerOptions); } } const listeners = new Set(); const listenerOptions = { passive: true }; let isListening = false; let lastUrl = typeof location !== 'undefined' ? location.href : ''; // --- Shared dispatcher --- const runListeners = (url, referer) => { listeners.forEach((listener) => { try { listener(url, referer); } catch (error) { console.error('[XsollaAnalytics] Navigation listener error', error); } }); }; const handleNavigation = () => { if (typeof location === 'undefined') return; const currentUrl = location.href; if (currentUrl === lastUrl) return; const referer = lastUrl; lastUrl = currentUrl; runListeners(currentUrl, referer); }; // --- Navigation API path (Chrome 102+, Firefox 147+, Safari 26.2+) --- let navigateHandler; const setupNavigationAPI = () => { navigateHandler = () => { handleNavigation(); }; window.navigation.addEventListener('navigate', navigateHandler); }; const teardownNavigationAPI = () => { if (navigateHandler) { window.navigation.removeEventListener('navigate', navigateHandler); navigateHandler = undefined; } }; // --- History API monkey-patch path (fallback) --- // Save from PROTOTYPE, not instance — avoids capturing another library's wrapper const nativePushState = typeof History !== 'undefined' ? History.prototype.pushState : undefined; const nativeReplaceState = typeof History !== 'undefined' ? History.prototype.replaceState : undefined; let wrappedPushState; let wrappedReplaceState; const setupHistoryMonkeyPatch = () => { if (typeof history === 'undefined') return; if (nativePushState) { wrappedPushState = function (...args) { const result = nativePushState.apply(this, args); handleNavigation(); return result; }; history.pushState = wrappedPushState; } if (nativeReplaceState) { wrappedReplaceState = function (...args) { const result = nativeReplaceState.apply(this, args); handleNavigation(); return result; }; history.replaceState = wrappedReplaceState; } window.addEventListener('popstate', handleNavigation, listenerOptions); window.addEventListener('hashchange', handleNavigation, listenerOptions); }; const teardownHistoryMonkeyPatch = () => { if (typeof history === 'undefined' || typeof window === 'undefined') return; window.removeEventListener('popstate', handleNavigation, listenerOptions); window.removeEventListener('hashchange', handleNavigation, listenerOptions); // Identity-check before restore: don't clobber patches from other libraries if (wrappedPushState && history.pushState === wrappedPushState) { history.pushState = nativePushState; } if (wrappedReplaceState && history.replaceState === wrappedReplaceState) { history.replaceState = nativeReplaceState; } wrappedPushState = undefined; wrappedReplaceState = undefined; }; // --- Public API (signature UNCHANGED) --- const setup = () => { if (isListening) return; if (typeof window === 'undefined') return; isListening = true; lastUrl = typeof location !== 'undefined' ? location.href : lastUrl; if ('navigation' in window) { setupNavigationAPI(); } else { setupHistoryMonkeyPatch(); } }; const teardown = () => { if (!isListening) return; if (typeof window !== 'undefined' && 'navigation' in window) { teardownNavigationAPI(); } else { teardownHistoryMonkeyPatch(); } isListening = false; }; const addNavigationListener = (listener) => { listeners.add(listener); if (!isListening) { setup(); } return () => { listeners.delete(listener); if (listeners.size === 0) { teardown(); } }; }; class NavigationTracker extends BaseTracker { constructor() { super(...arguments); Object.defineProperty(this, "removeListener", { enumerable: true, configurable: true, writable: true, value: void 0 }); } destroy() { this.removeListener?.(); this.removeListener = undefined; } init() { if (typeof window === 'undefined') return; if (this.removeListener) return; this.removeListener = addNavigationListener((url, referer) => { this.analytics.sendEvent({ type: EVENT_TYPE.HIT, data: { url, referer }, useBeacon: true, }); }); } } class PageLoadTracker extends BaseTracker { constructor() { super(...arguments); Object.defineProperty(this, "loadOptions", { enumerable: true, configurable: true, writable: true, value: { once: true, passive: true } }); Object.defineProperty(this, "loadHandler", { enumerable: true, configurable: true, writable: true, value: () => this.sendPageLoad() }); } init() { if (typeof window === 'undefined' || typeof document === 'undefined') return; if (document.readyState === 'complete') { this.sendPageLoad(); } else { window.addEventListener('load', this.loadHandler, this.loadOptions); } } destroy() { if (typeof window === 'undefined') return; window.removeEventListener('load', this.loadHandler, this.loadOptions); } sendPageLoad() { if (typeof window === 'undefined') return; this.analytics.plt = calculatePageLoadTime(); this.analytics.sendEvent({ type: EVENT_TYPE.HIT, useBeacon: true, }); } } // type PerformanceMetric = LCPMetric | FCPMetric | TTFBMetric | CLSMetric | INPMetric; class PerformanceTracker extends BaseTracker { init() { if (typeof window === 'undefined' || typeof document === 'undefined') return; // const filterMetric = (metric: PerformanceMetric) => { // // eslint-disable-next-line @typescript-eslint/no-unused-vars // const { entries, ...rest } = metric; // return rest; // }; webVitals.onLCP((metric) => { this.analytics.lcp = metric.value; this.analytics.sendEvent({ type: EVENT_TYPE.LCP, useBeacon: true, }); }); // onFCP((metric) => { // this.analytics.sendEvent({ // type: 'FCP', // data: { metricData: filterMetric(metric) }, // useBeacon: true, // }); // }); // onTTFB((metric) => { // this.analytics.sendEvent({ // type: 'TTFB', // data: { metricData: filterMetric(metric) }, // useBeacon: true, // }); // }); // onCLS((metric) => { // this.analytics.sendEvent({ // type: 'CLS', // data: { metricData: filterMetric(metric) }, // useBeacon: true, // }); // }); // onINP((metric) => { // this.analytics.sendEvent({ // type: 'INP', // data: { metricData: filterMetric(metric) }, // useBeacon: true, // }); // }); } } class ScrollTracker extends BaseTracker { constructor() { super(...arguments); Object.defineProperty(this, "thresholds", { enumerable: true, configurable: true, writable: true, value: [ { percent: 10, type: EVENT_TYPE.SCROLL_TOP, triggered: false }, { percent: 50, type: EVENT_TYPE.SCROLL_MIDDLE, triggered: false }, { percent: 90, type: EVENT_TYPE.SCROLL_BOTTOM, triggered: false }, ] }); Object.defineProperty(this, "ticking", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "scrollOptions", { enumerable: true, configurable: true, writable: true, value: { passive: true } }); Object.defineProperty(this, "removeNavigationListener", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "listening", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "onScroll", { enumerable: true, configurable: true, writable: true, value: () => { if (!this.ticking) { this.ticking = true; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(this.handleScroll); } else { this.handleScroll(); } } } }); Object.defineProperty(this, "handleScroll", { enumerable: true, configurable: true, writable: true, value: () => { this.ticking = false; const scrollY = window.scrollY; const docHeight = document.documentElement.scrollHeight; const winHeight = window.innerHeight; if (docHeight <= winHeight * 1.25 || docHeight <= 0) return; const scrollPercent = ((scrollY + winHeight) / docHeight) * 100; for (const threshold of this.thresholds) { if (!threshold.triggered && scrollPercent >= threshold.percent) { threshold.triggered = true; this.analytics.sendEvent({ type: threshold.type, useBeacon: true, }); } } if (this.thresholds.every((t) => t.triggered)) { this.stopScrollListener(); } } }); Object.defineProperty(this, "handleNavigation", { enumerable: true, configurable: true, writable: true, value: () => { this.resetThresholds(); this.ticking = false; this.startScrollListener(); } }); } init() { if (typeof window === 'undefined' || typeof document === 'undefined') return; if (!this.removeNavigationListener) { this.removeNavigationListener = addNavigationListener(this.handleNavigation); } this.startScrollListener(); } destroy() { this.stopScrollListener(); this.removeNavigationListener?.(); this.removeNavigationListener = undefined; } startScrollListener() { if (this.listening || typeof window === 'undefined') return; window.addEventListener('scroll', this.onScroll, this.scrollOptions); this.listening = true; } stopScrollListener() { if (!this.listening || typeof window === 'undefined') return; window.removeEventListener('scroll', this.onScroll, this.scrollOptions); this.listening = false; } resetThresholds() { for (const threshold of this.thresholds) { threshold.triggered = false; } } } let cachedRootDomain; const getRootDomain = () => { if (cachedRootDomain !== undefined) return cachedRootDomain; const hostname = window.location.hostname; // IP address or localhost — no domain attribute if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname === 'localhost') { cachedRootDomain = ''; return cachedRootDomain; } const parts = hostname.split('.'); // Walk from root (e.g. com → example.com → www.example.com) and try setting a test cookie for (let i = parts.length - 1; i >= 1; i--) { const candidate = '.' + parts.slice(i - 1).join('.'); const testName = '_xm_domain_test'; document.cookie = `${testName}=1; domain=${candidate}; path=/; max-age=1`; if (document.cookie.indexOf(testName) !== -1) { // Clean up test cookie document.cookie = `${testName}=; domain=${candidate}; path=/; max-age=0`; cachedRootDomain = candidate; return cachedRootDomain; } } cachedRootDomain = ''; return cachedRootDomain; }; const getCookie = (name) => { try { const match = document.cookie.match(new RegExp('(?:^|; )' + encodeURIComponent(name) + '=([^;]*)')); return match && match[1] ? decodeURIComponent(match[1]) : null; } catch { return null; } }; const setCookie = (name, value, minutes) => { try { const date = new Date(); date.setTime(date.getTime() + minutes * 60 * 1000); const expires = `expires=${date.toUTCString()}`; const domain = getRootDomain(); const domainPart = domain ? `; domain=${domain}` : ''; document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; ${expires}; path=/${domainPart}; SameSite=Lax`; } catch { // Silent fail — private browsing, iframe restrictions, etc. } }; const getLocalStorage = (key) => { try { const value = localStorage.getItem(key); return value ? decodeURIComponent(value) : null; } catch { return null; } }; const setLocalStorage = (key, value) => { try { localStorage.setItem(key, encodeURIComponent(value)); } catch { // Silent fail — storage full, private browsing, etc. } }; const isNotificationApiAvailable = () => { return 'Notification' in window; }; const areNotificationsEnabled = () => { if (!isNotificationApiAvailable()) return 0; return Notification.permission === 'granted' ? 1 : 0; }; const areCookiesEnabled = () => { try { const testKey = '__cookie_test__'; setCookie(testKey, '1', 1); const enabled = getCookie(testKey) === '1'; setCookie(testKey, '', -1); // cleanup return enabled ? 1 : 0; } catch { return 0; } }; const getDocumentCharset = () => { if (document.characterSet) return document.characterSet; // Fallback for older browsers if (document.charset) return document.charset; return 'unknown'; }; // export const getTimezone = (): string => { // const date = new Date(); // const offset = -date.getTimezoneOffset(); // const sign = offset >= 0 ? '+' : '-'; // const absOffset = Math.abs(offset); // const hours = String(Math.floor(absOffset / 60)).padStart(2, '0'); // const minutes = String(absOffset % 60).padStart(2, '0'); // return `UTC${sign}${hours}:${minutes}`; // }; const getTimezone = () => -new Date().getTimezoneOffset(); const getScreenResolution = () => { const width = window.screen.width; const height = window.screen.height; return { width: width, height: height, }; }; const getViewportSize = () => { const width = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; const height = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; return { width: width, height: height, }; }; const getLanguage = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const lang = navigator.language || navigator.userLanguage; return lang ? lang.split('-')[0] : 'unknown'; }; let adBlockCache; /** * Checks if an ad blocker is enabled. * Caches the result for the session lifetime. * @param timeoutMs Maximum time to wait for a network response (default is 150 ms). * @returns Promise<number> — 1 if an ad blocker is detected. */ async function isAdBlockEnabled(timeoutMs = 150) { if (adBlockCache !== undefined) { return adBlockCache ? 1 : 0; } const bait = document.createElement('div'); bait.className = 'adsbygoogle ads-ad ad-container advert'; bait.style.position = 'absolute'; bait.style.top = '-999px'; bait.style.width = '1px'; bait.style.height = '1px'; document.body.appendChild(bait); const baitBlocked = bait.offsetParent === null || bait.offsetHeight === 0 || bait.offsetWidth === 0; document.body.removeChild(bait); if (baitBlocked) { adBlockCache = 1; return 1; } return new Promise((resolve) => { const img = new Image(); let called = 0; img.onload = () => { if (!called) { called = 1; adBlockCache = 0; resolve(0); } }; img.onerror = img.onabort = () => { if (!called) { called = 1; adBlockCache = 1; resolve(1); } }; setTimeout(() => { if (!called) { called = 1; adBlockCache = 1; resolve(1); } }, timeoutMs); img.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'; }); } const getJavaEnabled = () => { try { return navigator.javaEnabled?.() ? 1 : 0; } catch { return 0; } }; const getConnectionType = () => { const nav = navigator; if (nav.connection && typeof nav.connection.effectiveType === 'string') { return nav.connection.effectiveType; } return 'unknown'; }; const isIframe = () => { try { return window.self !== window.top ? 1 : 0; } catch { return 0; } }; var env = /*#__PURE__*/Object.freeze({ __proto__: null, areCookiesEnabled: areCookiesEnabled, areNotificationsEnabled: areNotificationsEnabled, getConnectionType: getConnectionType, getDocumentCharset: getDocumentCharset, getJavaEnabled: getJavaEnabled, getLanguage: getLanguage, getScreenResolution: getScreenResolution, getTimezone: getTimezone, getViewportSize: getViewportSize, isAdBlockEnabled: isAdBlockEnabled, isIframe: isIframe, isNotificationApiAvailable: isNotificationApiAvailable }); const hasAdClickParam = (search) => { const params = new URLSearchParams(search); return adClickParams.some((key) => params.has(key)); }; const getVisitorIdFromUrl = () => { const urlParams = new URLSearchParams(window.location.search); const visitorId = urlParams.get('_xmvid'); return visitorId || null; }; const getXsollaUidFromUrl = (url) => { const urlObj = new URL(url); const xsollauid = urlObj.searchParams.get('_xsollauid') || urlObj.searchParams.get('_xm'); return xsollauid || null; }; const getXsollaUidFromCookie = () => { const xsollauid = getCookie('xsollauid'); if (xsollauid) return xsollauid; const xm = getCookie('xm'); return xm || null; }; const getXsollaUidFromLocalStorage = () => { const xsollauid = getLocalStorage('xsollauid'); if (xsollauid) return xsollauid; const xm = getLocalStorage('xm'); return xm || null; }; const generateId = () => { return `${getSeconds()}${getRandomNumber()}`; }; const getMachineId = () => { const stored = getLocalStorage('_xm_mid'); if (stored) { const num = parseInt(stored, 10); if (!isNaN(num) && num >= 0 && num <= 65535) return num; } const id = getRandomNumber(0, 65536); setLocalStorage('_xm_mid', id.toString()); return id; }; const sonyflake = new sonyflake$1.Sonyflake({ epoch: Date.UTC(2018, 1, 1, 0, 0, 0), machineId: getMachineId(), }); const generateXsollaUid = () => { const id = sonyflake.nextId(); return id.toString(); }; const isValidGeneratedId = (id) => { if (typeof id !== 'string') return false; if (id.length < 10 || id.length > 20) return false; for (let i = 0; i < id.length; i++) { const code = id.charCodeAt(i); if (code < 48 || code > 57) return false; } return true; }; const getXsollaUid = (siteDomains) => { const urlId = isInternal(document.referrer, siteDomains) ? getXsollaUidFromUrl(window.location.href) : null; if (isValidGeneratedId(urlId)) return urlId; const localStorageId = getXsollaUidFromLocalStorage(); if (isValidGeneratedId(localStorageId)) return localStorageId; const cookieId = getXsollaUidFromCookie(); if (isValidGeneratedId(cookieId)) return cookieId; return generateXsollaUid(); }; const setXsollaUid = (XsollaUid) => { const validId = isValidGeneratedId(XsollaUid) ? XsollaUid : generateXsollaUid(); const expiresMinutes = 576000; setCookie('xsollauid', validId, expiresMinutes); setLocalStorage('xsollauid', validId); }; const getVisitorId = (counterId) => { const cookieName = prepareName('vid', counterId); const expiresMinutes = 30; // 1. Cross-domain parameter → always takes priority const urlVisitorId = getVisitorIdFromUrl(); if (isValidGeneratedId(urlVisitorId)) { setCookie(cookieName, urlVisitorId, expiresMinutes); return urlVisitorId; } // 2. Ad click → new session if (hasAdClickParam(window.location.search)) { const newId = generateId(); setCookie(cookieName, newId, expiresMinutes); return newId; } // 3. Existing cookie → extend and return (session continuation) const existingId = getCookie(cookieName); if (existingId) { setCookie(cookieName, existingId, expiresMinutes); return existingId; } // 4. New session — generate new ID const newId = generateId(); setCookie(cookieName, newId, expiresMinutes); return newId; }; const getClientId = (counterId) => { const cookieName = prepareName('uid', counterId); const storedId = getCookie(cookieName) || getLocalStorage(cookieName); const expiresMinutes = 576000; if (isValidGeneratedId(storedId)) { setCookie(cookieName, storedId, expiresMinutes); setLocalStorage(cookieName, storedId); return storedId; } const clientId = generateId(); setCookie(cookieName, clientId, expiresMinutes); setLocalStorage(cookieName, clientId); return clientId; }; const getGoogleId = () => { try { const raw = getCookie('_ga'); if (!raw) return null; const match = raw.match(/(\d+\.\d+)$/); return match ? match[1] || null : null; } catch { return null; } }; const parser = new uaParserJs.UAParser(); const browser = parser.getBrowser(); function getBrowserName() { return browser.name ?? ''; } function getBrowserMajorVersion() { return browser.major ?? ''; } const getBrowserInfo = async () => { const cookieKeys = [ ['yid', '_ym_uid'], ['pv', 'paystation_visit'], ['cv', 'corpsite_visit'], ['pav', 'pa_visit'], ['dv', 'dev_visit'], ['fvp', 'first_visit'], ]; const cookies = Object.fromEntries(cookieKeys .map(([key, cookieName]) => { const value = getCookie(cookieName); return value ? [key, value] : null; }) .filter(Boolean)); return { adb: await isAdBlockEnabled(), ntf: areNotificationsEnabled(), net: getConnectionType(), t: document.title, pc: getDocumentCharset(), la: getLanguage(), tz: getTimezone(), wcw: getViewportSize().width, wch: getViewportSize().height, sw: getScreenResolution().width, sh: getScreenResolution().height, j: getJavaEnabled(), c: areCookiesEnabled(), sc: screen.colorDepth ?? screen.pixelDepth, ifr: isIframe(), gacid: getGoogleId(), ...cookies, }; }; const getUtmParams = () => { const urlParams = new URLSearchParams(window.location.search); const utm = {}; utmKeys.forEach((key) => { utm[key] = urlParams.get(key) ?? null; }); return utm; }; const getOpenstatParams = () => { const urlParams = new URLSearchParams(window.location.search); const openstatRaw = urlParams.get('_openstat'); const openstat = {}; if (openstatRaw) { const [service, campaign, ad, source] = openstatRaw.split(';'); openstat.openstat_service = service ?? null; openstat.openstat_campaign = campaign ?? null; openstat.openstat_ad = ad ?? null; openstat.openstat_source = source ?? null; } else { openstatKeys.forEach((key) => { openstat[key] = null; }); } return openstat; }; /** * Sender handles HTTP delivery with beacon or fetch. */ class Sender { constructor({ server, hitEndpoint, retryOptions, }) { /* * Base URL for the analytics server */ Object.defineProperty(this, "baseUrl", { enumerable: true, configurable: true, writable: true, value: void 0 }); /* * Endpoint for sending hits */ Object.defineProperty(this, "endpoint", { enumerable: true, configurable: true, writable: true, value: void 0 }); /* * Retry options */ Object.defineProperty(this, "retryOptions", { enumerable: true, configurable: true, writable: true, value: void 0 }); this.baseUrl = server; this.endpoint = hitEndpoint; this.retryOptions = { retries: retryOptions?.retries ?? 2, retryDelay: retryOptions?.retryDelay ?? 500, }; } /** * Sends a payload to the analytics server. * @param payload Data to send * @param useBeacon Whether to use the sendBeacon API * @param customEndpoint Optional custom endpoint for the request * * @returns Promise resolving to a boolean status of the send */ async send(payload, useBeacon, customEndpoint) { const url = new URL(customEndpoint ?? this.endpoint, this.baseUrl).toString(); const body = JSON.stringify(payload); if (useBeacon && navigator.sendBeacon) { try { return navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); } catch { // fallback to fetch } } return this.sendWithRetry(url, body, this.retryOptions.retries); } async sendWithRetry(url, body, retriesLeft) { try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true, credentials: 'include', }); if (res.ok) return true; if (retriesLeft > 0) { await this.delay(this.retryOptions.retryDelay * (this.retryOptions.retries - retriesLeft + 1)); // exponential backoff return this.sendWithRetry(url, body, retriesLeft - 1); } return false; } catch (e) { if (retriesLeft > 0) { await this.delay(this.retryOptions.retryDelay * (this.retryOptions.retries - retriesLeft + 1)); return this.sendWithRetry(url, body, retriesLeft - 1); } console.error('[Xsolla Analytics] Fetch failed', e); return false; } } delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } } /** * Xsolla Analytics SDK * * Provides a unified interface for tracking user interactions and events */ /** * XsollaAnalytics is a singleton class for collecting and sending analytics events * to the Xsolla analytics backend. It supports a variety of event types, including * page hits, external link clicks, element clicks, form submissions, custom events, * and performance metrics such as Page Load Time (PLT) and Largest Contentful Paint (LCP). * * The class manages internal configuration, handles fingerprinting for device identification, * and registers various trackers for automatic event collection. It provides a flexible API * for sending both standard and custom analytics events, with support for the Beacon API * for reliable event delivery. * * Usage: * - Initialize the singleton instance with `XsollaAnalytics.init(params)`. * - Use instance methods to track events, send custom data, or update session parameters. * - Call `destroy()` to clean up registered trackers when analytics is no longer needed. * * @example * ```typescript * const analytics = await XsollaAnalytics.init({ id: 'your-counter-id', ...otherParams }); * analytics.hit(window.location.href); * analytics.elementClick('button-name'); * analytics.setAbParams({ experiment: 'A' }); * ``` * * @remarks * - Requires a valid `id` parameter for initialization. * - Automatically collects browser, device, and session information for each event. * - Supports extension via custom trackers and event types. * */ class XsollaAnalytics { get plt() { return this._plt; } set plt(value) { this._plt = value; } get lcp() { return this._lcp; } set lcp(value) { this._lcp = value; } constructor(params) { /** Page Load Time (ms), set by PageLoadTracker */ Object.defineProperty(this, "_plt", { enumerable: true, configurable: true, writable: true, value: 0 }); /** Largest Contentful Paint (ms), set by PerformanceTracker */ Object.defineProperty(this, "_lcp", { enumerable: true, configurable: true, writable: true, value: 0 }); /** Internal configuration for analytics */ Object.defineProperty(this, "config", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** Sender instance for dispatching payloads */ Object.defineProperty(this, "sender", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** Registered tracker instances */ Object.defineProperty(this, "trackers", { enumerable: true, configurable: true, writable: true, value: [] }); const { server = DEFAULT_SERVER, hitEndpoint = DEFAULT_HIT_ENDPOINT, useBeacon = true, id, abParams = {}, extraValues = {}, siteDomains, ...rest } = params; const mergedDomains = Array.from(new Set([window.location.host, ...(Array.isArray(siteDomains) ? siteDomains : [])])); const storedXsollaUid = getXsollaUid(mergedDomains); setXsollaUid(storedXsollaUid); const clientId = getClientId(id); const visitorId = getVisitorId(id); this.config = { server, hitEndpoint, useBeacon, id, xsollauid: storedXsollaUid, clientId, visitorId, siteDomains: mergedDomains, abParams, extraValues, externalId: params.merchantId ?? params.externalId, retryOptions: params.retryOptions, ...rest, }; this.sender = new Sender({ server: this.config.server, hitEndpoint: this.config.hitEndpoint, retryOptions: this.config.retryOptions, }); } /** * Initializes the singleton instance and sets up trackers. * @param params Required analytics parameters (must include id) * @returns Promise resolving to the XsollaAnalytics instance * @throws Error if required id parameter is missing */ static async init(params) { if (!params.id) throw new TypeError('Counter `id` is required'); if (!XsollaAnalytics.instance) { const thumbmarkOptions = { logging: false, }; const thumbmark = new thumbmarkjs.Thumbmark(thumbmarkOptions); const fingerprintPromise = thumbmark.get().catch(() => null); const adBlockPromise = Promise.resolve().then(function () { return env; }) .then((mod) => mod.isAdBlockEnabled()) .catch(() => 0); const [fp] = await Promise.all([fingerprintPromise, adBlockPromise]); let fingerprint; if (fp) { fingerprint = { hash: fp.thumbmark, data: fp.components }; } XsollaAnalytics.instance = new XsollaAnalytics({ ...params, ...(fingerprint && { fingerprint }), }); await XsollaAnalytics.instance.registerTrackers(); CrossDomainTracker.cleanUrl(); } return XsollaAnalytics.instance; } /** * Registers trackers based on flags. */ async registerTrackers() { const registry = { hit: PageLoadTracker, extLink: ExternalLinkTracker, click: ClickTracker, scroll: ScrollTracker, crossDomainTracking: CrossDomainTracker, sendLoadMetrics: PerformanceTracker, navigation: NavigationTracker, }; for (const [flag, Ctor] of Object.entries(registry)) { if (this.config[flag]) { const tracker = new Ctor(this); tracker.init(); this.trackers.push(tracker); } } } /** * Builds the payload object to send to the analytics endpoint. */ async buildPayload(event) { const { type, data = {}, ...meta } = event; const browserInfo = await getBrowserInfo(); this.config.clientId = getClientId(this.config.id); this.config.visitorId = getVisitorId(this.config.id); const payload = { et: type, library_version: "2.11.0", counter_id: this.config.id, dfp: this.config.fingerprint?.hash, xsollauid: this.config.xsollauid, eid: this.config.externalId, ident: { ...browserInfo, plt: this._plt, lcp: this._lcp, clid: this.config.clientId, vid: this.config.visitorId, cts: Date.now(), }, user_agent: navigator.userAgent,