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');
+}