/** * Browser/React Native-safe telemetry client for platform-service. * * Replaces hand-rolled telemetry clients in ChronoMind web, NomGap, and LysnrAI user-dashboard. * No Node.js dependencies — uses globalThis.fetch and configurable storage. * * @example * ```ts * import { createTelemetryClient } from '@bytelyst/telemetry-client'; * * const telemetry = createTelemetryClient({ * productId: 'chronomind', * baseUrl: 'http://localhost:4003/api', * platform: 'web', * channel: 'pwa', * transport: 'beacon', * }); * * telemetry.init(); * telemetry.trackEvent('info', 'timer', 'timer_created'); * ``` */ import type { TelemetryClient, TelemetryClientConfig, TelemetryEvent, TelemetryStorage, } from './types.js'; // ── UUID helper (browser + RN safe) ────────────────────────────── function uuid(): string { if (typeof globalThis.crypto?.randomUUID === 'function') { return globalThis.crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } // ── Noop storage ───────────────────────────────────────────────── const noopStorage: TelemetryStorage = { getItem: () => null, setItem: () => {}, }; function getDefaultStorage(): TelemetryStorage { if ( typeof globalThis.localStorage !== 'undefined' && typeof globalThis.localStorage?.getItem === 'function' ) { return globalThis.localStorage; } return noopStorage; } // ── Factory ────────────────────────────────────────────────────── export function createTelemetryClient(config: TelemetryClientConfig): TelemetryClient { const { productId, baseUrl, endpoint = '/telemetry/events', platform, channel, transport = 'fetch', maxQueue = 50, flushIntervalMs = 30_000, appVersion = '0.0.0', buildNumber = '0', releaseChannel = 'dev', osFamily = 'other', osVersion = '', } = config; const storage = config.storage ?? getDefaultStorage(); const INSTALL_KEY = `${productId}_telemetry_install_id`; let queue: TelemetryEvent[] = []; let sessionId = ''; let installId = ''; let flushTimer: ReturnType | null = null; function getInstallId(): string { if (installId) return installId; const stored = storage.getItem(INSTALL_KEY); if (stored) { installId = stored; return installId; } installId = uuid(); storage.setItem(INSTALL_KEY, installId); return installId; } function getSessionId(): string { return sessionId; } function flushViaBeacon(): void { if (queue.length === 0) return; const events = [...queue]; queue = []; const body = JSON.stringify({ productId, events }); const url = `${baseUrl}${endpoint}`; try { const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url, body); if (!sent) { // Fallback to fetch globalThis .fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-product-id': productId, 'x-request-id': uuid(), }, body, keepalive: true, }) .catch(() => {}); } } catch { // Silently ignore telemetry failures } } function flushViaFetch(): void { if (queue.length === 0) return; const events = [...queue]; queue = []; const body = JSON.stringify({ productId, events }); const url = `${baseUrl}${endpoint}`; globalThis .fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-product-id': productId, 'x-request-id': uuid(), }, body, }) .catch(() => {}); } function flush(): void { if (transport === 'beacon') { flushViaBeacon(); } else { flushViaFetch(); } } function trackEvent( eventType: string, module: string, eventName: string, extra?: { feature?: string; message?: string; tags?: Record; metrics?: Record; userId?: string; } ): void { const event: TelemetryEvent = { id: uuid(), productId, anonymousInstallId: getInstallId(), sessionId, platform, channel, osFamily, osVersion, appVersion, buildNumber, releaseChannel, eventType, module, eventName, ...extra, occurredAt: new Date().toISOString(), }; queue.push(event); if (queue.length >= maxQueue) { flush(); } } function init(): void { sessionId = uuid(); getInstallId(); // Auto-flush on visibility change (web only) if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { flush(); } }); } // Periodic flush if (flushTimer) clearInterval(flushTimer); flushTimer = setInterval(flush, flushIntervalMs); trackEvent('info', 'app_lifecycle', 'session_started'); } function shutdown(): void { flush(); if (flushTimer) { clearInterval(flushTimer); flushTimer = null; } } return { init, trackEvent, flush, shutdown, getInstallId, getSessionId, }; }