Leon4gr45/builder
0
1import { TelemetryEvent, TelemetryEventName, TelemetryEventProperties } from './events';2import {3 TELEMETRY_ENDPOINT,4 TELEMETRY_TOKEN,5 TELEMETRY_ENABLED,6 TELEMETRY_DEBUG,7 FLUSH_INTERVAL_MS,8 MAX_BATCH_SIZE,9 MAX_RETRIES,10 RETRY_BASE_MS,11 HEARTBEAT_INTERVAL_MS,12 detectDeploymentType,13 getAppVersion,14 detectOsPlatform,15 getManagedContext,16} from './config';17import { configManager } from '@/lib/config/storage';18 19const VISITOR_ID_KEY = 'osw-telemetry-vid';20 21function getOrCreateVisitorId(): string {22 try {23 let id = localStorage.getItem(VISITOR_ID_KEY);24 if (!id) {25 id = crypto.randomUUID();26 localStorage.setItem(VISITOR_ID_KEY, id);27 }28 return id;29 } catch {30 return 'unknown';31 }32}33 34export class TelemetryTracker {35 private queue: TelemetryEvent[] = [];36 private flushTimer: ReturnType<typeof setInterval> | null = null;37 private heartbeatTimer: ReturnType<typeof setInterval> | null = null;38 private optedIn = true;39 private initialized = false;40 private sessionStartTime = 0;41 private flushing = false;42 private visitorId = 'unknown';43 private deploymentType = 'browser';44 private osPlatform = 'unknown';45 private appVersion = 'unknown';46 private managedContext: Record<string, string> | null = null;47 48 init(): void {49 try {50 if (typeof window === 'undefined') return;51 if (this.initialized) return;52 if (!TELEMETRY_ENABLED) return;53 54 this.optedIn = configManager.getSettings().telemetryOptIn !== false;55 this.visitorId = getOrCreateVisitorId();56 this.deploymentType = detectDeploymentType();57 this.osPlatform = detectOsPlatform();58 this.appVersion = getAppVersion();59 this.managedContext = getManagedContext();60 this.sessionStartTime = Date.now();61 this.initialized = true;62 63 this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);64 65 this.heartbeatTimer = setInterval(() => {66 if (document.visibilityState === 'visible') {67 this.track('heartbeat', { uptime_ms: Date.now() - this.sessionStartTime });68 }69 }, HEARTBEAT_INTERVAL_MS);70 71 window.addEventListener('beforeunload', this.handleUnload);72 document.addEventListener('visibilitychange', this.handleVisibility);73 74 this.debug('Telemetry initialized', { optedIn: this.optedIn });75 } catch {76 // silently ignore77 }78 }79 80 track(event: TelemetryEventName, properties?: TelemetryEventProperties): void {81 try {82 if (!this.initialized || !this.optedIn || !TELEMETRY_ENABLED) return;83 84 const entry: TelemetryEvent = {85 event,86 timestamp: Date.now(),87 fields: {88 vid: this.visitorId,89 deployment_type: this.deploymentType,90 os_platform: this.osPlatform,91 app_version: this.appVersion,92 ...(this.managedContext ?? {}),93 ...(properties ?? {}),94 },95 };96 97 this.queue.push(entry);98 this.debug('track', entry);99 100 if (this.queue.length >= MAX_BATCH_SIZE) {101 this.flush();102 }103 } catch {104 // silently ignore105 }106 }107 108 setOptIn(value: boolean): void {109 try {110 if (!value && this.optedIn) {111 this.track('telemetry_disabled');112 if (this.flushing) {113 this.beaconFlush();114 } else {115 this.flush();116 }117 }118 this.optedIn = value;119 configManager.setSetting('telemetryOptIn', value);120 if (!value) {121 this.queue = [];122 try { localStorage.removeItem(VISITOR_ID_KEY); } catch {}123 this.visitorId = 'unknown';124 } else {125 this.visitorId = getOrCreateVisitorId();126 }127 } catch {128 // silently ignore129 }130 }131 132 async flush(): Promise<void> {133 try {134 if (this.queue.length === 0 || this.flushing) return;135 this.flushing = true;136 137 const batch = this.queue.splice(0);138 let attempt = 0;139 let success = false;140 141 while (attempt < MAX_RETRIES && !success) {142 try {143 const headers: Record<string, string> = { 'Content-Type': 'application/json' };144 if (TELEMETRY_TOKEN) {145 headers['Authorization'] = `Bearer ${TELEMETRY_TOKEN}`;146 }147 const res = await fetch(TELEMETRY_ENDPOINT, {148 method: 'POST',149 headers,150 body: JSON.stringify({ events: batch }),151 credentials: 'omit',152 });153 if (res.ok) {154 success = true;155 this.debug(`Flushed ${batch.length} events`);156 } else {157 attempt++;158 if (attempt < MAX_RETRIES) {159 await this.sleep(RETRY_BASE_MS * Math.pow(2, attempt - 1));160 }161 }162 } catch {163 attempt++;164 if (attempt < MAX_RETRIES) {165 await this.sleep(RETRY_BASE_MS * Math.pow(2, attempt - 1));166 }167 }168 }169 170 if (!success) {171 this.debug(`Dropped ${batch.length} events after ${MAX_RETRIES} retries`);172 }173 } catch {174 // silently ignore175 } finally {176 this.flushing = false;177 }178 }179 180 private handleUnload = () => {181 this.beaconFlush();182 };183 184 private handleVisibility = () => {185 if (document.visibilityState === 'hidden') {186 this.beaconFlush();187 }188 };189 190 private beaconFlush(): void {191 if (this.queue.length === 0) return;192 try {193 const body: Record<string, unknown> = { events: this.queue.splice(0) };194 if (TELEMETRY_TOKEN) {195 body.token = TELEMETRY_TOKEN;196 }197 const json = JSON.stringify(body);198 // Use fetch with keepalive instead of sendBeacon to avoid CORS199 // issues (sendBeacon sends with credentials: 'include' by default,200 // which is incompatible with Access-Control-Allow-Origin: *)201 fetch(TELEMETRY_ENDPOINT, {202 method: 'POST',203 headers: { 'Content-Type': 'application/json' },204 body: json,205 keepalive: true,206 }).catch(() => {});207 } catch {208 // silently ignore209 }210 }211 212 private sleep(ms: number): Promise<void> {213 return new Promise((resolve) => setTimeout(resolve, ms));214 }215 216 private debug(...args: unknown[]): void {217 if (TELEMETRY_DEBUG) {218 // eslint-disable-next-line no-console219 console.debug('[telemetry]', ...args);220 }221 }222}223 