/** * Sim Telemetry - Client-side Instrumentation */ import { randomFloat } from '@sim/utils/random' import { env } from './lib/core/config/env' import { sanitizeEventData } from './lib/core/security/redaction' if (typeof window !== 'undefined') { const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status' const BATCH_INTERVAL_MS = 10000 // Send batches every 10 seconds const MAX_BATCH_SIZE = 50 // Max events per batch let telemetryEnabled = true const eventBatch: any[] = [] let batchTimer: NodeJS.Timeout | null = null try { if (env.NEXT_TELEMETRY_DISABLED === '1') { telemetryEnabled = false } else { const storedPreference = localStorage.getItem(TELEMETRY_STATUS_KEY) if (storedPreference) { const status = JSON.parse(storedPreference) telemetryEnabled = status.enabled } } } catch (_e) { telemetryEnabled = false } /** * Add event to batch and schedule flush */ function addToBatch(event: any): void { if (!telemetryEnabled) return eventBatch.push(event) if (eventBatch.length >= MAX_BATCH_SIZE) { flushBatch() } else if (!batchTimer) { batchTimer = setTimeout(flushBatch, BATCH_INTERVAL_MS) } } /** * Flush batch of events to server */ function flushBatch(): void { if (eventBatch.length === 0) return const batch = eventBatch.splice(0, eventBatch.length) if (batchTimer) { clearTimeout(batchTimer) batchTimer = null } const sanitizedBatch = batch.map(sanitizeEventData) const payload = JSON.stringify({ category: 'batch', action: 'client_events', events: sanitizedBatch, timestamp: Date.now(), }) const payloadSize = new Blob([payload]).size const MAX_BEACON_SIZE = 64 * 1024 // 64KB if (navigator.sendBeacon && payloadSize < MAX_BEACON_SIZE) { const sent = navigator.sendBeacon('/api/telemetry', payload) if (!sent) { // boundary-raw-fetch: pre-bootstrap instrumentation fetch('/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, keepalive: true, }).catch(() => { // Silently fail }) } } else { // boundary-raw-fetch: pre-bootstrap instrumentation fetch('/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, keepalive: true, }).catch(() => { // Silently fail }) } } window.addEventListener('beforeunload', flushBatch) window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { flushBatch() } }) /** * Global event tracking function */ ;(window as any).__SIM_TELEMETRY_ENABLED = telemetryEnabled ;(window as any).__SIM_TRACK_EVENT = (eventName: string, properties?: any) => { if (!telemetryEnabled) return addToBatch({ category: 'feature_usage', action: eventName, timestamp: Date.now(), ...(properties || {}), }) } if (telemetryEnabled) { const shouldTrackVitals = randomFloat() < 0.1 if (shouldTrackVitals) { window.addEventListener( 'load', () => { if (typeof PerformanceObserver !== 'undefined') { const lcpObserver = new PerformanceObserver((list) => { const entries = list.getEntries() const lastEntry = entries[entries.length - 1] if (lastEntry) { addToBatch({ category: 'performance', action: 'web_vital', label: 'LCP', value: (lastEntry as any).startTime || 0, entryType: 'largest-contentful-paint', timestamp: Date.now(), }) } lcpObserver.disconnect() }) let clsValue = 0 const clsObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!(entry as any).hadRecentInput) { clsValue += (entry as any).value || 0 } } }) const fidObserver = new PerformanceObserver((list) => { const entries = list.getEntries() for (const entry of entries) { const fidValue = ((entry as any).processingStart || 0) - ((entry as any).startTime || 0) addToBatch({ category: 'performance', action: 'web_vital', label: 'FID', value: fidValue, entryType: 'first-input', timestamp: Date.now(), }) } fidObserver.disconnect() }) window.addEventListener('beforeunload', () => { if (clsValue > 0) { addToBatch({ category: 'performance', action: 'web_vital', label: 'CLS', value: clsValue, entryType: 'layout-shift', timestamp: Date.now(), }) } clsObserver.disconnect() }) lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }) clsObserver.observe({ type: 'layout-shift', buffered: true }) fidObserver.observe({ type: 'first-input', buffered: true }) } }, { once: true } ) } window.addEventListener('error', (event) => { if (telemetryEnabled && !event.defaultPrevented) { addToBatch({ category: 'error', action: 'unhandled_error', message: event.error?.message || event.message || 'Unknown error', url: window.location.pathname, timestamp: Date.now(), }) } }) window.addEventListener('unhandledrejection', (event) => { if (telemetryEnabled) { addToBatch({ category: 'error', action: 'unhandled_rejection', message: event.reason?.message || String(event.reason) || 'Unhandled promise rejection', url: window.location.pathname, timestamp: Date.now(), }) } }) } }