fix(web-probe): correlate CPU samples with performance windows
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
|
||||
|
||||
const alertThresholds = {
|
||||
sameOriginApiSlowMs: 60000,
|
||||
partialApiSlowMs: 60000,
|
||||
longLivedStreamOpenSlowMs: 60000,
|
||||
visibleLoadingSlowMs: 60000,
|
||||
turnTimingSampleSlackSeconds: 60,
|
||||
turnElapsedSevereTimeoutSeconds: 3600,
|
||||
domEvaluateTimeoutRedCount: 99,
|
||||
domEvaluateTimeoutRedWindowMs: 60000,
|
||||
screenshotTimeoutRedCount: 99,
|
||||
pageErrorRedCount: 99,
|
||||
longTaskRedMs: 50,
|
||||
longAnimationFrameRedMs: 50,
|
||||
eventLoopGapRedMs: 50,
|
||||
browserProcessSampleIntervalMs: 1000,
|
||||
requestRateBucketMs: 10000,
|
||||
requestRateTotalRedPerMinute: 999999,
|
||||
requestRatePageRedPerMinute: 999999,
|
||||
requestRateApiPathRedPerMinute: 999999,
|
||||
browserTotalRssRedMb: 999999,
|
||||
browserProcessRssRedMb: 999999,
|
||||
browserRssGrowthRedMb: 999999,
|
||||
browserRssGrowthWindowMs: 60000,
|
||||
playwrightResponsivenessRedMs: 60000,
|
||||
playwrightResponsivenessTimeoutRedCount: 99,
|
||||
cdpMetricsTimeoutRedCount: 99,
|
||||
uncommandedStateChangeCommandWindowMs: 1000,
|
||||
scrollJumpCommandWindowMs: 1000,
|
||||
scrollJumpFromY: 999999,
|
||||
scrollJumpToY: 999999,
|
||||
sessionRailFallbackRatio: 0.5,
|
||||
};
|
||||
|
||||
const browserFreezePolicy = {
|
||||
enabled: true,
|
||||
blockerWindowMs: 60000,
|
||||
memory: {
|
||||
totalRssBlockerMb: 999999,
|
||||
processRssBlockerMb: 999999,
|
||||
growthBlockerMb: 999999,
|
||||
},
|
||||
responsiveness: {
|
||||
latencyBlockerMs: 60000,
|
||||
eventBlockerCount: 99,
|
||||
},
|
||||
cdp: {
|
||||
metricsTimeoutBlockerCount: 99,
|
||||
},
|
||||
kill: {
|
||||
enabled: false,
|
||||
gracefulSignal: "SIGTERM",
|
||||
forceSignal: "SIGKILL",
|
||||
graceMs: 1000,
|
||||
pollIntervalMs: 100,
|
||||
exitCode: 124,
|
||||
},
|
||||
};
|
||||
|
||||
async function writeJsonl(path: string, rows: Array<Record<string, unknown>>): Promise<void> {
|
||||
await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : ""));
|
||||
}
|
||||
|
||||
async function runAnalyzer(stateDir: string): Promise<Record<string, unknown>> {
|
||||
const analyzerPath = join(stateDir, "analyze.mjs");
|
||||
await writeFile(analyzerPath, nodeWebObserveAnalyzerSource(), { mode: 0o700 });
|
||||
const result = spawnSync("bun", [analyzerPath, stateDir], {
|
||||
cwd: join(import.meta.dir, "../../.."),
|
||||
env: {
|
||||
...process.env,
|
||||
UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES: "0",
|
||||
UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON: JSON.stringify(alertThresholds),
|
||||
UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON: JSON.stringify(browserFreezePolicy),
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return JSON.parse(await readFile(join(stateDir, "analysis", "report.json"), "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function writeBaseState(options: { withSourceMap: boolean; captureEndAt: string }): Promise<string> {
|
||||
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-analyzer-performance-"));
|
||||
await mkdir(join(stateDir, "analysis"), { recursive: true });
|
||||
const profilePath = join(stateDir, "profile.cpuprofile");
|
||||
const sourceMapPath = join(stateDir, "app.js.map");
|
||||
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-performance-test", status: "completed" }) + "\n");
|
||||
await writeFile(join(stateDir, "heartbeat.json"), JSON.stringify({ status: "completed", updatedAt: "2026-07-02T00:00:03.000Z" }) + "\n");
|
||||
await writeFile(profilePath, JSON.stringify({
|
||||
startTime: 0,
|
||||
endTime: 2000000,
|
||||
nodes: [
|
||||
{ id: 1, callFrame: { functionName: "(root)", url: "", lineNumber: -1, columnNumber: -1 }, children: [2] },
|
||||
{ id: 2, callFrame: { functionName: "hotParse", url: "https://hwlab.example.test/assets/app.js", scriptId: "7", lineNumber: 0, columnNumber: 10 } },
|
||||
],
|
||||
samples: [2, 2, 2],
|
||||
timeDeltas: [600000, 600000, 800000],
|
||||
}) + "\n");
|
||||
if (options.withSourceMap) {
|
||||
await writeFile(sourceMapPath, JSON.stringify({
|
||||
version: 3,
|
||||
file: "app.js",
|
||||
sources: ["src/api/client.ts"],
|
||||
names: ["responseJson"],
|
||||
mappings: "UAKEA",
|
||||
}) + "\n");
|
||||
}
|
||||
await writeJsonl(join(stateDir, "samples.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "network.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "control.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "console.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "errors.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "browser-process.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "artifacts.jsonl"), options.withSourceMap ? [{
|
||||
ts: "2026-07-02T00:00:00.100Z",
|
||||
kind: "source-map",
|
||||
path: sourceMapPath,
|
||||
url: "https://hwlab.example.test/assets/app.js.map",
|
||||
}] : []);
|
||||
await writeJsonl(join(stateDir, "performance-events.jsonl"), [
|
||||
{
|
||||
type: "performance-event",
|
||||
ts: "2026-07-02T00:00:01.080Z",
|
||||
sampleSeq: 11,
|
||||
pageRole: "control",
|
||||
pageId: "page-1",
|
||||
performance: {
|
||||
kind: "long-animation-frame",
|
||||
name: "long-animation-frame",
|
||||
duration: 80,
|
||||
blockingDuration: 80,
|
||||
timeOrigin: Date.parse("2026-07-02T00:00:00.000Z"),
|
||||
startTime: 1000,
|
||||
path: "/workbench",
|
||||
url: "https://hwlab.example.test/workbench",
|
||||
scripts: [{
|
||||
invoker: "then",
|
||||
invokerType: "promise",
|
||||
sourceURL: "https://hwlab.example.test/assets/app.js",
|
||||
sourceFunctionName: "Response.json.then",
|
||||
sourceCharPosition: 10,
|
||||
lineNumber: 0,
|
||||
columnNumber: 10,
|
||||
duration: 80,
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "performance-capture-completed",
|
||||
ts: options.captureEndAt,
|
||||
commandId: "cmd-profile",
|
||||
captureId: "cap-profile",
|
||||
pageRole: "control",
|
||||
pageId: "page-1",
|
||||
artifact: { kind: "performance-cpu-profile", captureId: "cap-profile", commandId: "cmd-profile", path: profilePath, durationMs: 2000 },
|
||||
summary: {
|
||||
durationMs: 2000,
|
||||
sampleCount: 3,
|
||||
totalTimeMs: 2000,
|
||||
topFunctions: [{
|
||||
functionName: "hotParse",
|
||||
url: "https://hwlab.example.test/assets/app.js",
|
||||
scriptId: "7",
|
||||
lineNumber: 0,
|
||||
columnNumber: 10,
|
||||
selfTimeMs: 2000,
|
||||
totalTimeMs: 2000,
|
||||
sampleCount: 3,
|
||||
}],
|
||||
},
|
||||
},
|
||||
]);
|
||||
return stateDir;
|
||||
}
|
||||
|
||||
test("observe analyzer maps LoAF and same-window CPU samples through loaded source map", async () => {
|
||||
const stateDir = await writeBaseState({ withSourceMap: true, captureEndAt: "2026-07-02T00:00:02.500Z" });
|
||||
const report = await runAnalyzer(stateDir);
|
||||
const frontendPerformance = report.frontendPerformance as Record<string, unknown>;
|
||||
const summary = frontendPerformance.summary as Record<string, unknown>;
|
||||
assert.equal(summary.sourceMapStatus, "mapped");
|
||||
assert.equal(summary.cpuProfileWindowCoveredCount, 1);
|
||||
|
||||
const scriptHotspot = (frontendPerformance.scriptHotspots as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(scriptHotspot.sourceMapStatus, "mapped");
|
||||
assert.equal(scriptHotspot.sourceFile, "src/api/client.ts");
|
||||
assert.equal(scriptHotspot.sourceLine, 6);
|
||||
assert.equal(scriptHotspot.lineNumber, 0);
|
||||
assert.equal(scriptHotspot.sourceCharPosition, 10);
|
||||
|
||||
const windowRow = (frontendPerformance.performanceWindows as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(windowRow.cpuProfileCoverageStatus, "covered");
|
||||
const evidence = windowRow.profileEvidence as Record<string, unknown>;
|
||||
assert.equal(evidence.timelineStatus, "window-samples");
|
||||
assert.equal(evidence.evidenceScope, "same-window-cpu-samples");
|
||||
assert.equal(evidence.sampleCount, 1);
|
||||
assert.equal(evidence.totalTimeMs, 80);
|
||||
const topFunction = (evidence.topFunctions as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(topFunction.functionName, "hotParse");
|
||||
assert.equal(topFunction.sourceMapStatus, "mapped");
|
||||
assert.equal(topFunction.sourceFile, "src/api/client.ts");
|
||||
assert.equal(topFunction.sourceLine, 6);
|
||||
assert.equal(topFunction.selfTimeMs, 80);
|
||||
}, 20_000);
|
||||
|
||||
test("observe analyzer preserves raw attribution when source map is missing and marks missed CPU timeline windows", async () => {
|
||||
const stateDir = await writeBaseState({ withSourceMap: false, captureEndAt: "2026-07-02T00:00:05.000Z" });
|
||||
const report = await runAnalyzer(stateDir);
|
||||
const frontendPerformance = report.frontendPerformance as Record<string, unknown>;
|
||||
const summary = frontendPerformance.summary as Record<string, unknown>;
|
||||
assert.equal(summary.sourceMapStatus, "missing");
|
||||
assert.equal(summary.cpuProfileWindowMissedCount, 1);
|
||||
assert.equal(summary.attributionMode, "cpu-profile-missed-performance-window");
|
||||
|
||||
const scriptHotspot = (frontendPerformance.scriptHotspots as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(scriptHotspot.sourceMapStatus, "missing");
|
||||
assert.equal(scriptHotspot.sourceFile, "app.js");
|
||||
assert.equal(scriptHotspot.sourceLine, 0);
|
||||
|
||||
const windowRow = (frontendPerformance.performanceWindows as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(windowRow.cpuProfileCoverageStatus, "missed");
|
||||
assert.equal(windowRow.profileEvidence, null);
|
||||
const capture = windowRow.capture as Record<string, unknown>;
|
||||
assert.equal(capture.profileTimelineStatus, "loaded");
|
||||
assert.equal(capture.windowDistanceMs, 1920);
|
||||
}, 20_000);
|
||||
Reference in New Issue
Block a user