V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,71 @@
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}:{})}})},
}
}