Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/utils/workbench-performance.ts
T

949 lines
40 KiB
TypeScript

// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
// Browser-side Workbench journey probe. Prometheus label shaping is enforced again by cloud-api.
import type { ChatMessage, TraceEvent } from "@/types";
type WorkbenchEventKind = "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible" | "workbench_ui_event";
type WorkbenchOutcome = "ok" | "timeout" | "error" | "dropped" | "stale" | "partial" | "empty" | "network" | "denied" | "unknown";
type WorkbenchLoadingScope = "workbench" | "session_list" | "session_detail" | "api" | "sse" | "page";
type WorkbenchUiState = "enter" | "exit" | "request" | "connect" | "open" | "message" | "close" | "error" | "sample";
type WorkbenchUiReason = "hydrate" | "select_session" | "create_session" | "api_request" | "sse_connect" | "sse_open" | "sse_error" | "sse_close" | "sse_flush" | "pagehide" | "visibility_hidden" | "unknown";
interface WorkbenchPerformanceEvent {
kind: WorkbenchEventKind;
valueMs: number;
route?: string;
journey?: string;
phase?: string;
eventType?: string;
backend?: string;
transport?: string;
entry?: string;
source?: string;
targetState?: string;
cache?: string;
authState?: string;
visibility?: string;
outcome?: WorkbenchOutcome;
eventName?: string;
uiTraceId?: string;
otelTraceId?: string;
spanId?: string;
parentSpanId?: string;
loadingScope?: WorkbenchLoadingScope;
state?: WorkbenchUiState;
reason?: WorkbenchUiReason;
sessionHash?: string;
traceHash?: string;
method?: string;
status?: number;
statusClass?: string;
startedAtEpochMs?: number;
endedAtEpochMs?: number;
errorName?: string;
timeoutName?: string;
activityWaitingFor?: string | null;
activityLastEventLabel?: string | null;
activityIdleMs?: number;
resourceDurationMs?: number;
resourceFetchToRequestMs?: number;
resourceRequestWaitMs?: number;
resourceResponseTransferMs?: number;
resourceTransferSize?: number;
resourceEncodedBodySize?: number;
resourceDecodedBodySize?: number;
resourceNextHopProtocol?: string;
resourceServerTiming?: string;
module?: string;
diagnosticCode?: string;
rootCause?: string;
recoveryAction?: string;
contractVersion?: string;
realtimeAuthority?: string;
sinceOutboxSeq?: number;
syncCursorOutboxSeq?: number;
entityFamilyCount?: number;
entityFamilies?: string;
maxEntityVersion?: number;
projectionRevision?: string;
terminalSeal?: boolean;
detailProjection?: boolean;
transportState?: string;
scopedKey?: string;
eventCount?: number;
deliveredCount?: number;
droppedCount?: number;
chunkCount?: number;
replacedByKey?: number;
flushDurationMs?: number;
maxItemsPerChunk?: number;
maxChunkMs?: number;
}
interface TraceEventTimingInput {
traceId: string | null | undefined;
events: TraceEvent[];
transport: "sse" | "sync_replay" | "detail_history" | "poll";
serverSentAt?: string | null | undefined;
eventCreatedAt?: string | null | undefined;
traceSeq?: number | string | null | undefined;
}
interface OpenJourneyState {
startAt: number;
route: string;
cache: string;
authState: string;
firstVisibleReported: boolean;
fullLoadReported: boolean;
}
interface SessionSwitchState {
sessionId: string;
startAt: number;
route: string;
source: string;
targetState: string;
cache: string;
firstVisibleReported: boolean;
fullLoadReported: boolean;
}
interface SubmitJourneyState {
traceId: string;
sessionId: string;
startAt: number;
route: string;
entry: string;
backend: string;
transport: string;
firstVisibleReported: boolean;
apiAcceptedReported: boolean;
}
interface TraceEventState {
traceId: string;
receivedAt: number;
projectedAt: number | null;
eventCreatedAt: number | null;
appendedAt: number | null;
serverSentAt: number | null;
traceSeq: number | null;
eventType: string;
backend: string;
transport: string;
visibleOutcome: WorkbenchOutcome;
visibleReported: boolean;
}
interface WorkbenchUiTrace {
uiTraceId: string;
otelTraceId: string;
traceparent: string;
startedAtEpochMs: number;
valuesPrinted: false;
}
interface LoadingStateRecord {
active: true;
startAt: number;
startEpochMs: number;
reason: WorkbenchUiReason;
route: string;
sessionHash?: string;
traceHash?: string;
}
const SCHEMA_VERSION = "hwlab-web-performance-v2";
const FLUSH_INTERVAL_MS = 10_000;
const PRIORITY_FLUSH_DELAY_MS = 250;
const FLUSH_BATCH_THRESHOLD = 120;
const MAX_QUEUE = 300;
const submitJourneys = new Map<string, SubmitJourneyState>();
const sessionSwitches = new Map<string, SessionSwitchState>();
const traceEvents = new Map<string, TraceEventState>();
const loadingStates = new Map<WorkbenchLoadingScope, LoadingStateRecord>();
let openJourney: OpenJourneyState | null = null;
let queue: WorkbenchPerformanceEvent[] = [];
let installed = false;
let resourceTimingBufferConfigured = false;
let flushTimer: number | null = null;
let priorityFlushTimer: number | null = null;
let uiTrace: WorkbenchUiTrace | null = null;
export function currentWorkbenchUiTrace(): WorkbenchUiTrace | null {
if (typeof window === "undefined") return null;
if (!uiTrace) {
const otelTraceId = randomHex(16);
const parentSpanId = randomHex(8);
uiTrace = {
uiTraceId: `ui_${Date.now().toString(36)}_${randomHex(6)}`,
otelTraceId,
traceparent: `00-${otelTraceId}-${parentSpanId}-01`,
startedAtEpochMs: Date.now(),
valuesPrinted: false
};
}
publishWorkbenchUiTrace(uiTrace);
return uiTrace;
}
export function recordWorkbenchLoadingState(input: { scope: WorkbenchLoadingScope; active: boolean; reason?: WorkbenchUiReason; sessionId?: string | null; traceId?: string | null }): void {
ensureInstalled();
const reason = input.reason ?? "unknown";
const now = monotonicNow();
const nowEpoch = wallNow();
const sessionHash = hashIdentifier(input.sessionId, "ses");
const traceHash = hashIdentifier(input.traceId, "trc");
const previous = loadingStates.get(input.scope);
if (input.active) {
if (previous?.active) return;
const state: LoadingStateRecord = { active: true, startAt: now, startEpochMs: nowEpoch, reason, route: pageRoute(), sessionHash, traceHash };
loadingStates.set(input.scope, state);
enqueueWorkbenchUiEvent({ eventType: "loading_state", loadingScope: input.scope, state: "enter", reason, route: state.route, valueMs: 0, startedAtEpochMs: nowEpoch, endedAtEpochMs: nowEpoch, sessionHash, traceHash, outcome: "ok" });
return;
}
if (!previous?.active) return;
loadingStates.delete(input.scope);
const valueMs = Math.max(0, now - previous.startAt);
enqueueWorkbenchUiEvent({ eventType: "loading_state", loadingScope: input.scope, state: "exit", reason: previous.reason, route: previous.route, valueMs, startedAtEpochMs: previous.startEpochMs, endedAtEpochMs: nowEpoch, sessionHash: previous.sessionHash, traceHash: previous.traceHash, outcome: "ok" });
}
export function recordWorkbenchApiRequest(input: { route: string; method?: string; status?: number; startedAtEpochMs: number; endedAtEpochMs?: number; outcome?: string | null; timeoutName?: string | null; errorName?: string | null; activityWaitingFor?: string | null; activityLastEventLabel?: string | null; activityIdleMs?: number | null }): void {
if (!isWorkbenchPage()) return;
const route = safeText(input.route);
if (!route || route.startsWith("/v1/web-performance")) return;
ensureInstalled();
const endedAtEpochMs = input.endedAtEpochMs ?? wallNow();
const resourceTiming = resourceTimingForApiRequest(route, input.startedAtEpochMs, endedAtEpochMs);
const routeLabel = routeTemplate(route);
enqueueWorkbenchUiEvent({
eventType: "api_request",
loadingScope: "api",
state: "request",
reason: "api_request",
route: routeLabel,
method: input.method ?? "GET",
status: input.status,
statusClass: statusClass(input.status),
outcome: normalizeUiOutcome(input.outcome),
timeoutName: input.timeoutName ? safeText(input.timeoutName).slice(0, 80) : undefined,
errorName: input.errorName ? safeText(input.errorName).slice(0, 80) : undefined,
activityWaitingFor: input.activityWaitingFor ? safeText(input.activityWaitingFor).slice(0, 80) : undefined,
activityLastEventLabel: input.activityLastEventLabel ? safeText(input.activityLastEventLabel).slice(0, 120) : undefined,
activityIdleMs: Number.isFinite(input.activityIdleMs ?? NaN) ? Math.max(0, Math.round(Number(input.activityIdleMs))) : undefined,
valueMs: Math.max(0, endedAtEpochMs - input.startedAtEpochMs),
startedAtEpochMs: input.startedAtEpochMs,
endedAtEpochMs,
...resourceTiming
});
}
function resourceTimingForApiRequest(route: string, startedAtEpochMs: number, endedAtEpochMs: number): Partial<WorkbenchPerformanceEvent> {
if (typeof window === "undefined" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") return {};
let target: URL;
try {
target = new URL(route, window.location.href);
} catch {
return {};
}
const timeOrigin = Number.isFinite(performance.timeOrigin) ? performance.timeOrigin : Date.now() - monotonicNow();
const entries = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
const lowerBound = startedAtEpochMs - 2000;
const upperBound = endedAtEpochMs + 2000;
const entry = bestResourceTimingEntry(entries, target, timeOrigin, lowerBound, upperBound, startedAtEpochMs);
clearCollectedResourceTimings(entries.length);
if (!entry) return {};
const result: Partial<WorkbenchPerformanceEvent> = {
resourceDurationMs: roundedNonNegative(entry.duration),
resourceFetchToRequestMs: roundedDelta(entry.requestStart, entry.fetchStart),
resourceRequestWaitMs: roundedDelta(entry.responseStart, entry.requestStart),
resourceResponseTransferMs: roundedDelta(entry.responseEnd, entry.responseStart),
resourceTransferSize: roundedNonNegative(entry.transferSize),
resourceEncodedBodySize: roundedNonNegative(entry.encodedBodySize),
resourceDecodedBodySize: roundedNonNegative(entry.decodedBodySize),
resourceNextHopProtocol: safeText(entry.nextHopProtocol).slice(0, 40) || undefined,
resourceServerTiming: serverTimingSummary(entry.serverTiming)
};
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined)) as Partial<WorkbenchPerformanceEvent>;
}
interface ResourceTimingCandidate {
entry: PerformanceResourceTiming;
queryExact: boolean;
initiatorRank: number;
startDistanceMs: number;
}
function bestResourceTimingEntry(entries: PerformanceResourceTiming[], target: URL, timeOrigin: number, lowerBound: number, upperBound: number, startedAtEpochMs: number): PerformanceResourceTiming | undefined {
let best: ResourceTimingCandidate | null = null;
for (const entry of entries) {
const candidate = resourceTimingCandidate(entry, target, timeOrigin, lowerBound, upperBound, startedAtEpochMs);
if (!candidate) continue;
if (!best || resourceTimingCandidateRank(candidate, best) < 0) best = candidate;
}
return best?.entry;
}
function resourceTimingCandidateRank(left: ResourceTimingCandidate, right: ResourceTimingCandidate): number {
return Number(right.queryExact) - Number(left.queryExact)
|| left.initiatorRank - right.initiatorRank
|| left.startDistanceMs - right.startDistanceMs;
}
function resourceTimingCandidate(entry: PerformanceResourceTiming, target: URL, timeOrigin: number, lowerBound: number, upperBound: number, startedAtEpochMs: number): ResourceTimingCandidate | null {
if (!entry) return null;
let url: URL;
try {
url = new URL(entry.name);
} catch {
return null;
}
if (url.origin !== target.origin) return null;
if (url.pathname !== target.pathname) return null;
const started = timeOrigin + entry.startTime;
if (started < lowerBound || started > upperBound) return null;
return {
entry,
queryExact: url.search === target.search,
initiatorRank: resourceInitiatorRank(entry.initiatorType),
startDistanceMs: Math.abs(started - startedAtEpochMs)
};
}
function clearCollectedResourceTimings(entryCount: number): void {
if (entryCount <= 0 || typeof performance === "undefined" || typeof performance.clearResourceTimings !== "function") return;
performance.clearResourceTimings();
}
function resourceInitiatorRank(value: string): number {
const initiator = safeText(value).toLowerCase();
if (initiator === "fetch") return 0;
if (initiator === "xmlhttprequest") return 1;
if (!initiator) return 2;
return 3;
}
function roundedDelta(end: number, start: number): number | undefined {
if (!Number.isFinite(end) || !Number.isFinite(start) || end < start) return undefined;
return Math.round(end - start);
}
function roundedNonNegative(value: number): number | undefined {
if (!Number.isFinite(value) || value < 0) return undefined;
return Math.round(value);
}
function serverTimingSummary(entries: readonly PerformanceServerTiming[] | undefined): string | undefined {
if (!Array.isArray(entries) || entries.length === 0) return undefined;
const summary = entries.slice(0, 8).map((entry) => {
const name = safeText(entry.name).replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 40) || "metric";
const duration = Number.isFinite(entry.duration) ? Math.round(entry.duration) : null;
return duration === null ? name : `${name}:${duration}`;
}).join(",");
return summary.slice(0, 240) || undefined;
}
export function recordWorkbenchSseLifecycle(input: { state: "connect" | "open" | "error" | "close"; route: string; sessionId?: string | null; traceId?: string | null; errorName?: string | null; valueMs?: number | null }): void {
ensureInstalled();
const state = input.state;
const endedAt = wallNow();
const valueMs = roundedNonNegative(Number(input.valueMs ?? 0)) ?? 0;
enqueueWorkbenchUiEvent({
eventType: "sse_lifecycle",
loadingScope: "sse",
state,
reason: state === "connect" ? "sse_connect" : state === "open" ? "sse_open" : state === "close" ? "sse_close" : "sse_error",
route: input.route,
valueMs,
startedAtEpochMs: Math.max(0, endedAt - valueMs),
endedAtEpochMs: endedAt,
sessionHash: hashIdentifier(input.sessionId, "ses"),
traceHash: hashIdentifier(input.traceId, "trc"),
outcome: state === "error" ? "network" : "ok",
errorName: input.errorName ? safeText(input.errorName).slice(0, 80) : undefined
});
}
export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null; diagnostic?: Record<string, unknown> | null; sessionId?: string | null; traceId?: string | null; outcome?: WorkbenchOutcome | null }): void {
ensureInstalled();
const diagnostic = recordValue(input.diagnostic);
const endedAt = wallNow();
const code = diagnosticText(diagnostic, "code") ?? "runtime_diagnostic";
const flushDurationMs = diagnosticNumber(diagnostic, "flushDurationMs");
const isSseFlush = code === "workbench_sse_flush";
enqueueWorkbenchUiEvent({
eventType: "runtime_diagnostic",
loadingScope: "sse",
state: isSseFlush ? "sample" : "error",
reason: isSseFlush ? "sse_flush" : "sse_error",
route: pageRoute(),
valueMs: flushDurationMs ?? 0,
startedAtEpochMs: Math.max(0, endedAt - (flushDurationMs ?? 0)),
endedAtEpochMs: endedAt,
sessionHash: hashIdentifier(input.sessionId ?? diagnosticText(diagnostic, "sessionId"), "ses"),
traceHash: hashIdentifier(input.traceId ?? diagnosticText(diagnostic, "traceId"), "trc"),
outcome: input.outcome ?? normalizeUiOutcome(diagnosticText(diagnostic, "outcome")),
errorName: code.slice(0, 80),
module: diagnosticLabel(input.module ?? diagnosticText(diagnostic, "module") ?? diagnosticText(diagnostic, "layer")),
diagnosticCode: diagnosticLabel(code),
rootCause: diagnosticText(diagnostic, "rootCause", "reason")?.slice(0, 160),
recoveryAction: diagnosticText(diagnostic, "recoveryAction")?.slice(0, 160),
contractVersion: diagnosticLabel(diagnosticText(diagnostic, "contractVersion")),
realtimeAuthority: diagnosticLabel(diagnosticText(diagnostic, "realtimeAuthority", "authority")),
sinceOutboxSeq: diagnosticNumber(diagnostic, "sinceOutboxSeq"),
syncCursorOutboxSeq: diagnosticNumber(diagnostic, "syncCursorOutboxSeq", "cursorOutboxSeq"),
entityFamilyCount: diagnosticNumber(diagnostic, "entityFamilyCount"),
entityFamilies: diagnosticText(diagnostic, "entityFamilies")?.slice(0, 160),
maxEntityVersion: diagnosticNumber(diagnostic, "maxEntityVersion", "entityVersion"),
projectionRevision: diagnosticText(diagnostic, "projectionRevision")?.slice(0, 120),
terminalSeal: diagnosticBoolean(diagnostic, "terminalSeal"),
detailProjection: diagnosticBoolean(diagnostic, "detailProjection"),
transportState: diagnosticLabel(diagnosticText(diagnostic, "transportState")),
scopedKey: hashIdentifier(diagnosticText(diagnostic, "scopedKey"), "scope"),
eventCount: diagnosticNumber(diagnostic, "eventCount"),
deliveredCount: diagnosticNumber(diagnostic, "deliveredCount"),
droppedCount: diagnosticNumber(diagnostic, "dropped", "droppedCount"),
chunkCount: diagnosticNumber(diagnostic, "chunkCount"),
replacedByKey: diagnosticNumber(diagnostic, "replacedByKey"),
flushDurationMs,
maxItemsPerChunk: diagnosticNumber(diagnostic, "maxItemsPerChunk"),
maxChunkMs: diagnosticNumber(diagnostic, "maxChunkMs")
});
if (isSseFlush) schedulePriorityFlush();
}
export function startWorkbenchOpenJourney(input: { route?: string; cache?: "warm" | "cold" | "unknown"; authState?: "warm" | "login_redirect" | "unknown" } = {}): void {
ensureInstalled();
openJourney = {
startAt: monotonicNow(),
route: routeTemplate(input.route ?? pageRoute()),
cache: input.cache ?? "unknown",
authState: input.authState ?? "unknown",
firstVisibleReported: false,
fullLoadReported: false
};
}
export function finishWorkbenchOpenFullLoad(outcome: WorkbenchOutcome = "ok"): void {
if (!openJourney || openJourney.fullLoadReported) return;
openJourney.fullLoadReported = true;
enqueue({
kind: "workbench_journey",
journey: "workbench_open_full_load",
route: openJourney.route,
cache: openJourney.cache,
authState: openJourney.authState,
visibility: visibilityState(),
outcome,
valueMs: elapsedSince(openJourney.startAt)
});
}
export function startWorkbenchSessionSwitch(input: { sessionId: string | null | undefined; source: "rail" | "deeplink" | "history" | "direct"; targetState?: string | null; cache?: "warm" | "cold" | "unknown" }): void {
const sessionId = safeText(input.sessionId);
if (!sessionId) return;
ensureInstalled();
sessionSwitches.set(sessionId, {
sessionId,
startAt: monotonicNow(),
route: pageRoute(),
source: input.source,
targetState: normalizeTargetState(input.targetState),
cache: input.cache ?? "unknown",
firstVisibleReported: false,
fullLoadReported: false
});
}
export function finishWorkbenchSessionSwitchFullLoad(sessionId: string | null | undefined, outcome: WorkbenchOutcome = "ok"): void {
const state = sessionSwitches.get(safeText(sessionId));
if (!state || state.fullLoadReported) return;
state.fullLoadReported = true;
enqueue({
kind: "workbench_journey",
journey: "session_switch_full_load",
route: state.route,
source: state.source,
targetState: state.targetState,
cache: state.cache,
visibility: visibilityState(),
outcome,
valueMs: elapsedSince(state.startAt)
});
}
export function failWorkbenchSessionSwitch(sessionId: string | null | undefined): void {
finishWorkbenchSessionSwitchFullLoad(sessionId, "error");
}
export function startWorkbenchSubmitJourney(input: { traceId: string; sessionId: string; entry: "new" | "existing" | "steer" | "retry"; backend?: string | null; transport?: string | null }): void {
if (!input.traceId || !input.sessionId) return;
ensureInstalled();
submitJourneys.set(input.traceId, {
traceId: input.traceId,
sessionId: input.sessionId,
startAt: monotonicNow(),
route: pageRoute(),
entry: input.entry,
backend: normalizeBackend(input.backend),
transport: normalizeTransport(input.transport ?? "sse"),
firstVisibleReported: false,
apiAcceptedReported: false
});
}
export function markWorkbenchSubmitApiAccepted(traceId: string | null | undefined): void {
const state = submitJourneys.get(safeText(traceId));
if (!state || state.apiAcceptedReported) return;
state.apiAcceptedReported = true;
enqueue({
kind: "workbench_event_phase",
phase: "user_submit_to_api_accepted",
eventType: "request",
backend: state.backend,
transport: state.transport,
outcome: "ok",
valueMs: elapsedSince(state.startAt)
});
}
export function failWorkbenchSubmitJourney(traceId: string | null | undefined, outcome: WorkbenchOutcome = "error"): void {
const state = submitJourneys.get(safeText(traceId));
if (!state || state.firstVisibleReported) return;
state.firstVisibleReported = true;
enqueue({
kind: "workbench_journey",
journey: "submit_to_failure",
route: state.route,
entry: state.entry,
backend: state.backend,
transport: state.transport,
visibility: visibilityState(),
outcome,
valueMs: elapsedSince(state.startAt)
});
submitJourneys.delete(state.traceId);
}
export function markWorkbenchTraceEventsReceived(input: TraceEventTimingInput): void {
const traceId = safeText(input.traceId);
if (!traceId) return;
const event = input.events.find(isUserVisibleTraceEvent) ?? input.events[0];
if (!event) return;
const eventType = eventTypeFromTraceEvent(event);
const backend = backendFromTraceEvent(event);
const eventCreatedAt = traceEventCreatedAt(event, input.eventCreatedAt);
const appendedAt = timestampMs(event.appendedAt);
const serverSentAt = timestampMs(input.serverSentAt);
const receivedAt = monotonicNow();
const receivedWallAt = wallNow();
const traceSeq = Number(input.traceSeq ?? event.seq ?? event.sourceSeq ?? NaN);
const visibleOutcome: WorkbenchOutcome = input.transport === "sse" && serverSentAt !== null ? "ok" : "stale";
traceEvents.set(traceId, { traceId, receivedAt, projectedAt: null, eventCreatedAt, appendedAt, serverSentAt, traceSeq: Number.isFinite(traceSeq) ? Math.trunc(traceSeq) : null, eventType, backend, transport: input.transport, visibleOutcome, visibleReported: false });
if (eventCreatedAt !== null && appendedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "created_to_append", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, appendedAt - eventCreatedAt) });
if (appendedAt !== null && serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "append_to_sse", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, serverSentAt - appendedAt) });
if (serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, receivedWallAt - serverSentAt) });
}
export function markWorkbenchTraceProjected(traceId: string | null | undefined): void {
const state = traceEvents.get(safeText(traceId));
if (!state || state.projectedAt !== null) return;
const projectedAt = monotonicNow();
state.projectedAt = projectedAt;
enqueue({ kind: "workbench_event_phase", phase: "receive_to_project", eventType: state.eventType, backend: state.backend, transport: state.transport, outcome: "ok", valueMs: Math.max(0, projectedAt - state.receivedAt) });
}
export function acknowledgeWorkbenchVisibleAfterPaint(input: { messages: ChatMessage[]; activeSessionId?: string | null; detailLoading?: boolean }): void {
afterNextPaint(() => acknowledgeWorkbenchVisible(input));
}
export function acknowledgeWorkbenchVisible(input: { messages: ChatMessage[]; activeSessionId?: string | null; detailLoading?: boolean }): void {
const now = monotonicNow();
const route = pageRoute();
if (openJourney && !openJourney.firstVisibleReported) {
openJourney.firstVisibleReported = true;
enqueue({ kind: "workbench_journey", journey: "workbench_open_first_visible", route: openJourney.route || route, cache: openJourney.cache, authState: openJourney.authState, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - openJourney.startAt) });
}
const activeSessionId = safeText(input.activeSessionId);
const session = activeSessionId ? sessionSwitches.get(activeSessionId) : null;
if (session && !session.firstVisibleReported && !input.detailLoading) {
session.firstVisibleReported = true;
enqueue({ kind: "workbench_journey", journey: "session_switch_first_visible", route: session.route, source: session.source, targetState: session.targetState, cache: session.cache, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - session.startAt) });
}
for (const message of input.messages) {
const traceId = safeText(message.traceId ?? message.runnerTrace?.traceId);
if (!traceId || message.role !== "agent") continue;
const submit = submitJourneys.get(traceId);
const outputVisible = isVisibleAgentOutputMessage(message);
if (submit && !submit.firstVisibleReported && outputVisible) {
submit.firstVisibleReported = true;
enqueue({ kind: "workbench_journey", journey: "submit_to_first_visible", route: submit.route, entry: submit.entry, backend: submit.backend, transport: submit.transport, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - submit.startAt) });
submitJourneys.delete(traceId);
}
const event = traceEvents.get(traceId);
if (event && !event.visibleReported && isVisibleTraceEventMessage(message)) {
event.visibleReported = true;
if (event.projectedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "project_to_paint", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: "ok", valueMs: Math.max(0, now - event.projectedAt) });
if (event.eventCreatedAt !== null && event.visibleOutcome === "ok") enqueue({ kind: "workbench_backend_event_visible", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: event.visibleOutcome, valueMs: Math.max(0, wallNow() - event.eventCreatedAt) });
traceEvents.delete(traceId);
}
}
}
export function resetWorkbenchPerformanceForTest(): void {
submitJourneys.clear();
sessionSwitches.clear();
traceEvents.clear();
loadingStates.clear();
openJourney = null;
queue = [];
uiTrace = null;
if (flushTimer !== null && hasBrowserTimerRuntime()) window.clearTimeout(flushTimer);
flushTimer = null;
if (priorityFlushTimer !== null && hasBrowserTimerRuntime()) window.clearTimeout(priorityFlushTimer);
priorityFlushTimer = null;
installed = false;
resourceTimingBufferConfigured = false;
}
export function drainWorkbenchPerformanceEventsForTest(): WorkbenchPerformanceEvent[] {
const events = queue;
queue = [];
return events;
}
function enqueueWorkbenchUiEvent(event: Omit<WorkbenchPerformanceEvent, "kind" | "uiTraceId" | "otelTraceId" | "spanId">): void {
const trace = currentWorkbenchUiTrace();
if (!trace) return;
enqueue({
...event,
kind: "workbench_ui_event",
uiTraceId: trace.uiTraceId,
otelTraceId: trace.otelTraceId,
spanId: randomHex(8),
visibility: visibilityState()
});
}
function enqueue(event: WorkbenchPerformanceEvent): void {
if (!Number.isFinite(event.valueMs) || event.valueMs < 0) return;
queue.push({ ...event, valueMs: Math.round(event.valueMs * 1000) / 1000 });
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
if (queue.length >= FLUSH_BATCH_THRESHOLD) {
flushWorkbenchPerformance(false);
return;
}
scheduleFlush();
}
function scheduleFlush(): void {
if (!hasBrowserTimerRuntime() || flushTimer !== null) return;
flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS);
}
function schedulePriorityFlush(): void {
if (!hasBrowserTimerRuntime() || priorityFlushTimer !== null) return;
priorityFlushTimer = window.setTimeout(() => {
priorityFlushTimer = null;
flushWorkbenchPerformance(false);
}, PRIORITY_FLUSH_DELAY_MS);
}
function flushWorkbenchPerformance(useBeacon: boolean): void {
if (flushTimer !== null && hasBrowserTimerRuntime()) {
window.clearTimeout(flushTimer);
flushTimer = null;
}
if (priorityFlushTimer !== null && hasBrowserTimerRuntime()) {
window.clearTimeout(priorityFlushTimer);
priorityFlushTimer = null;
}
if (!queue.length || typeof window === "undefined") return;
const events = queue.splice(0, queue.length).map(enrichWorkbenchEventForFlush);
const body = JSON.stringify({ schemaVersion: SCHEMA_VERSION, serviceId: "hwlab-cloud-web", page: pageRoute(), events });
if (useBeacon && navigator.sendBeacon) {
navigator.sendBeacon("/v1/web-performance", new Blob([body], { type: "application/json" }));
return;
}
void fetch("/v1/web-performance", { method: "POST", headers: { "content-type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => undefined);
}
function enrichWorkbenchEventForFlush(event: WorkbenchPerformanceEvent): WorkbenchPerformanceEvent {
if (event.kind !== "workbench_ui_event" || event.eventType !== "api_request") return event;
if (event.resourceDurationMs !== undefined) return event;
const route = safeText(event.route);
if (!route || !Number.isFinite(event.startedAtEpochMs) || !Number.isFinite(event.endedAtEpochMs)) return event;
const timing = resourceTimingForApiRequest(route, Number(event.startedAtEpochMs), Number(event.endedAtEpochMs));
return Object.keys(timing).length > 0 ? { ...event, ...timing } : event;
}
function ensureInstalled(): void {
if (installed || typeof document === "undefined") return;
installed = true;
configureResourceTimingBuffer();
currentWorkbenchUiTrace();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
enqueueWorkbenchUiEvent({ eventType: "page_lifecycle", loadingScope: "page", state: "close", reason: "visibility_hidden", route: pageRoute(), valueMs: 0, startedAtEpochMs: wallNow(), endedAtEpochMs: wallNow(), outcome: "ok" });
flushWorkbenchPerformance(true);
}
});
window.addEventListener("pagehide", () => {
enqueueWorkbenchUiEvent({ eventType: "page_lifecycle", loadingScope: "page", state: "close", reason: "pagehide", route: pageRoute(), valueMs: 0, startedAtEpochMs: wallNow(), endedAtEpochMs: wallNow(), outcome: "ok" });
flushWorkbenchPerformance(true);
});
}
function configureResourceTimingBuffer(): void {
if (resourceTimingBufferConfigured || typeof performance === "undefined" || typeof performance.setResourceTimingBufferSize !== "function") return;
resourceTimingBufferConfigured = true;
performance.setResourceTimingBufferSize(2000);
}
function afterNextPaint(callback: () => void): void {
if (typeof requestAnimationFrame !== "function") {
callback();
return;
}
requestAnimationFrame(() => requestAnimationFrame(callback));
}
function isVisibleAgentOutputMessage(message: ChatMessage): boolean {
if (isTerminalStatusText(message.status) && String(message.text ?? "").trim()) return true;
return traceEventsFromMessage(message).some(isSubmitFirstVisibleTraceEvent);
}
function isVisibleTraceEventMessage(message: ChatMessage): boolean {
return traceEventsFromMessage(message).some(isUserVisibleTraceEvent);
}
function traceEventsFromMessage(message: ChatMessage): TraceEvent[] {
return Array.isArray(message.runnerTrace?.events) ? message.runnerTrace.events : [];
}
function isUserVisibleTraceEvent(event: TraceEvent): boolean {
const type = eventTypeFromTraceEvent(event);
return type === "assistant" || type === "tool_call" || type === "backend" || type === "terminal" || type === "error";
}
function isSubmitFirstVisibleTraceEvent(event: TraceEvent): boolean {
const type = eventTypeFromTraceEvent(event);
if (type === "tool_call") return true;
return type === "assistant" && isTerminalTraceEvent(event) && traceEventHasDisplayText(event);
}
function isTerminalTraceEvent(event: TraceEvent): boolean {
return event.terminal === true || event.final === true || isTerminalStatusText(event.status);
}
function isTerminalStatusText(value: unknown): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(safeText(value).toLowerCase().replace(/_/gu, "-"));
}
function traceEventHasDisplayText(event: TraceEvent): boolean {
return [event.message, event.text, event.outputSummary, event.stdoutSummary, event.stderrSummary, event.command].some((value) => Boolean(safeText(value)));
}
function eventTypeFromTraceEvent(event: TraceEvent | null | undefined): string {
const raw = safeText(event?.eventType ?? event?.type ?? event?.kind ?? event?.label ?? event?.status).toLowerCase();
if (/tool/u.test(raw)) return "tool_call";
if (/assistant|message|delta|reply/u.test(raw)) return "assistant";
if (/terminal|complete|final/u.test(raw)) return "terminal";
if (/error|fail|timeout/u.test(raw)) return "error";
if (/backend|runner|agentrun/u.test(raw)) return "backend";
if (/request|accepted/u.test(raw)) return "request";
return "status";
}
function backendFromTraceEvent(event: TraceEvent | null | undefined): string {
const direct = safeText(event?.backend ?? event?.backendProfile ?? event?.provider ?? event?.runnerKind);
return normalizeBackend(direct);
}
function normalizeBackend(value: unknown): string {
const text = safeText(value).toLowerCase();
if (!text) return "unknown";
if (text.includes("/")) return text;
if (/^(?:codex|deepseek|openai|claude|hyue)/u.test(text)) return `agentrun-v01/${text}`;
return text;
}
function normalizeTransport(value: unknown): string {
const text = safeText(value).toLowerCase();
if (text === "rest_gap") return "detail_history";
return text === "detail_history" || text === "sync_replay" || text === "poll" || text === "sse" ? text : "unknown";
}
function normalizeTargetState(value: unknown): string {
const text = safeText(value).toLowerCase();
if (text === "running" || text === "terminal" || text === "empty") return text;
if (["completed", "failed", "timeout", "canceled"].includes(text)) return "terminal";
return "unknown";
}
function traceEventCreatedAt(event: TraceEvent, fallback?: unknown): number | null {
const raw = safeText(event.createdAt ?? fallback ?? event.ts ?? event.timestamp);
const parsed = raw ? Date.parse(raw) : NaN;
return Number.isFinite(parsed) ? parsed : null;
}
function timestampMs(value: unknown): number | null {
const raw = safeText(value);
const parsed = raw ? Date.parse(raw) : NaN;
return Number.isFinite(parsed) ? parsed : null;
}
function pageRoute(): string {
if (typeof window === "undefined" || !window.location) return "/workbench";
return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench");
}
function hasBrowserTimerRuntime(): boolean {
return typeof window !== "undefined" && typeof window.setTimeout === "function" && typeof window.clearTimeout === "function";
}
function routeTemplate(input: string): string {
const raw = safeText(input) || "/workbench";
const pathOnly = raw.startsWith("#") ? raw.slice(1) || "/" : raw.split(/[?#]/u, 1)[0] || "/";
const segments = pathOnly.split("/").filter(Boolean).map((part) => routeSegmentTemplate(part));
const path = `/${segments.join("/")}`;
return `${path}${routeQueryTemplate(raw)}`;
}
function routeQueryTemplate(input: string): string {
try {
const url = new URL(input.startsWith("#") ? input.slice(1) || "/" : input, "https://hwlab.local");
if (!/^\/v1\/workbench\/sessions\/[^/]+$/u.test(url.pathname)) return "";
if (url.searchParams.get("includeMessages") === "false") return "?includeMessages=false";
if (url.searchParams.get("messages") === "0") return "?messages=0";
} catch {
// Keep route templates low-cardinality when the URL is not parseable.
}
return "";
}
function routeSegmentTemplate(segment: string): string {
const decoded = safeDecode(segment);
if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(decoded)) return ":id";
if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(decoded)) return ":uuid";
if (/^[A-Za-z0-9_-]{20,}$/u.test(decoded)) return ":id";
if (/^\d{5,}$/u.test(decoded)) return ":id";
return decoded.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80) || "_";
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function visibilityState(): string {
if (typeof document === "undefined") return "unknown";
return document.visibilityState === "hidden" ? "background" : "foreground";
}
function publishWorkbenchUiTrace(trace: WorkbenchUiTrace): void {
if (typeof window === "undefined") return;
window.__HWLAB_WORKBENCH_UI_TRACE__ = { ...trace };
}
function randomHex(bytes: number): string {
const values = new Uint8Array(Math.max(1, bytes));
const cryptoSource = typeof crypto !== "undefined" ? crypto : typeof window !== "undefined" ? window.crypto : null;
if (cryptoSource?.getRandomValues) cryptoSource.getRandomValues(values);
else for (let index = 0; index < values.length; index += 1) values[index] = Math.floor(Math.random() * 256);
return [...values].map((value) => value.toString(16).padStart(2, "0")).join("");
}
function hashIdentifier(value: unknown, prefix: string): string | undefined {
const text = safeText(value);
if (!text) return undefined;
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return `${prefix}_${hash.toString(16).padStart(8, "0")}`;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function diagnosticText(record: Record<string, unknown> | null, ...keys: string[]): string | undefined {
if (!record) return undefined;
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
}
return undefined;
}
function diagnosticNumber(record: Record<string, unknown> | null, ...keys: string[]): number | undefined {
if (!record) return undefined;
for (const key of keys) {
const value = record[key];
const number = Number(value);
if (Number.isFinite(number) && number >= 0) return Math.round(number);
}
return undefined;
}
function diagnosticBoolean(record: Record<string, unknown> | null, ...keys: string[]): boolean | undefined {
if (!record) return undefined;
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const text = value.trim().toLowerCase();
if (text === "true") return true;
if (text === "false") return false;
}
}
return undefined;
}
function diagnosticLabel(value: unknown): string | undefined {
const text = safeText(value).replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80);
return text || undefined;
}
function isWorkbenchPage(): boolean {
return pageRoute().startsWith("/workbench");
}
function statusClass(status: unknown): string {
const code = typeof status === "number" ? status : Number(status);
if (Number.isFinite(code) && code >= 100 && code < 600) return `${Math.floor(code / 100)}xx`;
if (code === 0) return "network";
return "unknown";
}
function normalizeUiOutcome(value: unknown): WorkbenchOutcome {
const text = safeText(value).toLowerCase();
if (text === "ok" || text === "timeout" || text === "error" || text === "dropped" || text === "stale" || text === "partial" || text === "empty" || text === "network" || text === "unknown") return text;
if (text === "http_error") return "error";
if (text === "network_error") return "network";
return "unknown";
}
function monotonicNow(): number {
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
}
function wallNow(): number {
return Date.now();
}
function elapsedSince(startAt: number): number {
return Math.max(0, monotonicNow() - startAt);
}
function safeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}