mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-14 16:35:01 -05:00
* fix(redaction): consolidate redaction utils, apply them to inputs and outputs before persisting logs * added testing utils
220 lines
6.1 KiB
TypeScript
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(),
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|