From 225e3e68365262c661644c7b7abdb730c8527938 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 2 Jul 2026 15:41:39 +0000 Subject: [PATCH] fix(web-probe): correlate CPU samples with performance windows --- ...web-observe-analyzer-performance-source.ts | 465 +++++++++++++++++- .../hwlab-node-web-observe-analyzer-source.ts | 4 +- ...web-observe-analyzer-window-page-source.ts | 4 +- scripts/src/hwlab-node-web-observe-collect.ts | 50 +- .../web-observe-analyzer-performance.test.ts | 233 +++++++++ 5 files changed, 725 insertions(+), 31 deletions(-) create mode 100644 scripts/src/hwlab-node/web-observe-analyzer-performance.test.ts 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 0e7caaba..344e86e6 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts @@ -3,10 +3,14 @@ export function nodeWebObserveAnalyzerPerformanceSource(): string { return String.raw` -function buildFrontendPerformanceReport(rows, artifacts, samples, network) { +var frontendPerformanceSourceMapper = null; + +async function buildFrontendPerformanceReport(rows, artifacts, samples, network) { const sourceRows = Array.isArray(rows) ? rows : []; const sourceSamples = Array.isArray(samples) ? samples : []; const sourceNetwork = Array.isArray(network) ? network : []; + const sourceMapState = await buildFrontendPerformanceSourceMapState(artifacts); + frontendPerformanceSourceMapper = createFrontendPerformanceSourceMapper(sourceMapState.maps); const events = []; const drainErrors = []; const captures = []; @@ -33,6 +37,7 @@ function buildFrontendPerformanceReport(rows, artifacts, samples, network) { events.push(event); for (const script of Array.isArray(perf.scripts) ? perf.scripts : []) mergeLoafScript(scriptGroups, script, event); } + await attachCpuProfileTimelines(captures); const longTasks = events.filter((item) => item.kind === "longtask"); const loafs = events.filter((item) => item.kind === "long-animation-frame"); const gaps = events.filter((item) => item.kind === "event-loop-gap"); @@ -49,7 +54,7 @@ function buildFrontendPerformanceReport(rows, artifacts, samples, network) { .sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0)) .slice(0, 30); const performanceWindows = buildFrontendPerformanceWindows({ events, captures, profileHotspots, profileStacks, scriptHotspots, samples: sourceSamples, network: sourceNetwork }); - const sourceAttribution = buildFrontendSourceAttribution({ scriptHotspots, profileHotspots, profileStacks }); + const sourceAttribution = buildFrontendSourceAttribution({ scriptHotspots, profileHotspots, profileStacks, sourceMapState }); const attribution = frontendPerformanceAttributionStatus({ captures, profileHotspots, profileStacks, scriptHotspots, longTasks, loafs, gaps, performanceWindows }); return { summary: { @@ -280,6 +285,12 @@ function compactPerformanceCapture(row) { windowSource: startEpochMs === null ? "missing-capture-window" : "completed-event-ts-minus-artifact-duration", profileTotalTimeMs: numberOrNull(summary.totalTimeMs), profileSampleCount: numberOrNull(summary.sampleCount), + profileTimelineStatus: "not-loaded", + profileTimelineSampleCount: null, + profileTimelineTotalTimeMs: null, + profileTimelineStartAt: null, + profileTimelineEndAt: null, + profileTimeline: null, path: artifact.path ?? null, summaryPath: artifact.summaryPath ?? null, sha256: artifact.sha256 ?? null, @@ -396,6 +407,225 @@ function performanceCaptureArtifacts(artifacts) { .map((item) => ({ ts: item.ts ?? null, captureId: item.captureId ?? null, commandId: item.commandId ?? null, path: item.path ?? null, summaryPath: item.summaryPath ?? null, sha256: item.sha256 ?? null, summarySha256: item.summarySha256 ?? null, valuesRedacted: true })); } +async function attachCpuProfileTimelines(captures) { + for (const capture of Array.isArray(captures) ? captures : []) { + const profilePath = typeof capture?.path === "string" && capture.path ? capture.path : null; + if (!profilePath) { + capture.profileTimelineStatus = "missing-profile-path"; + continue; + } + const profile = await readJson(profilePath); + const timeline = buildCpuProfileSampleTimeline(profile, capture); + capture.profileTimelineStatus = timeline.status; + capture.profileTimelineSampleCount = timeline.sampleCount; + capture.profileTimelineTotalTimeMs = timeline.totalTimeMs; + capture.profileTimelineStartAt = timeline.startAt; + capture.profileTimelineEndAt = timeline.endAt; + capture.profileTimeline = timeline.samples; + } +} + +function buildCpuProfileSampleTimeline(profile, capture) { + if (!profile || typeof profile !== "object") return emptyCpuProfileTimeline("profile-read-failed"); + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const sampleIds = Array.isArray(profile.samples) ? profile.samples : []; + if (nodes.length === 0 || sampleIds.length === 0) return emptyCpuProfileTimeline("profile-has-no-samples"); + const byId = new Map(); + const parentById = new Map(); + for (const node of nodes) { + const id = Number(node?.id); + if (!Number.isFinite(id)) continue; + byId.set(id, node); + for (const childId of Array.isArray(node?.children) ? node.children : []) { + const child = Number(childId); + if (Number.isFinite(child)) parentById.set(child, id); + } + } + const profileStart = Number(profile.startTime); + const profileEnd = Number(profile.endTime); + const profileDurationMs = Number.isFinite(profileStart) && Number.isFinite(profileEnd) && profileEnd > profileStart ? (profileEnd - profileStart) / 1000 : null; + const captureStart = Number(capture?.startEpochMs); + const captureEnd = Number(capture?.endEpochMs); + const baseEpochMs = Number.isFinite(captureStart) + ? captureStart + : Number.isFinite(captureEnd) && Number.isFinite(profileDurationMs) + ? captureEnd - Number(profileDurationMs) + : null; + if (!Number.isFinite(baseEpochMs)) return emptyCpuProfileTimeline("missing-capture-timebase"); + const deltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : []; + const fallbackDeltaMs = Number.isFinite(profileDurationMs) && sampleIds.length > 0 ? Number(profileDurationMs) / sampleIds.length : 0; + const samples = []; + let cursor = Number(baseEpochMs); + for (let index = 0; index < sampleIds.length; index += 1) { + const nodeId = Number(sampleIds[index]); + const rawDelta = Number(deltas[index]); + const durationMs = Number.isFinite(rawDelta) && rawDelta > 0 ? rawDelta / 1000 : fallbackDeltaMs; + const startEpochMs = cursor; + const endEpochMs = cursor + (Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0); + cursor = endEpochMs; + const frames = cpuProfileFramesForNode(nodeId, byId, parentById).map(annotateProfileFrameSource).filter(Boolean); + samples.push({ + index, + nodeId: Number.isFinite(nodeId) ? nodeId : null, + startEpochMs: roundMs(startEpochMs), + endEpochMs: roundMs(endEpochMs), + durationMs: roundMs(endEpochMs - startEpochMs), + leaf: frames[frames.length - 1] || null, + frames, + valuesRedacted: true, + }); + } + const totalTimeMs = samples.reduce((sum, sample) => sum + Number(sample.durationMs || 0), 0); + const startAt = samples.length > 0 ? new Date(Number(samples[0].startEpochMs)).toISOString() : null; + const endAt = samples.length > 0 ? new Date(Number(samples[samples.length - 1].endEpochMs)).toISOString() : null; + return { + status: "loaded", + sampleCount: samples.length, + totalTimeMs: roundMs(totalTimeMs), + startAt, + endAt, + samples, + valuesRedacted: true, + }; +} + +function emptyCpuProfileTimeline(status) { + return { status, sampleCount: 0, totalTimeMs: null, startAt: null, endAt: null, samples: [], valuesRedacted: true }; +} + +function cpuProfileFramesForNode(nodeId, byId, parentById) { + const frames = []; + const seen = new Set(); + let current = nodeId; + while (Number.isFinite(current) && byId.has(current) && !seen.has(current)) { + seen.add(current); + const node = byId.get(current); + const callFrame = node?.callFrame && typeof node.callFrame === "object" ? node.callFrame : {}; + frames.push(compactCpuProfileTimelineFrame(callFrame, node)); + current = parentById.get(current); + } + return frames.reverse(); +} + +function compactCpuProfileTimelineFrame(callFrame, node) { + return { + functionName: limitText(callFrame?.functionName || "(anonymous)", 160), + url: safeReportUrl(callFrame?.url || ""), + scriptId: callFrame?.scriptId ?? node?.callFrame?.scriptId ?? null, + lineNumber: numberOrNull(callFrame?.lineNumber), + columnNumber: numberOrNull(callFrame?.columnNumber), + valuesRedacted: true, + }; +} + +function sameWindowProfileEvidence(event, capture, input) { + const timeline = Array.isArray(capture?.profileTimeline) ? capture.profileTimeline : []; + const eventStart = Number(event?.startEpochMs); + const eventEnd = Number(event?.endEpochMs); + if (!capture) return null; + if (timeline.length === 0 || ![eventStart, eventEnd].every(Number.isFinite)) { + return { + captureId: capture.captureId ?? null, + commandId: capture.commandId ?? null, + evidenceScope: "same-capture-aggregate-context", + timelineStatus: capture.profileTimelineStatus || "missing-profile-timeline", + topFunctions: (Array.isArray(input?.profileHotspots) ? input.profileHotspots : []).slice(0, 5), + topStacks: (Array.isArray(input?.profileStacks) ? input.profileStacks : []).slice(0, 3), + note: "CPU profile sample timeline is unavailable; hotspot rows are capture-level context, not same-window evidence.", + valuesRedacted: true, + }; + } + const functionGroups = new Map(); + const stackGroups = new Map(); + let sampleCount = 0; + let totalTimeMs = 0; + for (const sample of timeline) { + const start = Number(sample?.startEpochMs); + const end = Number(sample?.endEpochMs); + if (![start, end].every(Number.isFinite)) continue; + const overlapMs = Math.max(0, Math.min(end, eventEnd) - Math.max(start, eventStart)); + if (overlapMs <= 0) continue; + sampleCount += 1; + totalTimeMs += overlapMs; + const frames = Array.isArray(sample?.frames) ? sample.frames : []; + const leaf = sample?.leaf || frames[frames.length - 1] || null; + mergeWindowProfileFunction(functionGroups, leaf, overlapMs, overlapMs); + for (const frame of frames) mergeWindowProfileFunction(functionGroups, frame, 0, overlapMs); + mergeWindowProfileStack(stackGroups, frames, leaf, overlapMs); + } + const topFunctions = Array.from(functionGroups.values()) + .map(finalizeWindowProfileFunction) + .sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0) || Number(right.totalTimeMs ?? 0) - Number(left.totalTimeMs ?? 0)) + .slice(0, 8); + const topStacks = Array.from(stackGroups.values()) + .map(finalizeWindowProfileStack) + .sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0)) + .slice(0, 5); + return { + captureId: capture.captureId ?? null, + commandId: capture.commandId ?? null, + evidenceScope: "same-window-cpu-samples", + timelineStatus: sampleCount > 0 ? "window-samples" : "no-window-samples", + sampleCount, + totalTimeMs: roundMs(totalTimeMs), + windowStartAt: Number.isFinite(eventStart) ? new Date(eventStart).toISOString() : null, + windowEndAt: Number.isFinite(eventEnd) ? new Date(eventEnd).toISOString() : null, + topFunctions, + topStacks, + valuesRedacted: true, + }; +} + +function mergeWindowProfileFunction(groups, frame, selfTimeMs, totalTimeMs) { + if (!frame || typeof frame !== "object") return; + const source = frame.sourceMapStatus ? { + sourceFile: frame.sourceFile ?? sourceFileFromUrl(frame.url), + sourceLine: frame.sourceLine ?? numberOrNull(frame.lineNumber), + sourceColumn: frame.sourceColumn ?? numberOrNull(frame.columnNumber), + sourceMapStatus: frame.sourceMapStatus, + sourceAttributionMode: frame.sourceAttributionMode ?? "browser-raw-url-line-column", + } : sourceAttributionFor(frame.url, frame.functionName, frame.lineNumber, frame.columnNumber, null); + const key = [frame.functionName || "(anonymous)", frame.url || frame.scriptId || "", frame.lineNumber ?? "", frame.columnNumber ?? ""].join("@"); + const group = groups.get(key) || { + functionName: limitText(frame.functionName || "(anonymous)", 160), + url: safeReportUrl(frame.url || ""), + scriptId: frame.scriptId ?? null, + sourceFile: source.sourceFile, + sourceLine: source.sourceLine, + sourceColumn: source.sourceColumn, + sourceMapStatus: source.sourceMapStatus, + sourceAttributionMode: source.sourceAttributionMode, + lineNumber: numberOrNull(frame.lineNumber), + columnNumber: numberOrNull(frame.columnNumber), + sampleCount: 0, + selfTimeMs: 0, + totalTimeMs: 0, + valuesRedacted: true, + }; + group.sampleCount += selfTimeMs > 0 ? 1 : 0; + group.selfTimeMs += Number(selfTimeMs || 0); + group.totalTimeMs += Number(totalTimeMs || 0); + groups.set(key, group); +} + +function finalizeWindowProfileFunction(group) { + return { ...group, selfTimeMs: roundMs(group.selfTimeMs), totalTimeMs: roundMs(group.totalTimeMs), valuesRedacted: true }; +} + +function mergeWindowProfileStack(groups, frames, leaf, selfTimeMs) { + const stackFrames = Array.isArray(frames) ? frames.filter(Boolean) : []; + if (stackFrames.length === 0) return; + const key = stackFrames.map((frame) => [frame.functionName || "(anonymous)", frame.url || frame.scriptId || "", frame.lineNumber ?? "", frame.columnNumber ?? ""].join("@")).join(" <- "); + const group = groups.get(key) || { key, leaf: annotateProfileFrameSource(leaf), frames: stackFrames.slice(-10).map(annotateProfileFrameSource), sampleCount: 0, selfTimeMs: 0, valuesRedacted: true }; + group.sampleCount += 1; + group.selfTimeMs += Number(selfTimeMs || 0); + groups.set(key, group); +} + +function finalizeWindowProfileStack(group) { + return { ...group, selfTimeMs: roundMs(group.selfTimeMs), valuesRedacted: true }; +} + function buildFrontendPerformanceWindows(input) { const aggregatedLoafHotspotKeys = new Set( (Array.isArray(input?.scriptHotspots) ? input.scriptHotspots : []) @@ -428,12 +658,9 @@ function correlateFrontendPerformanceWindow(event, input) { const coverageStatus = selectedCapture ? covering.length > 0 ? "covered" : overlapping.length > 0 ? "overlapped" : "missed" : "no-cpu-profile"; - const profileEvidence = coverageStatus === "covered" || coverageStatus === "overlapped" ? { - topFunctions: (Array.isArray(input?.profileHotspots) ? input.profileHotspots : []).slice(0, 5), - topStacks: (Array.isArray(input?.profileStacks) ? input.profileStacks : []).slice(0, 3), - evidenceScope: "same-capture-window-candidate", - valuesRedacted: true, - } : null; + const profileEvidence = coverageStatus === "covered" || coverageStatus === "overlapped" + ? sameWindowProfileEvidence(event, selectedCapture, input) + : null; const sourceHint = event.kind === "long-animation-frame" ? topLoafScriptHint(event) : null; return { kind: event.kind, @@ -570,6 +797,11 @@ function compactWindowCapture(capture, event) { windowDistanceMs: Number.isFinite(distanceMs) ? roundMs(distanceMs) : null, profileSampleCount: capture.profileSampleCount ?? null, profileTotalTimeMs: capture.profileTotalTimeMs ?? null, + profileTimelineStatus: capture.profileTimelineStatus ?? null, + profileTimelineSampleCount: capture.profileTimelineSampleCount ?? null, + profileTimelineTotalTimeMs: capture.profileTimelineTotalTimeMs ?? null, + profileTimelineStartAt: capture.profileTimelineStartAt ?? null, + profileTimelineEndAt: capture.profileTimelineEndAt ?? null, valuesRedacted: true, }; } @@ -650,6 +882,183 @@ function topLoafScriptHint(event) { .sort((left, right) => Number(right.durationMs ?? 0) - Number(left.durationMs ?? 0))[0] || null; } +async function buildFrontendPerformanceSourceMapState(artifacts) { + const candidates = sourceMapArtifactCandidates(artifacts); + const maps = []; + let failedCount = 0; + for (const candidate of candidates) { + const parsed = await readJson(candidate.path); + const map = parseFrontendSourceMap(parsed, candidate); + if (map) maps.push(map); + else failedCount += 1; + } + return { artifactCount: candidates.length, loadedCount: maps.length, failedCount, maps, valuesRedacted: true }; +} + +function sourceMapArtifactCandidates(artifacts) { + const rows = []; + const seen = new Set(); + for (const item of Array.isArray(artifacts) ? artifacts : []) { + if (!item || typeof item !== "object") continue; + const paths = [item.path, item.sourceMapPath, item.mapPath, item.summaryPath].filter((value) => typeof value === "string" && value); + for (const candidatePath of paths) { + const kind = String(item.kind || item.type || "").toLowerCase(); + const url = String(item.url || item.sourceURL || item.sourceMapUrl || ""); + const pathText = String(candidatePath); + const looksLikeMap = kind.includes("source-map") || kind.includes("sourcemap") || pathText.endsWith(".map") || url.endsWith(".map"); + if (!looksLikeMap || seen.has(pathText)) continue; + seen.add(pathText); + rows.push({ path: pathText, url: url || null, kind: item.kind ?? null, ts: item.ts ?? null, valuesRedacted: true }); + } + } + return rows.slice(-40); +} + +function parseFrontendSourceMap(raw, candidate) { + if (!raw || typeof raw !== "object" || typeof raw.mappings !== "string" || !Array.isArray(raw.sources)) return null; + const parsedMappings = parseSourceMapMappings(raw.mappings, raw.sources, Array.isArray(raw.names) ? raw.names : []); + return { + path: candidate.path, + url: candidate.url ?? null, + file: typeof raw.file === "string" ? raw.file : null, + sources: raw.sources, + names: Array.isArray(raw.names) ? raw.names : [], + lines: parsedMappings.lines, + mappingCount: parsedMappings.mappingCount, + valuesRedacted: true, + }; +} + +function parseSourceMapMappings(mappings, sources, names) { + const lines = []; + let sourceIndex = 0; + let originalLine = 0; + let originalColumn = 0; + let nameIndex = 0; + const rawLines = String(mappings || "").split(";"); + for (let generatedLine = 0; generatedLine < rawLines.length; generatedLine += 1) { + let generatedColumn = 0; + const segments = []; + for (const segmentText of rawLines[generatedLine].split(",")) { + if (!segmentText) continue; + const values = decodeSourceMapVlqSegment(segmentText); + if (values.length === 0) continue; + generatedColumn += Number(values[0] || 0); + if (values.length >= 4) { + sourceIndex += Number(values[1] || 0); + originalLine += Number(values[2] || 0); + originalColumn += Number(values[3] || 0); + if (values.length >= 5) nameIndex += Number(values[4] || 0); + if (sources[sourceIndex]) { + segments.push({ + generatedColumn, + source: String(sources[sourceIndex]), + originalLine, + originalColumn, + name: names[nameIndex] ? String(names[nameIndex]) : null, + valuesRedacted: true, + }); + } + } + } + lines[generatedLine] = segments; + } + return { lines, mappingCount: lines.reduce((sum, line) => sum + (Array.isArray(line) ? line.length : 0), 0), valuesRedacted: true }; +} + +function decodeSourceMapVlqSegment(segmentText) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const values = []; + let value = 0; + let shift = 0; + for (const char of String(segmentText || "")) { + const digit = chars.indexOf(char); + if (digit < 0) return []; + const continuation = (digit & 32) !== 0; + value += (digit & 31) << shift; + if (continuation) { + shift += 5; + continue; + } + const negative = (value & 1) === 1; + const decoded = value >> 1; + values.push(negative ? -decoded : decoded); + value = 0; + shift = 0; + } + return values; +} + +function createFrontendPerformanceSourceMapper(maps) { + const loadedMaps = Array.isArray(maps) ? maps.filter((item) => item && Array.isArray(item.lines)) : []; + return { + loadedCount: loadedMaps.length, + resolve(url, lineNumber, columnNumber, sourceCharPosition) { + if (loadedMaps.length === 0) return null; + const matching = loadedMaps.filter((map) => sourceMapMatchesUrl(map, url)); + if (matching.length === 0) return { status: "missing", mapped: null }; + const generatedLine = sourceMapGeneratedLine(lineNumber); + const generatedColumn = sourceMapGeneratedColumn(columnNumber, sourceCharPosition); + if (![generatedLine, generatedColumn].every(Number.isFinite)) return { status: "unmapped", mapped: null }; + for (const map of matching) { + const mapped = lookupSourceMapPosition(map, generatedLine, generatedColumn); + if (mapped) return { status: "mapped", mapped, map }; + } + return { status: "unmapped", mapped: null }; + }, + }; +} + +function sourceMapMatchesUrl(map, url) { + const raw = String(url || ""); + if (!raw) return false; + const urlBase = sourceFileFromUrl(raw); + const mapFile = sourceFileFromUrl(map?.file || ""); + const mapPathBase = sourceFileFromUrl(map?.path || ""); + const mapUrlBase = sourceFileFromUrl(map?.url || ""); + const expectedMapBase = urlBase ? urlBase + ".map" : ""; + return Boolean( + (mapFile && urlBase && mapFile === urlBase) || + (mapPathBase && expectedMapBase && mapPathBase === expectedMapBase) || + (mapUrlBase && expectedMapBase && mapUrlBase === expectedMapBase) || + (mapPathBase && urlBase && mapPathBase.replace(/\.map$/u, "") === urlBase) || + (mapUrlBase && urlBase && mapUrlBase.replace(/\.map$/u, "") === urlBase) + ); +} + +function sourceMapGeneratedLine(lineNumber) { + const line = Number(lineNumber); + return Number.isFinite(line) && line >= 0 ? Math.floor(line) : 0; +} + +function sourceMapGeneratedColumn(columnNumber, sourceCharPosition) { + if (sourceCharPosition !== null && sourceCharPosition !== undefined) { + const charPosition = Number(sourceCharPosition); + if (Number.isFinite(charPosition) && charPosition >= 0) return Math.floor(charPosition); + } + const column = Number(columnNumber); + return Number.isFinite(column) && column >= 0 ? Math.floor(column) : null; +} + +function lookupSourceMapPosition(map, generatedLine, generatedColumn) { + const line = Array.isArray(map?.lines?.[generatedLine]) ? map.lines[generatedLine] : []; + let selected = null; + for (const segment of line) { + if (Number(segment?.generatedColumn) <= generatedColumn) selected = segment; + else break; + } + if (!selected) return null; + return { + sourceFile: limitText(selected.source || "", 180), + sourceLine: Number.isFinite(Number(selected.originalLine)) ? Number(selected.originalLine) + 1 : null, + sourceColumn: numberOrNull(selected.originalColumn), + sourceName: selected.name ? limitText(selected.name, 160) : null, + generatedLine, + generatedColumn, + valuesRedacted: true, + }; +} + function buildFrontendSourceAttribution(input) { const sourceFiles = new Map(); const add = (row) => { @@ -665,10 +1074,24 @@ function buildFrontendSourceAttribution(input) { for (const stack of Array.isArray(input?.profileStacks) ? input.profileStacks : []) { for (const frame of Array.isArray(stack?.frames) ? stack.frames : []) add(frame); } + const rows = Array.from(sourceFiles.values()); + const mappedCount = rows.filter((item) => item.sourceMapStatus === "mapped").length; + const unmappedCount = rows.filter((item) => item.sourceMapStatus === "unmapped").length; + const state = input?.sourceMapState && typeof input.sourceMapState === "object" ? input.sourceMapState : {}; + const loadedCount = Number(state.loadedCount ?? 0); + const status = mappedCount > 0 ? "mapped" : loadedCount > 0 || unmappedCount > 0 ? "unmapped" : "missing"; + const note = status === "mapped" + ? "Source-map artifact was loaded; mapped rows include original source file and line while raw browser locations remain present." + : loadedCount > 0 + ? "Source-map artifact was loaded, but observed browser locations did not resolve to original source positions." + : "No source-map artifact is loaded by this analyzer; function/file/line fields are raw browser URLs or CPU profile callFrame locations."; return { - sourceMapStatus: "missing", - note: "No source-map artifact is loaded by this analyzer; function/file/line fields are raw browser URLs or CPU profile callFrame locations.", - sourceFiles: Array.from(sourceFiles.values()).sort((left, right) => Number(right.count ?? 0) - Number(left.count ?? 0)).slice(0, 20), + sourceMapStatus: status, + note, + sourceMapArtifactCount: numberOrNull(state.artifactCount), + sourceMapLoadedCount: numberOrNull(state.loadedCount), + sourceMapFailedCount: numberOrNull(state.failedCount), + sourceFiles: rows.sort((left, right) => Number(right.count ?? 0) - Number(left.count ?? 0)).slice(0, 20), valuesRedacted: true, }; } @@ -680,14 +1103,30 @@ function annotateProfileFrameSource(frame) { } function sourceAttributionFor(url, functionName, lineNumber, columnNumber, sourceCharPosition) { + const resolved = frontendPerformanceSourceMapper?.resolve ? frontendPerformanceSourceMapper.resolve(url, lineNumber, columnNumber, sourceCharPosition) : null; + if (resolved?.status === "mapped" && resolved.mapped) { + return { + functionName: limitText(resolved.mapped.sourceName || functionName || "", 160), + sourceFile: resolved.mapped.sourceFile, + sourceLine: resolved.mapped.sourceLine, + sourceColumn: resolved.mapped.sourceColumn, + sourceCharPosition: numberOrNull(sourceCharPosition), + sourceMapStatus: "mapped", + sourceAttributionMode: "source-map-original-position", + rawSourceFile: sourceFileFromUrl(url), + rawLineNumber: numberOrNull(lineNumber), + rawColumnNumber: numberOrNull(columnNumber), + valuesRedacted: true, + }; + } return { functionName: limitText(functionName ?? "", 160), sourceFile: sourceFileFromUrl(url), sourceLine: numberOrNull(lineNumber), sourceColumn: numberOrNull(columnNumber), sourceCharPosition: numberOrNull(sourceCharPosition), - sourceMapStatus: "missing", - sourceAttributionMode: "browser-raw-url-line-column", + sourceMapStatus: resolved?.status === "unmapped" ? "unmapped" : "missing", + sourceAttributionMode: resolved?.status === "unmapped" ? "source-map-loaded-no-match" : "browser-raw-url-line-column", valuesRedacted: true, }; } diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index e2f0be54..4a0eca17 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-source.ts @@ -71,7 +71,7 @@ const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows); const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors); const apiDomLag = buildApiDomLagReport(samples, network); const browserProcess = buildBrowserProcessReport(browserProcessRows); -const frontendPerformance = buildFrontendPerformanceReport(performanceRows, artifacts, samples, network); +const frontendPerformance = await buildFrontendPerformanceReport(performanceRows, artifacts, samples, network); const projectManagement = buildProjectManagementReport(samples, control, network, pagePerformance, projectManagementConfig); const runnerErrors = errors.slice(-8).map((item) => { const details = item.error?.details && typeof item.error.details === "object" ? item.error.details : {}; @@ -124,7 +124,7 @@ const commandFailures = summarizeCommandFailures(control); const toolFindings = buildToolFindings({ manifest, heartbeat, commandState }); const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess, frontendPerformance)]; if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) }); -const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }); +const recentWindow = await buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }); const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl })); const report = { ok: findings.filter((item) => item.severity === "red").length === 0, diff --git a/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts index 0684d501..4fc07ec8 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts @@ -2,7 +2,7 @@ // Responsibility: Analyzer recent-window, page provenance, and Navigation Timing API performance source fragment. export function nodeWebObserveAnalyzerWindowPageSource(): string { - return String.raw`function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }) { + return String.raw`async function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }) { const latestSampleMs = latestTimestampMs(samples); const windowMs = 5 * 60 * 1000; const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY; @@ -27,7 +27,7 @@ export function nodeWebObserveAnalyzerWindowPageSource(): string { const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors); const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork); const browserProcess = buildBrowserProcessReport(windowBrowserProcessRows); - const frontendPerformance = buildFrontendPerformanceReport(windowPerformanceRows, windowArtifacts, windowSamples, windowNetwork); + const frontendPerformance = await buildFrontendPerformanceReport(windowPerformanceRows, windowArtifacts, windowSamples, windowNetwork); const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess, frontendPerformance); return { summary: { diff --git a/scripts/src/hwlab-node-web-observe-collect.ts b/scripts/src/hwlab-node-web-observe-collect.ts index 07c524f2..8cd57981 100644 --- a/scripts/src/hwlab-node-web-observe-collect.ts +++ b/scripts/src/hwlab-node-web-observe-collect.ts @@ -726,28 +726,29 @@ function targetNodeFromStateDir(){ return index>=0&&parts[index+1]?parts[index+1]:null; } function perfFrameKey(frame){ - return {functionName:short(frame?.functionName||frame?.name||'(anonymous)',48),url:short(frame?.url||frame?.sourceURL||frame?.scriptUrl||'',72),sourceFile:short(frame?.sourceFile||'',48),sourceMapStatus:frame?.sourceMapStatus??null,scriptId:frame?.scriptId??null,lineNumber:frame?.lineNumber??frame?.line??null,columnNumber:frame?.columnNumber??frame?.column??null,valuesRedacted:true}; + return {functionName:short(frame?.functionName||frame?.name||'(anonymous)',48),url:short(frame?.url||frame?.sourceURL||frame?.scriptUrl||'',72),sourceFile:short(frame?.sourceFile||'',48),sourceLine:frame?.sourceLine??null,sourceColumn:frame?.sourceColumn??null,sourceMapStatus:frame?.sourceMapStatus??null,scriptId:frame?.scriptId??null,lineNumber:frame?.lineNumber??frame?.line??null,columnNumber:frame?.columnNumber??frame?.column??null,valuesRedacted:true}; } function compactProfileHotspot(item){ - return {functionName:short(item?.functionName||item?.name||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||'',88),sourceFile:short(item?.sourceFile||'',48),sourceMapStatus:item?.sourceMapStatus??null,scriptId:item?.scriptId??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,captureCount:item?.captureCount??null,valuesRedacted:true}; + return {functionName:short(item?.functionName||item?.name||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||'',88),sourceFile:short(item?.sourceFile||'',48),sourceLine:item?.sourceLine??null,sourceColumn:item?.sourceColumn??null,sourceMapStatus:item?.sourceMapStatus??null,scriptId:item?.scriptId??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,captureCount:item?.captureCount??null,valuesRedacted:true}; } function compactProfileStack(item){ + const leaf=item?.leaf&&typeof item.leaf==='object'?item.leaf:null; const frames=Array.isArray(item?.frames)?item.frames.slice(0,3).map(perfFrameKey):[]; - return {functionName:short(item?.functionName||item?.name||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||'',88),scriptId:item?.scriptId??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,frameCount:Array.isArray(item?.frames)?item.frames.length:null,frames,framesOmitted:Math.max(0,(Array.isArray(item?.frames)?item.frames.length:0)-frames.length),valuesRedacted:true}; + return {functionName:short(item?.functionName||item?.name||leaf?.functionName||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||leaf?.url||'',88),sourceFile:short(item?.sourceFile||leaf?.sourceFile||'',48),sourceLine:item?.sourceLine??leaf?.sourceLine??null,sourceColumn:item?.sourceColumn??leaf?.sourceColumn??null,sourceMapStatus:item?.sourceMapStatus??leaf?.sourceMapStatus??null,scriptId:item?.scriptId??leaf?.scriptId??null,lineNumber:item?.lineNumber??item?.line??leaf?.lineNumber??null,columnNumber:item?.columnNumber??item?.column??leaf?.columnNumber??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,frameCount:Array.isArray(item?.frames)?item.frames.length:null,frames,framesOmitted:Math.max(0,(Array.isArray(item?.frames)?item.frames.length:0)-frames.length),valuesRedacted:true}; } function compactScriptHotspot(item){ - return {sourceFunctionName:short(item?.sourceFunctionName||item?.functionName||item?.invoker||'(anonymous)',64),sourceURL:short(item?.sourceURL||item?.url||'',88),sourceFile:short(item?.sourceFile||'',48),sourceMapStatus:item?.sourceMapStatus??null,sourceCharPosition:item?.sourceCharPosition??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,totalDurationMs:item?.totalDurationMs??item?.durationMs??null,count:item?.count??item?.hitCount??null,invoker:short(item?.invoker||'',64),valuesRedacted:true}; + return {sourceFunctionName:short(item?.sourceFunctionName||item?.functionName||item?.invoker||'(anonymous)',64),sourceURL:short(item?.sourceURL||item?.url||'',88),sourceFile:short(item?.sourceFile||'',48),sourceLine:item?.sourceLine??null,sourceColumn:item?.sourceColumn??null,sourceMapStatus:item?.sourceMapStatus??null,sourceCharPosition:item?.sourceCharPosition??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,totalDurationMs:item?.totalDurationMs??item?.durationMs??null,count:item?.count??item?.hitCount??null,invoker:short(item?.invoker||'',64),valuesRedacted:true}; } function compactPerfEvent(item){ return {ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,kind:item?.kind??null,durationMs:item?.durationMs??null,pageRole:item?.pageRole??null,pageId:item?.pageId??null,sampleSeq:item?.sampleSeq??item?.seq??null,scriptCount:item?.scriptCount??(Array.isArray(item?.scripts)?item.scripts.length:null),url:short(item?.url||item?.sourceURL||'',88),valuesRedacted:true}; } function compactPerfCapture(item){ - return {ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,captureId:item?.captureId??null,commandId:item?.commandId??null,status:item?.status??null,durationMs:item?.durationMs??item?.profileDurationMs??null,sampleCount:item?.sampleCount??item?.profileSampleCount??item?.samples??null,nodeCount:item?.nodeCount??item?.nodes??null,file:short(item?.file||item?.path||item?.relativePath||'',88),valuesRedacted:true}; + return {ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,captureId:item?.captureId??null,commandId:item?.commandId??null,status:item?.status??null,durationMs:item?.durationMs??item?.profileDurationMs??null,sampleCount:item?.sampleCount??item?.profileSampleCount??item?.samples??null,nodeCount:item?.nodeCount??item?.nodes??null,profileTimelineStatus:item?.profileTimelineStatus??null,profileTimelineSampleCount:item?.profileTimelineSampleCount??null,profileTimelineTotalTimeMs:item?.profileTimelineTotalTimeMs??null,file:short(item?.file||item?.path||item?.relativePath||'',88),valuesRedacted:true}; } function compactPerformanceWindow(item){ const capture=item?.capture&&typeof item.capture==='object'?item.capture:null; const script=item?.topLoafScript&&typeof item.topLoafScript==='object'?item.topLoafScript:null; - return {kind:item?.kind??null,ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,durationMs:item?.durationMs??null,blockingDurationMs:item?.blockingDurationMs??null,pageRole:item?.pageRole??null,pageId:item?.pageId??null,sampleSeq:item?.sampleSeq??null,cpuProfileCoverageStatus:item?.cpuProfileCoverageStatus??null,coverageReason:short(item?.coverageReason||'',90),capture:capture?{captureId:capture.captureId??null,commandId:capture.commandId??null,startAt:capture.startAt??null,endAt:capture.endAt??null,durationMs:capture.durationMs??null,windowDistanceMs:capture.windowDistanceMs??null,profileSampleCount:capture.profileSampleCount??null,valuesRedacted:true}:null,topLoafScript:script?compactScriptHotspot(script):null,relatedNetwork:Array.isArray(item?.relatedNetwork)?item.relatedNetwork.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,phase:row?.phase??null,method:row?.method??null,status:row?.status??null,urlPath:short(row?.urlPath||'',56),bodyByteCount:row?.bodyByteCount??null,traceId:short(row?.traceId||'',32),routeSessionId:short(row?.routeSessionId||'',32),valuesRedacted:true})):[],relatedSamples:Array.isArray(item?.relatedSamples)?item.relatedSamples.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,pageRole:row?.pageRole??null,path:short(row?.path||'',40),routeSessionId:short(row?.routeSessionId||'',32),activeSessionId:short(row?.activeSessionId||'',32),traceIds:Array.isArray(row?.traceIds)?row.traceIds.slice(0,1).map((id)=>short(id,32)):[],valuesRedacted:true})):[],profileEvidence:item?.profileEvidence?{evidenceScope:item.profileEvidence.evidenceScope??null,topFunctions:Array.isArray(item.profileEvidence.topFunctions)?item.profileEvidence.topFunctions.slice(0,1).map(compactProfileHotspot):[],valuesRedacted:true}:null,valuesRedacted:true}; + return {kind:item?.kind??null,ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,durationMs:item?.durationMs??null,blockingDurationMs:item?.blockingDurationMs??null,pageRole:item?.pageRole??null,pageId:item?.pageId??null,sampleSeq:item?.sampleSeq??null,cpuProfileCoverageStatus:item?.cpuProfileCoverageStatus??null,coverageReason:short(item?.coverageReason||'',90),capture:capture?{captureId:capture.captureId??null,commandId:capture.commandId??null,startAt:capture.startAt??null,endAt:capture.endAt??null,durationMs:capture.durationMs??null,windowDistanceMs:capture.windowDistanceMs??null,profileSampleCount:capture.profileSampleCount??null,profileTimelineStatus:capture.profileTimelineStatus??null,profileTimelineSampleCount:capture.profileTimelineSampleCount??null,valuesRedacted:true}:null,topLoafScript:script?compactScriptHotspot(script):null,relatedNetwork:Array.isArray(item?.relatedNetwork)?item.relatedNetwork.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,phase:row?.phase??null,method:row?.method??null,status:row?.status??null,urlPath:short(row?.urlPath||'',56),bodyByteCount:row?.bodyByteCount??null,traceId:short(row?.traceId||'',32),routeSessionId:short(row?.routeSessionId||'',32),valuesRedacted:true})):[],relatedSamples:Array.isArray(item?.relatedSamples)?item.relatedSamples.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,pageRole:row?.pageRole??null,path:short(row?.path||'',40),routeSessionId:short(row?.routeSessionId||'',32),activeSessionId:short(row?.activeSessionId||'',32),traceIds:Array.isArray(row?.traceIds)?row.traceIds.slice(0,1).map((id)=>short(id,32)):[],valuesRedacted:true})):[],profileEvidence:item?.profileEvidence?{evidenceScope:item.profileEvidence.evidenceScope??null,timelineStatus:item.profileEvidence.timelineStatus??null,sampleCount:item.profileEvidence.sampleCount??null,totalTimeMs:item.profileEvidence.totalTimeMs??null,note:short(item.profileEvidence.note||'',100),topFunctions:Array.isArray(item.profileEvidence.topFunctions)?item.profileEvidence.topFunctions.slice(0,1).map(compactProfileHotspot):[],topStacks:Array.isArray(item.profileEvidence.topStacks)?item.profileEvidence.topStacks.slice(0,1).map(compactProfileStack):[],valuesRedacted:true}:null,valuesRedacted:true}; } 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}; @@ -807,12 +808,25 @@ function performanceEvidenceMode(perf, commandFiles){ const statement=hasCpuProfile ? cpuProfileMissedPerformanceWindows ? 'CPU profile artifacts exist but missed severe performance windows; do not cite CPU profile hotspots as same-window evidence.' - : 'CPU profile artifacts are present; hotspot rows may be used as CPU profile evidence.' + : missedWindowCount>0&&(coveredWindowCount>0||overlappedWindowCount>0) + ? 'CPU profile artifacts overlap some severe performance windows; cite CPU hotspots as same-window evidence only for rows marked covered/overlapped.' + : '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,cpuProfileMissedPerformanceWindows,runner,performanceCaptureCommands:commands,statement,valuesRedacted:true}; } +function performanceWindowsForSummary(rows){ + const source=Array.isArray(rows)?rows:[]; + const selected=source.slice(0,3); + const hasCovered=selected.some((item)=>item?.cpuProfileCoverageStatus==='covered'||item?.cpuProfileCoverageStatus==='overlapped'); + const covered=hasCovered?null:source.find((item)=>item?.cpuProfileCoverageStatus==='covered'||item?.cpuProfileCoverageStatus==='overlapped'); + if(covered){ + if(selected.length>=3) selected[2]=covered; + else selected.push(covered); + } + return selected.map(compactPerformanceWindow); +} function performanceSummaryFromReport(){ const perf=report.frontendPerformance&&typeof report.frontendPerformance==='object'?report.frontendPerformance:{}; const summary=perf.summary&&typeof perf.summary==='object'?perf.summary:{}; @@ -823,7 +837,7 @@ function performanceSummaryFromReport(){ 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):[], - performanceWindows:Array.isArray(perf.performanceWindows)?perf.performanceWindows.slice(0,3).map(compactPerformanceWindow):[], + performanceWindows:performanceWindowsForSummary(perf.performanceWindows), scriptHotspots:Array.isArray(perf.scriptHotspots)?perf.scriptHotspots.slice(0,4).map(compactScriptHotspot):[], profileHotspots:Array.isArray(perf.profileHotspots)?perf.profileHotspots.slice(0,4).map(compactProfileHotspot):[], profileStacks:Array.isArray(perf.profileStacks)?perf.profileStacks.slice(0,2).map(compactProfileStack):[], @@ -857,28 +871,36 @@ function renderPerformanceSummary(perf){ if(perf.sourceAttribution?.note) lines.push('source attribution: '+perf.sourceAttribution.note); lines.push('','CPU profile hotspots'); 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,4)) lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',40)+' '+short(item.sourceFile||item.url||item.scriptId||'-',70)+' line='+String(item.lineNumber??'-')+' sourceMap='+String(item.sourceMapStatus||'-')+' captures='+String(item.captureCount??'-')); + for(const item of perf.profileHotspots.slice(0,4)) lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',40)+' '+short(item.sourceFile||item.url||item.scriptId||'-',70)+' sourceLine='+String(item.sourceLine??'-')+' rawLine='+String(item.lineNumber??'-')+' sourceMap='+String(item.sourceMapStatus||'-')+' captures='+String(item.captureCount??'-')); lines.push('','CPU profile stacks'); if(perf.profileStacks.length===0) lines.push('-'); for(const item of perf.profileStacks.slice(0,2)){ - const frames=Array.isArray(item.frames)?item.frames.slice(0,3).map((frame)=>short(frame.functionName||'(anonymous)',28)+'@'+short(frame.sourceFile||frame.url||frame.scriptId||'-',36)+':'+String(frame.lineNumber??'-')).join(' <- '):'-'; - lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',44)+' line='+String(item.lineNumber??'-')+' frames='+frames+(item.framesOmitted?(' omitted='+String(item.framesOmitted)):'')); + const frames=Array.isArray(item.frames)?item.frames.slice(0,3).map((frame)=>short(frame.functionName||'(anonymous)',28)+'@'+short(frame.sourceFile||frame.url||frame.scriptId||'-',36)+':'+String(frame.sourceLine??frame.lineNumber??'-')).join(' <- '):'-'; + lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',44)+' sourceLine='+String(item.sourceLine??'-')+' rawLine='+String(item.lineNumber??'-')+' frames='+frames+(item.framesOmitted?(' omitted='+String(item.framesOmitted)):'')); } lines.push('','LoAF script hotspots'); if(perf.scriptHotspots.length===0) lines.push('-'); - for(const item of perf.scriptHotspots.slice(0,4)) lines.push(String(item.totalDurationMs??0)+'ms total count='+String(item.count??0)+' '+short(item.sourceFunctionName||item.invoker||'(anonymous)',40)+' '+short(item.sourceFile||item.sourceURL||'-',70)+' line='+String(item.lineNumber??'-')+' char='+String(item.sourceCharPosition??'-')+' sourceMap='+String(item.sourceMapStatus||'-')); + for(const item of perf.scriptHotspots.slice(0,4)) lines.push(String(item.totalDurationMs??0)+'ms total count='+String(item.count??0)+' '+short(item.sourceFunctionName||item.invoker||'(anonymous)',40)+' '+short(item.sourceFile||item.sourceURL||'-',70)+' sourceLine='+String(item.sourceLine??'-')+' rawLine='+String(item.lineNumber??'-')+' char='+String(item.sourceCharPosition??'-')+' sourceMap='+String(item.sourceMapStatus||'-')); lines.push('','Time-window correlation'); if(!Array.isArray(perf.performanceWindows)||perf.performanceWindows.length===0) lines.push('-'); for(const item of (perf.performanceWindows||[]).slice(0,3)){ const capture=item.capture?String(item.capture.captureId||item.capture.commandId||'-')+' dist='+String(item.capture.windowDistanceMs??'-')+'ms':'-'; - const script=item.topLoafScript?short(item.topLoafScript.sourceFunctionName||item.topLoafScript.invoker||'(anonymous)',40)+'@'+short(item.topLoafScript.sourceFile||item.topLoafScript.sourceURL||'-',48)+':'+String(item.topLoafScript.lineNumber??'-'):'-'; + const script=item.topLoafScript?short(item.topLoafScript.sourceFunctionName||item.topLoafScript.invoker||'(anonymous)',40)+'@'+short(item.topLoafScript.sourceFile||item.topLoafScript.sourceURL||'-',48)+':'+String(item.topLoafScript.sourceLine??item.topLoafScript.lineNumber??'-'):'-'; const samples=(item.relatedSamples||[]).map((row)=>'#'+String(row.sampleSeq??'-')+' session='+String(row.activeSessionId||row.routeSessionId||'-')+' trace='+String((row.traceIds||[])[0]||'-')).join('; '); const network=(item.relatedNetwork||[]).map((row)=>String(row.status??'-')+' '+String(row.method||'-')+' '+short(row.urlPath||'-',44)+' bytes='+String(row.bodyByteCount??'-')).join('; '); lines.push(String(item.startAt||item.ts||'-')+' '+String(item.kind||'-')+' duration='+String(item.durationMs??'-')+'ms sample='+String(item.sampleSeq??'-')+' cpuWindow='+String(item.cpuProfileCoverageStatus||'-')+' capture='+capture); lines.push(' reason='+String(item.coverageReason||'-')+' script='+script); if(samples) lines.push(' samples='+samples); if(network) lines.push(' network='+network); - if(item.profileEvidence?.topFunctions?.length>0) lines.push(' same-capture topFunction='+item.profileEvidence.topFunctions.map((fn)=>String(fn.selfTimeMs??0)+'ms '+short(fn.functionName||'(anonymous)',28)+'@'+short(fn.sourceFile||fn.url||'-',32)).join('; ')); + if(item.profileEvidence?.topFunctions?.length>0){ + const scope=item.profileEvidence.timelineStatus==='window-samples'?'same-window topFunction':'cpu profile context topFunction'; + const suffix=item.profileEvidence.timelineStatus?(' timeline='+String(item.profileEvidence.timelineStatus)+' samples='+String(item.profileEvidence.sampleCount??'-')+' total='+String(item.profileEvidence.totalTimeMs??'-')+'ms'):''; + lines.push(' '+scope+'='+item.profileEvidence.topFunctions.map((fn)=>String(fn.selfTimeMs??0)+'ms self '+String(fn.totalTimeMs??0)+'ms total '+short(fn.functionName||'(anonymous)',28)+'@'+short(fn.sourceFile||fn.url||'-',32)+' sourceMap='+String(fn.sourceMapStatus||'-')).join('; ')+suffix); + } + if(item.profileEvidence?.topStacks?.length>0){ + lines.push(' same-window topStack='+item.profileEvidence.topStacks.map((stack)=>String(stack.selfTimeMs??0)+'ms '+short(stack.functionName||stack.leaf?.functionName||'(anonymous)',28)+' frames='+String(stack.frameCount??'-')).join('; ')); + } + if(item.profileEvidence?.note) lines.push(' cpu profile note='+item.profileEvidence.note); } lines.push('','Longest events'); for(const item of [...perf.longTasks.slice(0,3),...perf.longAnimationFrames.slice(0,3),...perf.eventLoopGaps.slice(0,3)].sort((a,b)=>Number(b.durationMs??0)-Number(a.durationMs??0)).slice(0,6)) lines.push(String(item.ts||'-')+' '+String(item.kind||'-')+' duration='+String(item.durationMs??'-')+'ms role='+String(item.pageRole||'-')+' sample='+String(item.sampleSeq??'-')+' scripts='+String(item.scriptCount??0)); diff --git a/scripts/src/hwlab-node/web-observe-analyzer-performance.test.ts b/scripts/src/hwlab-node/web-observe-analyzer-performance.test.ts new file mode 100644 index 00000000..9c79ccea --- /dev/null +++ b/scripts/src/hwlab-node/web-observe-analyzer-performance.test.ts @@ -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>): Promise { + await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : "")); +} + +async function runAnalyzer(stateDir: string): Promise> { + 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; +} + +async function writeBaseState(options: { withSourceMap: boolean; captureEndAt: string }): Promise { + 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; + const summary = frontendPerformance.summary as Record; + assert.equal(summary.sourceMapStatus, "mapped"); + assert.equal(summary.cpuProfileWindowCoveredCount, 1); + + const scriptHotspot = (frontendPerformance.scriptHotspots as Array>)[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>)[0]; + assert.equal(windowRow.cpuProfileCoverageStatus, "covered"); + const evidence = windowRow.profileEvidence as Record; + 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>)[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; + const summary = frontendPerformance.summary as Record; + 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>)[0]; + assert.equal(scriptHotspot.sourceMapStatus, "missing"); + assert.equal(scriptHotspot.sourceFile, "app.js"); + assert.equal(scriptHotspot.sourceLine, 0); + + const windowRow = (frontendPerformance.performanceWindows as Array>)[0]; + assert.equal(windowRow.cpuProfileCoverageStatus, "missed"); + assert.equal(windowRow.profileEvidence, null); + const capture = windowRow.capture as Record; + assert.equal(capture.profileTimelineStatus, "loaded"); + assert.equal(capture.windowDistanceMs, 1920); +}, 20_000);