@xsolla/metrika
Version:
A lightweight integration library for Xsolla Metrics (XMTS) that simplifies user tracking and analytics setup.
1,487 lines (1,468 loc) • 56.9 kB
JavaScript
/*!
* @xsolla/metrika v2.11.0
* (c) 2026 Xsolla
* Released under the MIT License
*/
import { Thumbmark } from '@thumbmarkjs/thumbmarkjs';
import { onLCP } from 'web-vitals';
import { UAParser } from 'ua-parser-js';
import { Sonyflake } from '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;
// };
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({
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 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 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,
browser_major: getBrowserMajorVersion(),
browser_name: