Files
sim/apps/sim/instrumentation-client.ts
Waleed 65efa039da fix(redaction): consolidate redaction utils, apply them to inputs and outputs before persisting logs (#2478)
* fix(redaction): consolidate redaction utils, apply them to inputs and outputs before persisting logs

* added testing utils
2025-12-19 13:17:51 -08:00

220 lines
6.1 KiB
TypeScript

/**
* Sim Telemetry - Client-side Instrumentation
*/
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) {
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}).catch(() => {
// Silently fail
})
}
} else {
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 = Math.random() < 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(),
})
}
})
}
}