import { ACTIVITY_INTERVAL_MS, ANALYTICS_BATCH_SIZE, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT } from '../../constants/analytics.js'; interface AnalyticsTransport { events(events: Record[]): Promise; activity(visible: boolean): Promise } export function createClientAnalytics(transport: AnalyticsTransport) { const queue = new Map; at: number; attempts: number }>(); let sending = false, pageSent = false, enabled = false, destroyed = false, lastVisible = -Infinity, lastHidden = -Infinity; function event(eventType: string, fields: Record = {}) { if (queue.size >= RETRY_QUEUE_LIMIT) return; const eventId = crypto.randomUUID(); queue.set(eventId, { data: { eventId, eventType, ...fields }, at: Date.now(), attempts: 0 }); if (queue.size >= ANALYTICS_BATCH_SIZE) void flush(); } async function flush() { if (sending || !enabled || destroyed) return; sending = true; for (const [id, item] of queue) if (Date.now() - item.at > RETRY_MAX_AGE_MS || item.attempts >= 6) queue.delete(id); while (queue.size && !destroyed) { const batch = [...queue.entries()].slice(0, ANALYTICS_BATCH_SIZE); try { await transport.events(batch.map(([, item]) => ({ ...item.data, delayed: ++item.attempts > 1 }))); batch.forEach(([id]) => queue.delete(id)); } catch (error) { if ((error as { code?: string }).code === 'INVALID_ANALYTICS_EVENT') batch.forEach(([id]) => queue.delete(id)); break; } if (queue.size < ANALYTICS_BATCH_SIZE) break; } sending = false; } function activity() { if (!enabled || destroyed) return; const visible = !document.hidden, now = Date.now(); if (now - (visible ? lastVisible : lastHidden) < ACTIVITY_INTERVAL_MS) return; if (visible) lastVisible = now; else lastHidden = now; void transport.activity(visible).catch(() => {}); } const heartbeat = setInterval(() => { if (!document.hidden) activity(); }, ACTIVITY_INTERVAL_MS); const retry = setInterval(() => { void flush(); }, RETRY_INTERVAL_MS); document.addEventListener('visibilitychange', activity); return { ready() { enabled = true; activity(); if (!pageSent) { pageSent = true; const ua = navigator.userAgent; const browser = /Edg\/(\d+)/.exec(ua) ?? /Firefox\/(\d+)/.exec(ua) ?? /Chrome\/(\d+)/.exec(ua) ?? /Version\/(\d+).*Safari/.exec(ua); const name = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Other'; const params = new URLSearchParams(location.search); event('page_view', { screen: 'home', metadata: { device: /iPad|Tablet/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) ? 'tablet' : /Mobi|Android|iPhone/.test(ua) ? 'mobile' : 'pc', browser: `${name}${browser ? ` ${browser[1]}` : ''}`, referrer: document.referrer, source: params.get('utm_source'), medium: params.get('utm_medium'), campaign: params.get('utm_campaign'), invitation: params.has('room'), } }); } }, screen(screen: string) { event('screen_view', { screen }); }, click(action: string) { const flowId = crypto.randomUUID(); event('ui_action', { action, flowId }); return flowId; }, error(code: string, screen: string) { event('client_error', { code, screen }); }, gameStarted(matchId: string, mode: string) { event('game_started', { matchId, mode }); }, gameFinished(matchId: string, mode: string, outcome: string, reason: string) { event('game_finished', { matchId, mode, outcome, reason }); }, destroy() { destroyed = true; clearInterval(heartbeat); clearInterval(retry); document.removeEventListener('visibilitychange', activity); queue.clear(); }, }; }