Merge pull request #2318 from pikasTech/issue-1328-workbench-performance-buffer

fix: bound workbench performance resource timing
This commit is contained in:
Lyon
2026-07-01 01:48:18 +08:00
committed by GitHub
3 changed files with 106 additions and 10 deletions
+1
View File
@@ -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");
@@ -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;
@@ -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<WorkbenchPerformanceEvent> = {
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[] {