diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 9fd23bae..996959d3 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -181,6 +181,7 @@ assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_ assertIncludes(workbenchRefreshRuntimeSource, "if (existing)", "Scheduled refresh runtime must coalesce instead of resetting timers under SSE error storms"); assertIncludes(workbenchRefreshRuntimeSource, "replaceTimer", "Scheduled refresh runtime must make replacement explicit instead of ad hoc timer resets"); assertIncludes(workbenchPerformanceSource, "recordWorkbenchRuntimeDiagnostic", "Workbench performance probe must record runtime diagnostics for monitor root cause visibility"); +assertIncludes(workbenchPerformanceSource, "clearResourceTimings", "Workbench performance probe must bound browser ResourceTiming retention after API enrichment"); assertIncludes(workbenchStoreSource, "recordWorkbenchRuntimeDiagnostic", "Workbench store must surface SSE recovery diagnostics to the performance probe"); assertIncludes(workbenchRealtimePlanSource, "new Set(recovery.actions)", "Realtime recovery planner must consume transport-owned actions explicitly"); assertIncludes(workbenchRealtimePlanSource, "actions.has(\"schedule-session-list\")", "Realtime stream errors must schedule bounded session list refreshes only when transport requests that action"); diff --git a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts index 275c4d63..b7ffff79 100644 --- a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts @@ -14,6 +14,7 @@ import { markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, + recordWorkbenchApiRequest, recordWorkbenchRuntimeDiagnostic, resetWorkbenchPerformanceForTest, startWorkbenchOpenJourney, @@ -98,6 +99,44 @@ test("Workbench runtime diagnostics expose root cause labels without raw identif } }); +test("Workbench API timing enriches from ResourceTiming and clears the browser buffer", () => { + resetWorkbenchPerformanceForTest(); + let clearCount = 0; + let configuredBufferSize = 0; + const startedAtEpochMs = 1_000_000; + const entries = [resourceTimingEntry({ name: "https://hwlab.example.test/v1/workbench/sessions/ses_secret/messages?limit=100", startTime: 10, duration: 42, responseStart: 37, responseEnd: 52 })]; + const restoreBrowserRuntime = installBrowserProbeRuntime({ + performance: { + timeOrigin: startedAtEpochMs - 10, + now: () => 123, + getEntriesByType: (type: string) => type === "resource" ? entries : [], + setResourceTimingBufferSize: (value: number) => { + configuredBufferSize = value; + }, + clearResourceTimings: () => { + clearCount += 1; + entries.splice(0, entries.length); + } + } + }); + try { + recordWorkbenchApiRequest({ route: "/v1/workbench/sessions/ses_secret/messages?limit=100", method: "GET", status: 200, outcome: "ok", startedAtEpochMs, endedAtEpochMs: startedAtEpochMs + 60 }); + + const events = drainWorkbenchPerformanceEventsForTest(); + const event = events.find((item) => item.kind === "workbench_ui_event" && item.eventType === "api_request"); + assert.equal(configuredBufferSize, 2000); + assert.equal(clearCount, 1); + assert.equal(entries.length, 0); + assert.equal(event?.route, "/v1/workbench/sessions/:id/messages"); + assert.equal(event?.resourceDurationMs, 42); + assert.equal(event?.resourceRequestWaitMs, 15); + assert.equal(event?.resourceResponseTransferMs, 15); + } finally { + resetWorkbenchPerformanceForTest(); + restoreBrowserRuntime(); + } +}); + test("Workbench session switch treats visible empty sessions as successful first paint", () => { resetWorkbenchPerformanceForTest(); startWorkbenchSessionSwitch({ sessionId: "ses_empty_visible", source: "rail", targetState: "empty", cache: "cold" }); @@ -168,15 +207,18 @@ function traceOnlyAgentMessage(sessionId: string, traceId: string, events: Trace }; } -function installBrowserProbeRuntime(): () => void { +function installBrowserProbeRuntime(options: { performance?: FakePerformance } = {}): () => void { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const previousDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); + const previousPerformance = Object.getOwnPropertyDescriptor(globalThis, "performance"); Object.defineProperty(globalThis, "window", { configurable: true, value: { - location: { pathname: "/workbench", hash: "" }, + location: { href: "https://hwlab.example.test/workbench", pathname: "/workbench", hash: "" }, crypto: globalThis.crypto, - addEventListener: () => undefined + addEventListener: () => undefined, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout } }); Object.defineProperty(globalThis, "document", { @@ -186,13 +228,46 @@ function installBrowserProbeRuntime(): () => void { addEventListener: () => undefined } }); + if (options.performance) { + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: options.performance + }); + } return () => { restoreGlobalProperty("window", previousWindow); restoreGlobalProperty("document", previousDocument); + restoreGlobalProperty("performance", previousPerformance); }; } -function restoreGlobalProperty(key: "window" | "document", descriptor: PropertyDescriptor | undefined): void { +interface FakePerformance { + timeOrigin: number; + now: () => number; + getEntriesByType: (type: string) => unknown[]; + setResourceTimingBufferSize?: (value: number) => void; + clearResourceTimings?: () => void; +} + +function resourceTimingEntry(input: { name: string; startTime: number; duration: number; responseStart: number; responseEnd: number }): PerformanceResourceTiming { + return { + name: input.name, + startTime: input.startTime, + duration: input.duration, + fetchStart: input.startTime, + requestStart: input.startTime + 12, + responseStart: input.responseStart, + responseEnd: input.responseEnd, + transferSize: 1000, + encodedBodySize: 800, + decodedBodySize: 1600, + nextHopProtocol: "h2", + initiatorType: "fetch", + serverTiming: [] + } as unknown as PerformanceResourceTiming; +} + +function restoreGlobalProperty(key: "window" | "document" | "performance", descriptor: PropertyDescriptor | undefined): void { if (descriptor) { Object.defineProperty(globalThis, key, descriptor); return; diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index 5eae9212..be51af24 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -196,12 +196,13 @@ export function recordWorkbenchApiRequest(input: { route: string; method?: strin 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, + route: routeLabel, method: input.method ?? "GET", status: input.status, statusClass: statusClass(input.status), @@ -230,11 +231,8 @@ function resourceTimingForApiRequest(route: string, startedAtEpochMs: number, en const entries = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; const lowerBound = startedAtEpochMs - 2000; const upperBound = endedAtEpochMs + 2000; - const candidates = entries - .map((entry) => resourceTimingCandidate(entry, target, timeOrigin, lowerBound, upperBound, startedAtEpochMs)) - .filter((candidate): candidate is ResourceTimingCandidate => candidate !== null) - .sort((left, right) => Number(right.queryExact) - Number(left.queryExact) || left.initiatorRank - right.initiatorRank || left.startDistanceMs - right.startDistanceMs); - const entry = candidates[0]?.entry; + const entry = bestResourceTimingEntry(entries, target, timeOrigin, lowerBound, upperBound, startedAtEpochMs); + clearCollectedResourceTimings(entries.length); if (!entry) return {}; const result: Partial = { resourceDurationMs: roundedNonNegative(entry.duration), @@ -257,6 +255,22 @@ interface ResourceTimingCandidate { 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; @@ -277,6 +291,11 @@ function resourceTimingCandidate(entry: PerformanceResourceTiming, target: URL, }; } +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; @@ -542,6 +561,7 @@ export function resetWorkbenchPerformanceForTest(): void { if (flushTimer !== null && hasBrowserTimerRuntime()) window.clearTimeout(flushTimer); flushTimer = null; installed = false; + resourceTimingBufferConfigured = false; } export function drainWorkbenchPerformanceEventsForTest(): WorkbenchPerformanceEvent[] {