Merge pull request #1451 from pikasTech/fix/2351-webprobe-performance-evidence

fix: surface WebProbe performance evidence mode
This commit is contained in:
Lyon
2026-07-02 20:56:51 +08:00
committed by GitHub
3 changed files with 239 additions and 4 deletions
@@ -46,6 +46,7 @@ function buildFrontendPerformanceReport(rows, artifacts) {
.map(finalizeProfileStackHotspot)
.sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0))
.slice(0, 30);
const attribution = frontendPerformanceAttributionStatus({ captures, profileHotspots, profileStacks, scriptHotspots, longTasks, loafs, gaps });
return {
summary: {
rowCount: sourceRows.length,
@@ -63,6 +64,12 @@ function buildFrontendPerformanceReport(rows, artifacts) {
maxLongTaskMs: frontendPerformanceMaxNumber(longTasks, (item) => item.durationMs),
maxLongAnimationFrameMs: frontendPerformanceMaxNumber(loafs, (item) => item.durationMs),
maxEventLoopGapMs: frontendPerformanceMaxNumber(gaps, (item) => item.durationMs),
cpuProfileStatus: attribution.cpuProfileStatus,
attributionMode: attribution.attributionMode,
noCpuProfile: attribution.noCpuProfile,
loafOnly: attribution.loafOnly,
cpuProfileHotspotEvidence: attribution.cpuProfileHotspotEvidence,
evidenceNote: attribution.evidenceNote,
captureArtifacts: performanceCaptureArtifacts(artifacts),
valuesRedacted: true,
},
@@ -127,6 +134,21 @@ function buildFrontendPerformanceFindings(report) {
topStacks: (report?.profileStacks || []).slice(0, 8),
valuesRedacted: true,
});
if (summary.noCpuProfile === true && (severeLongTasks.length > 0 || severeLoafs.length > 0 || severeGaps.length > 0)) findings.push({
id: "frontend-performance-loaf-only-no-cpu-profile",
severity: "amber",
summary: "frontend performance evidence is LoAF/LongTask/event-loop only because no completed CPU profile capture is present; do not cite CPU profile hotspots for this run",
count: 1,
attributionMode: summary.attributionMode || "loaf-only-no-cpu-profile",
cpuProfileStatus: summary.cpuProfileStatus || "missing",
captureCount: summary.captureCount ?? 0,
longTaskCount: summary.longTaskCount ?? 0,
longAnimationFrameCount: summary.longAnimationFrameCount ?? 0,
eventLoopGapCount: summary.eventLoopGapCount ?? 0,
topScripts: (report?.scriptHotspots || []).slice(0, 12),
nextAction: "Run an explicit performanceCapture command and re-run observe analyze before making CPU-profile hotspot claims; existing LoAF scripts remain valid browser-side attribution.",
valuesRedacted: true,
});
if ((report?.drainErrors || []).length > 0) findings.push({
id: "frontend-performance-probe-drain-errors",
severity: "amber",
@@ -138,6 +160,43 @@ function buildFrontendPerformanceFindings(report) {
return findings;
}
function frontendPerformanceAttributionStatus(input) {
const captureCount = Array.isArray(input?.captures) ? input.captures.length : 0;
const profileHotspotCount = Array.isArray(input?.profileHotspots) ? input.profileHotspots.length : 0;
const profileStackCount = Array.isArray(input?.profileStacks) ? input.profileStacks.length : 0;
const scriptHotspotCount = Array.isArray(input?.scriptHotspots) ? input.scriptHotspots.length : 0;
const eventCount =
(Array.isArray(input?.longTasks) ? input.longTasks.length : 0) +
(Array.isArray(input?.loafs) ? input.loafs.length : 0) +
(Array.isArray(input?.gaps) ? input.gaps.length : 0);
const hasCpuProfile = captureCount > 0;
const hasCpuProfileHotspots = profileHotspotCount > 0 || profileStackCount > 0;
if (hasCpuProfile) {
return {
cpuProfileStatus: hasCpuProfileHotspots ? "captured-with-hotspots" : "captured-no-hotspots",
attributionMode: "cpu-profile-and-performance-observer",
noCpuProfile: false,
loafOnly: false,
cpuProfileHotspotEvidence: hasCpuProfileHotspots,
evidenceNote: hasCpuProfileHotspots
? "completed performanceCapture artifacts produced CPU profile hotspot evidence"
: "completed performanceCapture artifacts exist, but no CPU profile hotspots were extracted",
valuesRedacted: true,
};
}
return {
cpuProfileStatus: "missing",
attributionMode: eventCount > 0 || scriptHotspotCount > 0 ? "loaf-only-no-cpu-profile" : "no-frontend-performance-evidence",
noCpuProfile: true,
loafOnly: eventCount > 0 || scriptHotspotCount > 0,
cpuProfileHotspotEvidence: false,
evidenceNote: eventCount > 0 || scriptHotspotCount > 0
? "LongTask/LoAF/event-loop evidence is present, but no completed performanceCapture CPU profile exists"
: "no frontend performance events or completed performanceCapture CPU profile exist",
valuesRedacted: true,
};
}
function compactPerformanceEventRow(row, perf) {
return {
ts: row.ts ?? null,
+78 -4
View File
@@ -46,6 +46,7 @@ const network=readJsonl('network.jsonl');
const browserProcess=readJsonl('browser-process.jsonl');
const performanceRows=readJsonl('performance-events.jsonl');
const manifest=readJson('manifest.json')||{};
const heartbeat=readJson('heartbeat.json')||{};
const report=readJson('analysis/report.json')||{};
function unique(values){return Array.from(new Set(values.filter(Boolean)));}
function numOrNull(value){const n=Number(value); return Number.isFinite(n)?n:null}
@@ -746,13 +747,68 @@ function compactPerfCapture(item){
function compactPerfFinding(item){
return {severity:item?.severity??item?.level??null,id:short(item?.id||item?.kind||item?.code||'',56),count:item?.count??item?.sampleCount??null,rootCause:item?.rootCause??item?.rootCauseStatus??null,summary:short(item?.summary||item?.message||'',110),valuesRedacted:true};
}
function compactPerfCommand(row){
return {bucket:row?.bucket||null,id:short(commandFileId(row)||'',40),type:short(commandFileType(row)||'',32),ts:short(commandFileTs(row)||'',32),ageSeconds:row?.data?.ageSeconds??null,relative:short(row?.relative||'',72),valuesRedacted:true};
}
function runnerStatusForPerformance(){
const status=String(report?.heartbeat?.status||heartbeat?.status||report?.manifest?.status||manifest?.status||'').trim();
const terminal=/^(completed|failed|force-stopped|stopped|abandoned)$/u.test(status);
const failed=/^(failed|force-stopped|abandoned|not-running)$/u.test(status);
return {status:status||null,terminal,notRunningOrFailed:failed,updatedAt:report?.heartbeat?.updatedAt||heartbeat?.updatedAt||heartbeat?.lastSampleAt||null,valuesRedacted:true};
}
function performanceCaptureCommandStatus(commandFiles){
const rows=commandFiles.filter((row)=>commandFileType(row)==='performanceCapture');
const byBucket=(bucket)=>rows.filter((row)=>row.bucket===bucket);
return {
total:rows.length,
pendingCount:byBucket('pending').length,
processingCount:byBucket('processing').length,
doneCount:byBucket('done').length,
failedCount:byBucket('failed').length,
abandonedCount:byBucket('abandoned').length,
pending:byBucket('pending').slice(0,6).map(compactPerfCommand),
processing:byBucket('processing').slice(0,6).map(compactPerfCommand),
failed:byBucket('failed').slice(-4).map(compactPerfCommand),
done:byBucket('done').slice(-4).map(compactPerfCommand),
valuesRedacted:true
};
}
function performanceToolFindings(){
const ids=new Set(['tool-pending-commands-unconsumed','tool-runner-heartbeat-stale','tool-target-page-not-ready','tool-runner-force-stopped','frontend-browser-freeze-runner-blocker','frontend-playwright-responsiveness-red','frontend-cdp-metrics-timeout-red','frontend-performance-probe-drain-errors','frontend-performance-loaf-only-no-cpu-profile']);
return (Array.isArray(report.findings)?report.findings:[]).filter((item)=>ids.has(String(item?.id||item?.kind||item?.code||''))).slice(0,10).map(compactPerfFinding);
}
function performanceEvidenceMode(perf, commandFiles){
const s=perf.summary||{};
const captureCount=Number(s.captureCount??0);
const hasCpuProfile=captureCount>0;
const hasPerformanceObserverEvidence=
Number(s.longTaskCount??0)>0||
Number(s.longAnimationFrameCount??0)>0||
Number(s.eventLoopGapCount??0)>0||
Number(s.scriptHotspotCount??0)>0||
(Array.isArray(perf.longTasks)&&perf.longTasks.length>0)||
(Array.isArray(perf.longAnimationFrames)&&perf.longAnimationFrames.length>0)||
(Array.isArray(perf.eventLoopGaps)&&perf.eventLoopGaps.length>0)||
(Array.isArray(perf.scriptHotspots)&&perf.scriptHotspots.length>0);
const commands=performanceCaptureCommandStatus(commandFiles);
const runner=runnerStatusForPerformance();
const pendingPerformanceCapture=commands.pendingCount>0||commands.processingCount>0;
const cpuProfileStatus=hasCpuProfile?String(s.cpuProfileStatus||'captured'):pendingPerformanceCapture?'pending-command-no-cpu-profile':'no-cpu-profile';
const attributionMode=hasCpuProfile?'cpu-profile-and-performance-observer':hasPerformanceObserverEvidence?'loaf-only-no-cpu-profile':'no-frontend-performance-evidence';
const statement=hasCpuProfile
? 'CPU profile artifacts are present; hotspot rows may be used as CPU profile evidence.'
: pendingPerformanceCapture
? 'LoAF-only / no CPU profile: performanceCapture is pending or processing, so CPU profile hotspots are unavailable for this run.'
: 'LoAF-only / no CPU profile: no completed performanceCapture artifact is present, so do not cite CPU profile hotspots.';
return {attributionMode,cpuProfileStatus,hasCpuProfile,noCpuProfile:!hasCpuProfile,loafOnly:!hasCpuProfile&&hasPerformanceObserverEvidence,pendingPerformanceCapture,runner,performanceCaptureCommands:commands,statement,valuesRedacted:true};
}
function performanceSummaryFromReport(){
const perf=report.frontendPerformance&&typeof report.frontendPerformance==='object'?report.frontendPerformance:{};
const summary=perf.summary&&typeof perf.summary==='object'?perf.summary:{};
const findings=Array.isArray(report.findings)?report.findings.filter((item)=>String(item?.id||item?.kind||'').match(/^frontend-(?:long|event-loop|cpu-profile|performance)/u)).slice(0,6).map(compactPerfFinding):[];
const captureRows=Array.isArray(perf.captures)?perf.captures:[];
return {
summary:{...summary, rawPerformanceRowCount:performanceRows.length, valuesRedacted:true},
const commandFiles=readCommandFiles();
const model={summary:{...summary, rawPerformanceRowCount:performanceRows.length, valuesRedacted:true},
longTasks:Array.isArray(perf.longTasks)?perf.longTasks.slice(0,3).map(compactPerfEvent):[],
longAnimationFrames:Array.isArray(perf.longAnimationFrames)?perf.longAnimationFrames.slice(0,3).map(compactPerfEvent):[],
eventLoopGaps:Array.isArray(perf.eventLoopGaps)?perf.eventLoopGaps.slice(0,3).map(compactPerfEvent):[],
@@ -761,16 +817,31 @@ function performanceSummaryFromReport(){
profileStacks:Array.isArray(perf.profileStacks)?perf.profileStacks.slice(0,3).map(compactProfileStack):[],
captures:captureRows.slice(-3).map(compactPerfCapture),
findings,
toolFindings:performanceToolFindings(),
valuesRedacted:true
};
model.evidenceMode=performanceEvidenceMode(model,commandFiles);
return {
...model
};
}
function renderPerformanceSummary(perf){
const s=perf.summary||{};
const mode=perf.evidenceMode||{};
const captureCommands=mode.performanceCaptureCommands||{};
const runner=mode.runner||{};
const lines=['WEB-PROBE frontend performance '+(manifest.jobId||'-'),'======================================================='];
lines.push('events='+String(s.eventCount??0)+' rawRows='+String(s.rawPerformanceRowCount??0)+' longTask='+String(s.longTaskCount??0)+' loaf='+String(s.longAnimationFrameCount??0)+' eventLoopGap='+String(s.eventLoopGapCount??0)+' captures='+String(s.captureCount??0));
lines.push('max longTask='+String(s.maxLongTaskMs??'-')+'ms budget='+String(s.longTaskRedMs??'-')+'ms; max LoAF='+String(s.maxLongAnimationFrameMs??'-')+'ms budget='+String(s.longAnimationFrameRedMs??'-')+'ms; max gap='+String(s.maxEventLoopGapMs??'-')+'ms budget='+String(s.eventLoopGapRedMs??'-')+'ms');
lines.push('evidence attribution='+String(mode.attributionMode||s.attributionMode||'-')+' cpuProfile='+String(mode.cpuProfileStatus||s.cpuProfileStatus||'-')+' pendingPerformanceCapture='+String(captureCommands.pendingCount??0)+' processingPerformanceCapture='+String(captureCommands.processingCount??0)+' runner='+String(runner.status||'-')+' runnerNotRunningOrFailed='+String(runner.notRunningOrFailed===true));
if(mode.statement) lines.push('status: '+mode.statement);
if(mode.pendingPerformanceCapture===true){
const pending=[...(captureCommands.pending||[]),...(captureCommands.processing||[])].slice(0,4).map((item)=>String(item.id||'-')+'('+String(item.bucket||'-')+')').join(', ');
lines.push('pending performanceCapture: '+(pending||'-'));
}
if(runner.notRunningOrFailed===true) lines.push('runner state: not-running/failed for performance evidence; treat missing CPU profile as tool state, not as proof that no CPU hotspot exists.');
lines.push('','CPU profile hotspots');
if(perf.profileHotspots.length===0) lines.push('-');
if(perf.profileHotspots.length===0) lines.push(mode.noCpuProfile===true?'(no CPU profile hotspots; no completed performanceCapture artifact in this run)':'-');
for(const item of perf.profileHotspots.slice(0,10)) lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',44)+' '+short(item.url||item.scriptId||'-',92)+' line='+String(item.lineNumber??'-')+' captures='+String(item.captureCount??'-'));
lines.push('','CPU profile stacks');
if(perf.profileStacks.length===0) lines.push('-');
@@ -786,6 +857,9 @@ function renderPerformanceSummary(perf){
lines.push('','Findings');
if(perf.findings.length===0) lines.push('-');
for(const item of perf.findings.slice(0,12)) lines.push(String(item.severity||'-')+': '+String(item.id||item.kind||'-')+' count='+String(item.count??'-')+' '+short(item.summary||item.message||'',150));
lines.push('','Tool status');
if(!Array.isArray(perf.toolFindings)||perf.toolFindings.length===0) lines.push('-');
for(const item of (perf.toolFindings||[]).slice(0,10)) lines.push(String(item.severity||'-')+': '+String(item.id||'-')+' count='+String(item.count??'-')+' '+short(item.summary||'',150));
lines.push('','NEXT',' capture: bun scripts/cli.ts web-probe observe command '+(manifest.jobId||'<observer>')+' --type performanceCapture --duration-ms 5000 --wait-ms 8000',' analyze: bun scripts/cli.ts web-probe observe analyze '+(manifest.jobId||'<observer>'),'DISCLOSURE source=existing artifacts valuesRedacted=true; this view does not start browser/probe or mutate runtime.');
return lines.join('\\n');
}
@@ -816,7 +890,7 @@ function renderProjectSummary(project){
const rows=turnSummaryRows();
if(view==='performance-summary'){
const perf=performanceSummaryFromReport();
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,summary:perf.summary,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,renderedText:renderPerformanceSummary(perf),sourceFiles:['performance-events.jsonl','artifacts.jsonl','analysis/report.json'],drillDown:'bun scripts/cli.ts web-probe observe collect '+String(manifest.jobId||'<observer>')+' --view files --file analysis/report.json',valuesRedacted:true}));
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,summary:perf.summary,evidenceMode:perf.evidenceMode,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,renderedText:renderPerformanceSummary(perf),sourceFiles:['performance-events.jsonl','artifacts.jsonl','analysis/report.json','commands/pending/*.json','commands/processing/*.json','heartbeat.json','manifest.json'],drillDown:'bun scripts/cli.ts web-probe observe collect '+String(manifest.jobId||'<observer>')+' --view files --file analysis/report.json',valuesRedacted:true}));
process.exit(0);
}
if(view==='project-summary'||view==='project-mdtodo-summary'){
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, 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 { nodeWebObserveCollectViewNodeScript } from "../hwlab-node-web-observe-collect";
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
test("performance-summary labels LoAF-only evidence when CPU profile capture is pending", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-"));
await mkdir(join(stateDir, "analysis"), { recursive: true });
await mkdir(join(stateDir, "commands", "pending"), { recursive: true });
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-perf-test", status: "failed" }) + "\n");
await writeFile(join(stateDir, "heartbeat.json"), JSON.stringify({ status: "failed", updatedAt: "2026-07-02T12:08:06Z" }) + "\n");
await writeFile(join(stateDir, "performance-events.jsonl"), [
JSON.stringify({
type: "performance-event",
ts: "2026-07-02T12:07:00Z",
performance: { kind: "long-animation-frame", duration: 2083.8 },
}),
].join("\n") + "\n");
await writeFile(join(stateDir, "commands", "pending", "cmd-perf.json"), JSON.stringify({
id: "cmd-perf",
type: "performanceCapture",
createdAt: "2026-07-02T12:08:00Z",
}) + "\n");
await writeFile(join(stateDir, "analysis", "report.json"), JSON.stringify({
manifest: { status: "failed" },
heartbeat: { status: "failed", updatedAt: "2026-07-02T12:08:06Z" },
frontendPerformance: {
summary: {
eventCount: 1,
longTaskCount: 0,
longAnimationFrameCount: 1,
eventLoopGapCount: 0,
captureCount: 0,
scriptHotspotCount: 1,
maxLongAnimationFrameMs: 2083.8,
longAnimationFrameRedMs: 200,
cpuProfileStatus: "missing",
attributionMode: "loaf-only-no-cpu-profile",
noCpuProfile: true,
loafOnly: true,
valuesRedacted: true,
},
scriptHotspots: [{
sourceFunctionName: "Response.json.then",
sourceURL: "https://hwlab.example.test/app.js",
totalDurationMs: 2108,
count: 2,
valuesRedacted: true,
}],
profileHotspots: [],
profileStacks: [],
captures: [],
longAnimationFrames: [{ ts: "2026-07-02T12:07:00Z", kind: "long-animation-frame", durationMs: 2083.8, sampleSeq: 42, pageRole: "control", scriptCount: 1 }],
},
findings: [{
id: "frontend-performance-loaf-only-no-cpu-profile",
severity: "amber",
summary: "frontend performance evidence is LoAF/LongTask/event-loop only because no completed CPU profile capture is present",
count: 1,
}, {
id: "tool-pending-commands-unconsumed",
severity: "red",
summary: "web-probe observe has pending/processing control commands that were not consumed by the runner",
count: 1,
}],
}) + "\n");
const script = nodeWebObserveCollectViewNodeScript({
maxFiles: 100,
view: "performance-summary",
traceId: null,
sampleSeq: null,
timestamp: null,
turn: null,
commandId: null,
windowMs: null,
});
const result = spawnSync("bash", ["-lc", `state_dir=${shellQuote(stateDir)}\n${script}`], {
cwd: join(import.meta.dir, "../../.."),
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const output = JSON.parse(result.stdout);
const text = String(output.renderedText ?? "");
const debug = JSON.stringify({ summary: output.summary, evidenceMode: output.evidenceMode, text }, null, 2);
assert.equal(output.evidenceMode.attributionMode, "loaf-only-no-cpu-profile", debug);
assert.equal(output.evidenceMode.cpuProfileStatus, "pending-command-no-cpu-profile");
assert.equal(output.evidenceMode.pendingPerformanceCapture, true);
assert.match(text, /LoAF-only \/ no CPU profile/u);
assert.match(text, /pending performanceCapture: cmd-perf\(pending\)/u);
assert.match(text, /runner state: not-running\/failed/u);
assert.match(text, /no CPU profile hotspots; no completed performanceCapture artifact/u);
}, 20_000);