63 lines
3.8 KiB
TypeScript
63 lines
3.8 KiB
TypeScript
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<string, unknown>[]): Promise<unknown>; activity(visible: boolean): Promise<unknown> }
|
|
export function createClientAnalytics(transport: AnalyticsTransport) {
|
|
const queue = new Map<string, { data: Record<string, unknown>; at: number; attempts: number }>();
|
|
let sending = false, pageSent = false, enabled = false, destroyed = false, lastVisible = -Infinity, lastHidden = -Infinity;
|
|
function event(eventType: string, fields: Record<string, unknown> = {}) {
|
|
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(); },
|
|
};
|
|
}
|