Files
ssessesse-ki-battle/src/client/analytics.ts
T

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(); },
};
}