438 lines
18 KiB
TypeScript
438 lines
18 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";
|
|
type WorkbenchOutcome = "ok" | "timeout" | "error" | "dropped" | "stale" | "partial" | "empty" | "network" | "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;
|
|
}
|
|
|
|
interface TraceEventTimingInput {
|
|
traceId: string | null | undefined;
|
|
events: TraceEvent[];
|
|
transport: "sse" | "rest_gap" | "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 {
|
|
conversationId: string;
|
|
startAt: number;
|
|
route: string;
|
|
source: string;
|
|
targetState: string;
|
|
cache: string;
|
|
firstVisibleReported: boolean;
|
|
fullLoadReported: boolean;
|
|
}
|
|
|
|
interface SubmitJourneyState {
|
|
traceId: string;
|
|
conversationId: 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;
|
|
visibleReported: boolean;
|
|
}
|
|
|
|
const SCHEMA_VERSION = "hwlab-web-performance-v2";
|
|
const FLUSH_INTERVAL_MS = 2500;
|
|
const MAX_QUEUE = 120;
|
|
const submitJourneys = new Map<string, SubmitJourneyState>();
|
|
const sessionSwitches = new Map<string, SessionSwitchState>();
|
|
const traceEvents = new Map<string, TraceEventState>();
|
|
let openJourney: OpenJourneyState | null = null;
|
|
let queue: WorkbenchPerformanceEvent[] = [];
|
|
let installed = false;
|
|
let flushTimer: number | null = null;
|
|
|
|
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: { conversationId: string | null | undefined; source: "rail" | "deeplink" | "history" | "direct"; targetState?: string | null; cache?: "warm" | "cold" | "unknown" }): void {
|
|
const conversationId = safeText(input.conversationId);
|
|
if (!conversationId) return;
|
|
ensureInstalled();
|
|
sessionSwitches.set(conversationId, {
|
|
conversationId,
|
|
startAt: monotonicNow(),
|
|
route: pageRoute(),
|
|
source: input.source,
|
|
targetState: normalizeTargetState(input.targetState),
|
|
cache: input.cache ?? "unknown",
|
|
firstVisibleReported: false,
|
|
fullLoadReported: false
|
|
});
|
|
}
|
|
|
|
export function finishWorkbenchSessionSwitchFullLoad(conversationId: string | null | undefined, outcome: WorkbenchOutcome = "ok"): void {
|
|
const state = sessionSwitches.get(safeText(conversationId));
|
|
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(conversationId: string | null | undefined): void {
|
|
finishWorkbenchSessionSwitchFullLoad(conversationId, "error");
|
|
}
|
|
|
|
export function startWorkbenchSubmitJourney(input: { traceId: string; conversationId: string; entry: "new" | "existing" | "steer" | "retry"; backend?: string | null; transport?: string | null }): void {
|
|
if (!input.traceId || !input.conversationId) return;
|
|
ensureInstalled();
|
|
submitJourneys.set(input.traceId, {
|
|
traceId: input.traceId,
|
|
conversationId: input.conversationId,
|
|
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_first_visible",
|
|
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);
|
|
traceEvents.set(traceId, { traceId, receivedAt, projectedAt: null, eventCreatedAt, appendedAt, serverSentAt, traceSeq: Number.isFinite(traceSeq) ? Math.trunc(traceSeq) : null, eventType, backend, transport: input.transport, 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) });
|
|
else if (eventCreatedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, receivedWallAt - eventCreatedAt) });
|
|
}
|
|
|
|
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[]; activeConversationId?: string | null; detailLoading?: boolean }): void {
|
|
afterNextPaint(() => acknowledgeWorkbenchVisible(input));
|
|
}
|
|
|
|
export function acknowledgeWorkbenchVisible(input: { messages: ChatMessage[]; activeConversationId?: 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 activeConversationId = safeText(input.activeConversationId);
|
|
const session = activeConversationId ? sessionSwitches.get(activeConversationId) : 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: input.messages.length > 0 ? "ok" : "empty", 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" || !isVisibleAgentMessage(message)) continue;
|
|
const submit = submitJourneys.get(traceId);
|
|
if (submit && !submit.firstVisibleReported) {
|
|
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) {
|
|
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) enqueue({ kind: "workbench_backend_event_visible", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: "ok", valueMs: Math.max(0, wallNow() - event.eventCreatedAt) });
|
|
traceEvents.delete(traceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resetWorkbenchPerformanceForTest(): void {
|
|
submitJourneys.clear();
|
|
sessionSwitches.clear();
|
|
traceEvents.clear();
|
|
openJourney = null;
|
|
queue = [];
|
|
if (flushTimer !== null && typeof window !== "undefined") window.clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
installed = false;
|
|
}
|
|
|
|
export function drainWorkbenchPerformanceEventsForTest(): WorkbenchPerformanceEvent[] {
|
|
const events = queue;
|
|
queue = [];
|
|
return events;
|
|
}
|
|
|
|
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);
|
|
scheduleFlush();
|
|
}
|
|
|
|
function scheduleFlush(): void {
|
|
if (typeof window === "undefined" || flushTimer !== null) return;
|
|
flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS);
|
|
}
|
|
|
|
function flushWorkbenchPerformance(useBeacon: boolean): void {
|
|
if (flushTimer !== null && typeof window !== "undefined") {
|
|
window.clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (!queue.length || typeof window === "undefined") return;
|
|
const events = queue.splice(0, queue.length);
|
|
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 ensureInstalled(): void {
|
|
if (installed || typeof document === "undefined") return;
|
|
installed = true;
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "hidden") flushWorkbenchPerformance(true);
|
|
});
|
|
}
|
|
|
|
function afterNextPaint(callback: () => void): void {
|
|
if (typeof requestAnimationFrame !== "function") {
|
|
callback();
|
|
return;
|
|
}
|
|
requestAnimationFrame(() => requestAnimationFrame(callback));
|
|
}
|
|
|
|
function isVisibleAgentMessage(message: ChatMessage): boolean {
|
|
if (message.runnerTrace && ((message.runnerTrace.events?.length ?? 0) > 0 || message.runnerTrace.eventCount)) return true;
|
|
if (String(message.text ?? "").trim()) return true;
|
|
return !["pending", "running"].includes(String(message.status ?? "").toLowerCase());
|
|
}
|
|
|
|
function isUserVisibleTraceEvent(event: TraceEvent): boolean {
|
|
const type = eventTypeFromTraceEvent(event);
|
|
return type === "assistant" || type === "tool_call" || type === "backend" || type === "terminal" || type === "error";
|
|
}
|
|
|
|
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();
|
|
return text === "rest_gap" || 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") return "/workbench";
|
|
return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench");
|
|
}
|
|
|
|
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));
|
|
return `/${segments.join("/")}`;
|
|
}
|
|
|
|
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 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() : "";
|
|
}
|