diff --git a/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts index 968f619a..f003b5c0 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts @@ -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, diff --git a/scripts/src/hwlab-node-web-observe-collect.ts b/scripts/src/hwlab-node-web-observe-collect.ts index 87772bb4..deded034 100644 --- a/scripts/src/hwlab-node-web-observe-collect.ts +++ b/scripts/src/hwlab-node-web-observe-collect.ts @@ -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||'')+' --type performanceCapture --duration-ms 5000 --wait-ms 8000',' analyze: bun scripts/cli.ts web-probe observe analyze '+(manifest.jobId||''),'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||'')+' --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||'')+' --view files --file analysis/report.json',valuesRedacted:true})); process.exit(0); } if(view==='project-summary'||view==='project-mdtodo-summary'){ diff --git a/scripts/src/hwlab-node/web-observe-collect-performance.test.ts b/scripts/src/hwlab-node/web-observe-collect-performance.test.ts new file mode 100644 index 00000000..c9a73c04 --- /dev/null +++ b/scripts/src/hwlab-node/web-observe-collect-performance.test.ts @@ -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);