Merge pull request #2318 from pikasTech/issue-1328-workbench-performance-buffer
fix: bound workbench performance resource timing
This commit is contained in:
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user