72 lines
3.8 KiB
TypeScript
72 lines
3.8 KiB
TypeScript
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<string,string>|undefined){
|
|
if(!attributes) return undefined
|
|
const allowed=new Set<string>(kbxTelemetryCatalog[eventName].allowedAttributes as readonly string[])
|
|
const result:Record<string,string>={}
|
|
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<string,{experimentId:string;experimentVariant:string}>()
|
|
|
|
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<KbxUxEvent,'eventName'|'sessionId'|'occurredAt'>={}){
|
|
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}:{})}})},
|
|
}
|
|
}
|