import { kbxApi } from '../http/generated/kbxApiClient' import { kbxTelemetryCatalog, type KbxApiOperationId, type KbxTelemetryEventName, type KbxUxEvent } from '@kbx/contracts' const forbiddenKeyPattern = /(phone|address|name|orderNo|customer|itemCode|barcode|keyword|query|email|businessNumber|entityId)/i const MAX_BUFFER = 50 const FLUSH_INTERVAL_MS = 5000 function sessionId() { const key='kbx.telemetry.session-id' const current=sessionStorage.getItem(key) if(current) return current const value=crypto.randomUUID();sessionStorage.setItem(key,value);return value } function sanitizeAttributes(eventName:KbxTelemetryEventName, attributes:Record|undefined){ if(!attributes) return undefined const allowed=new Set(kbxTelemetryCatalog[eventName].allowedAttributes as readonly string[]) const result:Record={} for(const [key,value] of Object.entries(attributes)){ if(!allowed.has(key)) throw new Error(`Telemetry attribute '${key}' is not allowed for ${eventName}.`) if(forbiddenKeyPattern.test(key)) throw new Error(`Telemetry attribute '${key}' may contain business/sensitive data.`) const normalized=String(value).slice(0,80) if(normalized.length) result[key]=normalized } return Object.keys(result).length?result:undefined } class KbxTelemetryClient { private buffer:KbxUxEvent[]=[] private timer:number|undefined private experimentByScreen=new Map() bindExperiment(screenId:string,experimentId:string,experimentVariant:string){this.experimentByScreen.set(screenId,{experimentId,experimentVariant})} clearExperiment(screenId:string){this.experimentByScreen.delete(screenId)} start(){if(typeof window==='undefined'||this.timer)return;this.timer=window.setInterval(()=>void this.flush(),FLUSH_INTERVAL_MS)} stop(){if(this.timer){window.clearInterval(this.timer);this.timer=undefined}void this.flush()} track(eventName:KbxTelemetryEventName,input:Omit={}){ try { const def=kbxTelemetryCatalog[eventName] if(def.requiresDuration && input.durationMs==null) return false const experiment=input.screenId?this.experimentByScreen.get(input.screenId):undefined this.buffer.push({eventName,sessionId:sessionId(),occurredAt:new Date().toISOString(),...experiment,...input,attributes:sanitizeAttributes(eventName,input.attributes)}) if(this.buffer.length>=MAX_BUFFER) void this.flush() return true } catch { // Telemetry is never allowed to fail or block a business command. return false } } async flush(){ if(!this.buffer.length)return const events=this.buffer.splice(0,MAX_BUFFER) try{await kbxApi.request('common.uxTelemetry.ingest' as KbxApiOperationId,{body:{events}})} catch{this.buffer.unshift(...events);if(this.buffer.length>MAX_BUFFER*4)this.buffer.splice(0,this.buffer.length-MAX_BUFFER*4)} } } export const kbxTelemetry=new KbxTelemetryClient() export function startKbxTask(screenId:string,screenVersion:string,taskType:string){ const id=crypto.randomUUID(), started=performance.now() kbxTelemetry.track('task.start',{screenId,screenVersion,taskSessionId:id,attributes:{taskType}}) return { id, complete(result='success'){kbxTelemetry.track('task.complete',{screenId,screenVersion,taskSessionId:id,durationMs:Math.round(performance.now()-started),attributes:{taskType,result}})}, abandon(reasonCode='cancelled'){kbxTelemetry.track('task.abandon',{screenId,screenVersion,taskSessionId:id,durationMs:Math.round(performance.now()-started),attributes:{taskType,reasonCode}})}, interaction(interactionType:string,commandId?:string){kbxTelemetry.track('interaction.execute',{screenId,screenVersion,taskSessionId:id,attributes:{interactionType,...(commandId?{commandId}:{})}})}, } }