From 1713ce058bad1d02389a8c731635abfa2f4427e3 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 02:04:08 -0800 Subject: [PATCH] feat(web): add platform-service telemetry client --- web/src/app/providers.tsx | 6 ++ web/src/lib/telemetry.ts | 155 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 web/src/lib/telemetry.ts diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx index 24cd699..e99b21e 100644 --- a/web/src/app/providers.tsx +++ b/web/src/app/providers.tsx @@ -1,8 +1,14 @@ 'use client'; +import { useEffect } from 'react'; import { AuthProvider } from '@/lib/auth-context'; +import { initTelemetry } from '@/lib/telemetry'; import type { ReactNode } from 'react'; export function Providers({ children }: { children: ReactNode }) { + useEffect(() => { + initTelemetry(); + }, []); + return {children}; } diff --git a/web/src/lib/telemetry.ts b/web/src/lib/telemetry.ts new file mode 100644 index 0000000..0302b4f --- /dev/null +++ b/web/src/lib/telemetry.ts @@ -0,0 +1,155 @@ +// ── Platform Telemetry Client ───────────────────────────────── +// Sends lightweight events to platform-service telemetry endpoint. +// Runs in the browser only. Privacy: no PII, only action names + timing. + +import { PRODUCT_ID } from './platform-sync'; + +const PLATFORM = 'web'; +const OS_FAMILY = 'other'; +const CHANNEL = 'pwa'; +const MAX_QUEUE = 50; +const FLUSH_INTERVAL_MS = 30_000; + +interface TelemetryEvent { + id: string; + productId: string; + anonymousInstallId: string; + sessionId: string; + platform: string; + channel: string; + osFamily: string; + osVersion: string; + appVersion: string; + buildNumber: string; + releaseChannel: string; + eventType: string; + module: string; + eventName: string; + feature?: string; + message?: string; + tags?: Record; + metrics?: Record; + occurredAt: string; +} + +let queue: TelemetryEvent[] = []; +let sessionId = ''; +let installId = ''; +let flushTimer: ReturnType | null = null; +let baseUrl = ''; + +function uuid(): string { + if (typeof crypto?.randomUUID === 'function') return 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); + }); +} + +function getInstallId(): string { + if (installId) return installId; + try { + const stored = localStorage.getItem('chronomind_telemetry_install_id'); + if (stored) { + installId = stored; + return installId; + } + installId = uuid(); + localStorage.setItem('chronomind_telemetry_install_id', installId); + } catch { + installId = uuid(); + } + return installId; +} + +export function trackEvent( + eventType: 'debug' | 'info' | 'warn' | 'error', + module: string, + eventName: string, + options?: { + feature?: string; + message?: string; + tags?: Record; + metrics?: Record; + } +): void { + if (typeof window === 'undefined') return; + + const event: TelemetryEvent = { + id: uuid(), + productId: PRODUCT_ID, + anonymousInstallId: getInstallId(), + sessionId, + platform: PLATFORM, + channel: CHANNEL, + osFamily: OS_FAMILY, + osVersion: navigator.userAgent.substring(0, 128), + appVersion: '0.1.0', + buildNumber: '1', + releaseChannel: 'beta', + eventType, + module, + eventName, + occurredAt: new Date().toISOString(), + ...options, + }; + + queue.push(event); + if (queue.length > MAX_QUEUE) { + queue = queue.slice(-MAX_QUEUE); + } + + if (queue.length >= 10) { + flush(); + } +} + +export function trackTimerEvent(eventName: string, tags?: Record, metrics?: Record): void { + trackEvent('info', 'timers', eventName, { tags, metrics }); +} + +export function trackPageView(path: string): void { + trackEvent('info', 'navigation', 'page_view', { tags: { path } }); +} + +export function flush(): void { + if (typeof window === 'undefined' || queue.length === 0 || !baseUrl) return; + + const events = [...queue]; + queue = []; + + const body = JSON.stringify({ productId: PRODUCT_ID, events }); + const url = `${baseUrl}/telemetry/events`; + + try { + const sent = navigator.sendBeacon(url, body); + if (!sent) { + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => {}); + } + } catch { + // Best effort — telemetry is non-critical + } +} + +export function initTelemetry(): void { + if (typeof window === 'undefined') return; + + baseUrl = process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app'; + sessionId = uuid(); + + window.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flush(); + } + }); + + if (flushTimer) clearInterval(flushTimer); + flushTimer = setInterval(flush, FLUSH_INTERVAL_MS); + + trackEvent('info', 'app_lifecycle', 'session_started'); +}