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 f003b5c0..0e7caaba 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-performance-source.ts @@ -3,8 +3,10 @@ export function nodeWebObserveAnalyzerPerformanceSource(): string { return String.raw` -function buildFrontendPerformanceReport(rows, artifacts) { +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 events = []; const drainErrors = []; const captures = []; @@ -46,7 +48,9 @@ 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 }); + const performanceWindows = buildFrontendPerformanceWindows({ events, captures, profileHotspots, profileStacks, scriptHotspots, samples: sourceSamples, network: sourceNetwork }); + const sourceAttribution = buildFrontendSourceAttribution({ scriptHotspots, profileHotspots, profileStacks }); + const attribution = frontendPerformanceAttributionStatus({ captures, profileHotspots, profileStacks, scriptHotspots, longTasks, loafs, gaps, performanceWindows }); return { summary: { rowCount: sourceRows.length, @@ -70,6 +74,12 @@ function buildFrontendPerformanceReport(rows, artifacts) { loafOnly: attribution.loafOnly, cpuProfileHotspotEvidence: attribution.cpuProfileHotspotEvidence, evidenceNote: attribution.evidenceNote, + windowCorrelationCount: performanceWindows.length, + cpuProfileWindowCoveredCount: performanceWindows.filter((item) => item.cpuProfileCoverageStatus === "covered").length, + cpuProfileWindowOverlappedCount: performanceWindows.filter((item) => item.cpuProfileCoverageStatus === "overlapped").length, + cpuProfileWindowMissedCount: performanceWindows.filter((item) => item.cpuProfileCoverageStatus === "missed").length, + cpuProfileWindowMissingCount: performanceWindows.filter((item) => item.cpuProfileCoverageStatus === "no-cpu-profile").length, + sourceMapStatus: sourceAttribution.sourceMapStatus, captureArtifacts: performanceCaptureArtifacts(artifacts), valuesRedacted: true, }, @@ -79,6 +89,8 @@ function buildFrontendPerformanceReport(rows, artifacts) { scriptHotspots, profileHotspots, profileStacks, + performanceWindows, + sourceAttribution, captures: captures.slice(-20), drainErrors: drainErrors.slice(-20), valuesRedacted: true, @@ -142,6 +154,7 @@ function buildFrontendPerformanceFindings(report) { attributionMode: summary.attributionMode || "loaf-only-no-cpu-profile", cpuProfileStatus: summary.cpuProfileStatus || "missing", captureCount: summary.captureCount ?? 0, + windowCorrelations: (report?.performanceWindows || []).slice(0, 8), longTaskCount: summary.longTaskCount ?? 0, longAnimationFrameCount: summary.longAnimationFrameCount ?? 0, eventLoopGapCount: summary.eventLoopGapCount ?? 0, @@ -171,15 +184,20 @@ function frontendPerformanceAttributionStatus(input) { (Array.isArray(input?.gaps) ? input.gaps.length : 0); const hasCpuProfile = captureCount > 0; const hasCpuProfileHotspots = profileHotspotCount > 0 || profileStackCount > 0; + const windowRows = Array.isArray(input?.performanceWindows) ? input.performanceWindows : []; + const coveredWindowCount = windowRows.filter((item) => item?.cpuProfileCoverageStatus === "covered" || item?.cpuProfileCoverageStatus === "overlapped").length; + const missedWindowCount = windowRows.filter((item) => item?.cpuProfileCoverageStatus === "missed").length; if (hasCpuProfile) { return { - cpuProfileStatus: hasCpuProfileHotspots ? "captured-with-hotspots" : "captured-no-hotspots", - attributionMode: "cpu-profile-and-performance-observer", + cpuProfileStatus: hasCpuProfileHotspots ? (coveredWindowCount > 0 ? "captured-with-window-hotspots" : "captured-hotspots-outside-performance-window") : "captured-no-hotspots", + attributionMode: missedWindowCount > 0 && coveredWindowCount === 0 ? "cpu-profile-missed-performance-window" : "cpu-profile-and-performance-observer", noCpuProfile: false, loafOnly: false, cpuProfileHotspotEvidence: hasCpuProfileHotspots, evidenceNote: hasCpuProfileHotspots - ? "completed performanceCapture artifacts produced CPU profile hotspot evidence" + ? coveredWindowCount > 0 + ? "completed performanceCapture artifacts overlap severe frontend performance windows; attached CPU hotspots are same-capture candidates" + : "completed performanceCapture artifacts produced CPU profile hotspots, but none overlap the severe frontend performance windows" : "completed performanceCapture artifacts exist, but no CPU profile hotspots were extracted", valuesRedacted: true, }; @@ -198,6 +216,7 @@ function frontendPerformanceAttributionStatus(input) { } function compactPerformanceEventRow(row, perf) { + const window = performanceEventWindow(row, perf); return { ts: row.ts ?? null, seq: row.seq ?? null, @@ -213,6 +232,13 @@ function compactPerformanceEventRow(row, perf) { blockingDurationMs: numberOrNull(perf.blockingDuration), startTime: numberOrNull(perf.startTime), thresholdMs: numberOrNull(perf.thresholdMs), + timeOrigin: numberOrNull(perf.timeOrigin), + observedAt: numberOrNull(perf.observedAt), + startEpochMs: window.startEpochMs, + endEpochMs: window.endEpochMs, + startAt: window.startAt, + endAt: window.endAt, + windowSource: window.source, path: limitText(perf.path ?? "", 160), url: safeReportUrl(perf.url), scriptCount: Array.isArray(perf.scripts) ? perf.scripts.length : 0, @@ -237,13 +263,21 @@ function compactPerformanceDrainError(row) { function compactPerformanceCapture(row) { const artifact = row.artifact && typeof row.artifact === "object" ? row.artifact : {}; const summary = row.summary && typeof row.summary === "object" ? row.summary : {}; + const durationMs = numberOrNull(artifact.durationMs ?? summary.durationMs); + const endEpochMs = performanceTimestampMs(row.ts); + const startEpochMs = Number.isFinite(endEpochMs) && Number.isFinite(durationMs) ? endEpochMs - Number(durationMs) : null; return { ts: row.ts ?? null, commandId: row.commandId ?? null, captureId: row.captureId ?? artifact.captureId ?? null, pageRole: row.pageRole ?? artifact.pageRole ?? null, pageId: row.pageId ?? artifact.pageId ?? null, - durationMs: numberOrNull(artifact.durationMs), + durationMs, + startEpochMs, + endEpochMs, + startAt: startEpochMs === null ? null : new Date(startEpochMs).toISOString(), + endAt: endEpochMs === null ? null : new Date(endEpochMs).toISOString(), + windowSource: startEpochMs === null ? "missing-capture-window" : "completed-event-ts-minus-artifact-duration", profileTotalTimeMs: numberOrNull(summary.totalTimeMs), profileSampleCount: numberOrNull(summary.sampleCount), path: artifact.path ?? null, @@ -256,13 +290,22 @@ function compactPerformanceCapture(row) { function compactLoafScript(script) { const value = script && typeof script === "object" ? script : {}; + const source = sourceAttributionFor(value.sourceURL, value.sourceFunctionName, value.lineNumber, value.columnNumber, value.sourceCharPosition); return { invoker: limitText(value.invoker ?? "", 160), invokerType: limitText(value.invokerType ?? "", 80), sourceURL: safeReportUrl(value.sourceURL), sourceFunctionName: limitText(value.sourceFunctionName ?? "", 160), + sourceFile: source.sourceFile, + sourceLine: source.sourceLine, + sourceColumn: source.sourceColumn, + sourceCharPosition: numberOrNull(value.sourceCharPosition), + sourceMapStatus: source.sourceMapStatus, + sourceAttributionMode: source.sourceAttributionMode, lineNumber: numberOrNull(value.lineNumber), columnNumber: numberOrNull(value.columnNumber), + startTime: numberOrNull(value.startTime), + executionStart: numberOrNull(value.executionStart), durationMs: numberOrNull(value.duration), forcedStyleAndLayoutDurationMs: numberOrNull(value.forcedStyleAndLayoutDuration), pauseDurationMs: numberOrNull(value.pauseDuration), @@ -272,17 +315,22 @@ function compactLoafScript(script) { function mergeLoafScript(groups, script, event) { const compact = compactLoafScript(script); - const key = [compact.sourceFunctionName || compact.invoker || "(anonymous)", compact.sourceURL || "", compact.lineNumber ?? "", compact.columnNumber ?? ""].join("@"); + const key = loafScriptKey(compact); const group = groups.get(key) || { ...compact, key, count: 0, totalDurationMs: 0, maxDurationMs: 0, firstAt: event.ts, lastAt: event.ts, examples: [], valuesRedacted: true }; const duration = Number(compact.durationMs || 0); group.count += 1; group.totalDurationMs += duration; group.maxDurationMs = Math.max(group.maxDurationMs, duration); group.lastAt = event.ts; - if (group.examples.length < 8) group.examples.push({ ts: event.ts, sampleSeq: event.sampleSeq, durationMs: event.durationMs, scriptDurationMs: compact.durationMs, pageRole: event.pageRole, valuesRedacted: true }); + if (group.examples.length < 8) group.examples.push({ ts: event.ts, startAt: event.startAt, endAt: event.endAt, sampleSeq: event.sampleSeq, durationMs: event.durationMs, scriptDurationMs: compact.durationMs, pageRole: event.pageRole, sourceCharPosition: compact.sourceCharPosition, valuesRedacted: true }); groups.set(key, group); } +function loafScriptKey(script) { + if (!script || typeof script !== "object") return ""; + return [script.sourceFunctionName || script.invoker || "(anonymous)", script.sourceURL || "", script.lineNumber ?? "", script.columnNumber ?? ""].join("@"); +} + function finalizeScriptHotspot(group) { return { ...group, @@ -294,8 +342,9 @@ function finalizeScriptHotspot(group) { function mergeProfileFunction(groups, fn, capture) { const value = fn && typeof fn === "object" ? fn : {}; + const source = sourceAttributionFor(value.url, value.functionName, value.lineNumber, value.columnNumber, null); const key = [value.functionName || "(anonymous)", value.url || value.scriptId || "", value.lineNumber ?? "", value.columnNumber ?? ""].join("@"); - const group = groups.get(key) || { functionName: value.functionName || "(anonymous)", url: safeReportUrl(value.url), scriptId: value.scriptId ?? null, lineNumber: numberOrNull(value.lineNumber), columnNumber: numberOrNull(value.columnNumber), key, captureCount: 0, sampleCount: 0, selfTimeMs: 0, totalTimeMs: 0, maxSelfTimeMs: 0, captures: [], valuesRedacted: true }; + const group = groups.get(key) || { functionName: value.functionName || "(anonymous)", url: safeReportUrl(value.url), scriptId: value.scriptId ?? null, sourceFile: source.sourceFile, sourceLine: source.sourceLine, sourceColumn: source.sourceColumn, sourceMapStatus: source.sourceMapStatus, sourceAttributionMode: source.sourceAttributionMode, lineNumber: numberOrNull(value.lineNumber), columnNumber: numberOrNull(value.columnNumber), key, captureCount: 0, sampleCount: 0, selfTimeMs: 0, totalTimeMs: 0, maxSelfTimeMs: 0, captures: [], valuesRedacted: true }; const selfTime = Number(value.selfTimeMs || 0); const totalTime = Number(value.totalTimeMs || 0); group.captureCount += 1; @@ -321,7 +370,8 @@ function mergeProfileStack(groups, stack, capture) { const value = stack && typeof stack === "object" ? stack : {}; const key = String(value.key || "").slice(0, 800); if (!key) return; - const group = groups.get(key) || { key, sampleCount: 0, selfTimeMs: 0, maxSelfTimeMs: 0, leaf: value.leaf ?? null, frames: Array.isArray(value.frames) ? value.frames.slice(-10) : [], captures: [], valuesRedacted: true }; + const frames = Array.isArray(value.frames) ? value.frames.slice(-10).map(annotateProfileFrameSource) : []; + const group = groups.get(key) || { key, sampleCount: 0, selfTimeMs: 0, maxSelfTimeMs: 0, leaf: annotateProfileFrameSource(value.leaf), frames, captures: [], valuesRedacted: true }; const selfTime = Number(value.selfTimeMs || 0); group.sampleCount += Number(value.sampleCount || 0); group.selfTimeMs += selfTime; @@ -346,6 +396,328 @@ 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 })); } +function buildFrontendPerformanceWindows(input) { + const aggregatedLoafHotspotKeys = new Set( + (Array.isArray(input?.scriptHotspots) ? input.scriptHotspots : []) + .filter((item) => Number(item?.totalDurationMs ?? 0) >= alertThresholds.longAnimationFrameRedMs) + .map(loafScriptKey) + .filter(Boolean) + ); + const events = (Array.isArray(input?.events) ? input.events : []) + .filter((item) => item && (item.kind === "longtask" || item.kind === "long-animation-frame" || item.kind === "event-loop-gap")) + .filter((item) => Number(item.durationMs ?? 0) >= frontendPerformanceWindowBudget(item) || hasAggregatedLoafHotspot(item, aggregatedLoafHotspotKeys)) + .sort(descDuration) + .slice(0, 20); + return events.map((event) => correlateFrontendPerformanceWindow(event, input)).filter(Boolean); +} + +function hasAggregatedLoafHotspot(event, keys) { + if (event?.kind !== "long-animation-frame" || !(keys instanceof Set) || keys.size === 0) return false; + for (const script of Array.isArray(event?.scripts) ? event.scripts : []) { + if (keys.has(loafScriptKey(script))) return true; + } + return false; +} + +function correlateFrontendPerformanceWindow(event, input) { + const captures = Array.isArray(input?.captures) ? input.captures : []; + const matchingCaptures = captures.filter((capture) => samePageContext(event, capture)); + const overlapping = matchingCaptures.filter((capture) => windowsOverlap(event, capture)); + const covering = overlapping.filter((capture) => windowCovers(capture, event)); + const selectedCapture = covering[0] || overlapping[0] || nearestCapture(event, matchingCaptures) || nearestCapture(event, captures); + 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 sourceHint = event.kind === "long-animation-frame" ? topLoafScriptHint(event) : null; + return { + kind: event.kind, + ts: event.ts ?? null, + startAt: event.startAt ?? null, + endAt: event.endAt ?? null, + startEpochMs: event.startEpochMs ?? null, + endEpochMs: event.endEpochMs ?? null, + durationMs: event.durationMs ?? null, + blockingDurationMs: event.blockingDurationMs ?? null, + sampleSeq: event.sampleSeq ?? null, + pageRole: event.pageRole ?? null, + pageId: event.pageId ?? null, + pageEpoch: event.pageEpoch ?? null, + path: event.path ?? null, + url: event.url ?? null, + scriptCount: event.scriptCount ?? null, + topLoafScript: sourceHint, + cpuProfileCoverageStatus: coverageStatus, + coverageReason: cpuProfileCoverageReason(event, selectedCapture, coverageStatus), + capture: selectedCapture ? compactWindowCapture(selectedCapture, event) : null, + relatedNetwork: relatedNetworkRows(event, input?.network), + relatedSamples: relatedSampleRows(event, input?.samples), + profileEvidence, + valuesRedacted: true, + }; +} + +function frontendPerformanceWindowBudget(event) { + if (event?.kind === "longtask") return alertThresholds.longTaskRedMs; + if (event?.kind === "long-animation-frame") return alertThresholds.longAnimationFrameRedMs; + if (event?.kind === "event-loop-gap") return alertThresholds.eventLoopGapRedMs; + return 0; +} + +function performanceEventWindow(row, perf) { + const timeOrigin = Number(perf?.timeOrigin); + const startTime = Number(perf?.startTime); + const duration = Number(perf?.duration); + if (Number.isFinite(timeOrigin) && timeOrigin > 0 && Number.isFinite(startTime)) { + const startEpochMs = timeOrigin + startTime; + const endEpochMs = startEpochMs + (Number.isFinite(duration) ? duration : 0); + return { startEpochMs: roundMs(startEpochMs), endEpochMs: roundMs(endEpochMs), startAt: new Date(startEpochMs).toISOString(), endAt: new Date(endEpochMs).toISOString(), source: "performance-timeOrigin-startTime" }; + } + const observedAt = Number(perf?.observedAt); + if (Number.isFinite(observedAt) && observedAt > 0) { + const endEpochMs = observedAt; + const startEpochMs = endEpochMs - (Number.isFinite(duration) ? duration : 0); + return { startEpochMs: roundMs(startEpochMs), endEpochMs: roundMs(endEpochMs), startAt: new Date(startEpochMs).toISOString(), endAt: new Date(endEpochMs).toISOString(), source: "observedAt-minus-duration" }; + } + const rowMs = performanceTimestampMs(row?.ts); + if (Number.isFinite(rowMs)) { + const endEpochMs = rowMs; + const startEpochMs = endEpochMs - (Number.isFinite(duration) ? duration : 0); + return { startEpochMs: roundMs(startEpochMs), endEpochMs: roundMs(endEpochMs), startAt: new Date(startEpochMs).toISOString(), endAt: new Date(endEpochMs).toISOString(), source: "row-ts-minus-duration" }; + } + return { startEpochMs: null, endEpochMs: null, startAt: null, endAt: null, source: "missing-window" }; +} + +function performanceTimestampMs(value) { + const ms = Date.parse(String(value || "")); + return Number.isFinite(ms) ? ms : null; +} + +function samePageContext(event, capture) { + if (!capture) return false; + if (event.pageRole && capture.pageRole && event.pageRole !== capture.pageRole) return false; + if (event.pageId && capture.pageId && event.pageId !== capture.pageId) return false; + return true; +} + +function windowsOverlap(left, right) { + const leftStart = Number(left?.startEpochMs); + const leftEnd = Number(left?.endEpochMs); + const rightStart = Number(right?.startEpochMs); + const rightEnd = Number(right?.endEpochMs); + if (![leftStart, leftEnd, rightStart, rightEnd].every(Number.isFinite)) return false; + return leftStart <= rightEnd && rightStart <= leftEnd; +} + +function windowCovers(outer, inner) { + const outerStart = Number(outer?.startEpochMs); + const outerEnd = Number(outer?.endEpochMs); + const innerStart = Number(inner?.startEpochMs); + const innerEnd = Number(inner?.endEpochMs); + if (![outerStart, outerEnd, innerStart, innerEnd].every(Number.isFinite)) return false; + return outerStart <= innerStart && outerEnd >= innerEnd; +} + +function nearestCapture(event, captures) { + const rows = Array.isArray(captures) ? captures : []; + const eventStart = Number(event?.startEpochMs); + const eventEnd = Number(event?.endEpochMs); + if (![eventStart, eventEnd].every(Number.isFinite) || rows.length === 0) return null; + return rows + .map((capture) => ({ capture, distanceMs: captureWindowDistanceMs(eventStart, eventEnd, capture) })) + .filter((item) => Number.isFinite(item.distanceMs)) + .sort((left, right) => left.distanceMs - right.distanceMs)[0]?.capture || null; +} + +function captureWindowDistanceMs(eventStart, eventEnd, capture) { + const start = Number(capture?.startEpochMs); + const end = Number(capture?.endEpochMs); + if (![start, end].every(Number.isFinite)) return Number.POSITIVE_INFINITY; + if (eventEnd < start) return start - eventEnd; + if (end < eventStart) return eventStart - end; + return 0; +} + +function cpuProfileCoverageReason(event, capture, status) { + if (status === "no-cpu-profile") return "no completed performanceCapture artifact is present"; + if (!capture) return "no comparable capture window is present"; + if (status === "covered") return "performanceCapture window fully covers this frontend performance event"; + if (status === "overlapped") return "performanceCapture window overlaps this frontend performance event, but does not fully cover it"; + const eventStart = Number(event?.startEpochMs); + const eventEnd = Number(event?.endEpochMs); + const captureStart = Number(capture?.startEpochMs); + const captureEnd = Number(capture?.endEpochMs); + if (Number.isFinite(captureStart) && Number.isFinite(eventEnd) && captureStart > eventEnd) return "nearest performanceCapture started after this frontend performance window ended"; + if (Number.isFinite(captureEnd) && Number.isFinite(eventStart) && captureEnd < eventStart) return "nearest performanceCapture ended before this frontend performance window started"; + return "completed performanceCapture exists but its window could not be matched to this frontend event"; +} + +function compactWindowCapture(capture, event) { + const distanceMs = captureWindowDistanceMs(Number(event?.startEpochMs), Number(event?.endEpochMs), capture); + return { + captureId: capture.captureId ?? null, + commandId: capture.commandId ?? null, + pageRole: capture.pageRole ?? null, + pageId: capture.pageId ?? null, + startAt: capture.startAt ?? null, + endAt: capture.endAt ?? null, + durationMs: capture.durationMs ?? null, + windowDistanceMs: Number.isFinite(distanceMs) ? roundMs(distanceMs) : null, + profileSampleCount: capture.profileSampleCount ?? null, + profileTotalTimeMs: capture.profileTotalTimeMs ?? null, + valuesRedacted: true, + }; +} + +function relatedNetworkRows(event, network) { + const rows = Array.isArray(network) ? network : []; + const start = Number(event?.startEpochMs); + const end = Number(event?.endEpochMs); + const toleranceMs = Math.max(1000, Math.min(10000, Number(event?.durationMs || 0) + 1000)); + if (![start, end].every(Number.isFinite)) return []; + return rows + .filter((item) => { + const ms = performanceTimestampMs(item?.ts); + if (!Number.isFinite(ms)) return false; + return ms >= start - toleranceMs && ms <= end + toleranceMs; + }) + .sort((left, right) => Math.abs(Number(performanceTimestampMs(left?.ts)) - start) - Math.abs(Number(performanceTimestampMs(right?.ts)) - start)) + .slice(0, 8) + .map(compactRelatedNetworkRow); +} + +function compactRelatedNetworkRow(item) { + const url = item?.url ?? item?.request?.url ?? item?.response?.url ?? item?.detail?.url ?? ""; + return { + ts: item?.ts ?? null, + sampleSeq: item?.sampleSeq ?? null, + phase: item?.phase ?? item?.type ?? null, + method: item?.method ?? item?.request?.method ?? null, + status: item?.status ?? item?.response?.status ?? null, + urlPath: urlPathOnly(url), + bodyByteCount: numberOrNull(item?.bodyByteCount ?? item?.response?.bodyByteCount ?? item?.bodySummary?.byteCount ?? item?.bodySummary?.bodyByteCount), + durationMs: numberOrNull(item?.durationMs ?? item?.response?.durationMs), + routeSessionId: item?.routeSessionId ?? item?.sessionId ?? null, + traceId: item?.traceId ?? item?.bodySummary?.traceId ?? null, + valuesRedacted: true, + }; +} + +function relatedSampleRows(event, samples) { + const rows = Array.isArray(samples) ? samples : []; + const start = Number(event?.startEpochMs); + const end = Number(event?.endEpochMs); + const toleranceMs = Math.max(1000, Math.min(10000, Number(event?.durationMs || 0) + 1000)); + if (![start, end].every(Number.isFinite)) return []; + return rows + .filter((item) => { + const ms = performanceTimestampMs(item?.ts); + if (!Number.isFinite(ms)) return false; + return ms >= start - toleranceMs && ms <= end + toleranceMs; + }) + .slice(-6) + .map((sample) => ({ + ts: sample?.ts ?? null, + sampleSeq: sample?.seq ?? sample?.sampleSeq ?? null, + pageRole: sample?.pageRole ?? null, + path: limitText(sample?.path ?? "", 160), + routeSessionId: sample?.routeSessionId ?? null, + activeSessionId: sample?.activeSessionId ?? null, + traceIds: sampleTraceIdsForPerformance(sample).slice(0, 6), + valuesRedacted: true, + })); +} + +function sampleTraceIdsForPerformance(sample) { + const ids = new Set(); + for (const group of [sample?.messages, sample?.traceRows, sample?.turns, sample?.diagnostics]) { + if (!Array.isArray(group)) continue; + for (const item of group) if (item?.traceId) ids.add(String(item.traceId)); + } + if (sample?.traceId) ids.add(String(sample.traceId)); + return Array.from(ids); +} + +function topLoafScriptHint(event) { + const scripts = Array.isArray(event?.scripts) ? event.scripts : []; + return scripts + .slice() + .sort((left, right) => Number(right.durationMs ?? 0) - Number(left.durationMs ?? 0))[0] || null; +} + +function buildFrontendSourceAttribution(input) { + const sourceFiles = new Map(); + const add = (row) => { + const file = row?.sourceFile || sourceFileFromUrl(row?.sourceURL || row?.url); + if (!file) return; + const existing = sourceFiles.get(file) || { sourceFile: file, count: 0, sourceMapStatus: row?.sourceMapStatus || "missing", valuesRedacted: true }; + existing.count += 1; + if (existing.sourceMapStatus !== "mapped") existing.sourceMapStatus = row?.sourceMapStatus || existing.sourceMapStatus; + sourceFiles.set(file, existing); + }; + for (const row of Array.isArray(input?.scriptHotspots) ? input.scriptHotspots : []) add(row); + for (const row of Array.isArray(input?.profileHotspots) ? input.profileHotspots : []) add(row); + for (const stack of Array.isArray(input?.profileStacks) ? input.profileStacks : []) { + for (const frame of Array.isArray(stack?.frames) ? stack.frames : []) add(frame); + } + 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), + valuesRedacted: true, + }; +} + +function annotateProfileFrameSource(frame) { + if (!frame || typeof frame !== "object") return frame ?? null; + const source = sourceAttributionFor(frame.url, frame.functionName, frame.lineNumber, frame.columnNumber, null); + return { ...frame, sourceFile: source.sourceFile, sourceLine: source.sourceLine, sourceColumn: source.sourceColumn, sourceMapStatus: source.sourceMapStatus, sourceAttributionMode: source.sourceAttributionMode, valuesRedacted: true }; +} + +function sourceAttributionFor(url, functionName, lineNumber, columnNumber, sourceCharPosition) { + 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", + valuesRedacted: true, + }; +} + +function sourceFileFromUrl(value) { + const text = String(value || ""); + if (!text) return null; + try { + const parsed = new URL(text, "http://local.invalid"); + const path = parsed.pathname || ""; + const parts = path.split("/").filter(Boolean); + return limitText(parts[parts.length - 1] || path || text, 160); + } catch { + const clean = text.split(/[?#]/u)[0]; + const parts = clean.split("/").filter(Boolean); + return limitText(parts[parts.length - 1] || clean, 160); + } +} + +function urlPathOnly(value) { + const text = String(value || ""); + if (!text) return null; + try { + const parsed = new URL(text, "http://local.invalid"); + return limitText(parsed.pathname || "/", 180); + } catch { + return limitText(text.split("?")[0] || text, 180); + } +} + function frontendPerformanceMaxNumber(items, getter) { const selected = maxByNumber(items, getter); return selected ? numberOrNull(getter(selected)) : null; diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index 3538ed63..e2f0be54 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); +const frontendPerformance = 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 : {}; 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 0a4ee152..0684d501 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 @@ -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); + const frontendPerformance = 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 deded034..5045a60e 100644 --- a/scripts/src/hwlab-node-web-observe-collect.ts +++ b/scripts/src/hwlab-node-web-observe-collect.ts @@ -726,23 +726,28 @@ 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),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),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),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),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 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}; } function compactScriptHotspot(item){ - return {sourceFunctionName:short(item?.sourceFunctionName||item?.functionName||item?.invoker||'(anonymous)',64),sourceURL:short(item?.sourceURL||item?.url||'',88),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),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,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}; + 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,commandId:item?.commandId??null,status:item?.status??null,durationMs:item?.durationMs??item?.profileDurationMs??null,sampleCount:item?.sampleCount??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,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}; } 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}; @@ -812,10 +817,12 @@ 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):[], - scriptHotspots:Array.isArray(perf.scriptHotspots)?perf.scriptHotspots.slice(0,5).map(compactScriptHotspot):[], - profileHotspots:Array.isArray(perf.profileHotspots)?perf.profileHotspots.slice(0,5).map(compactProfileHotspot):[], - profileStacks:Array.isArray(perf.profileStacks)?perf.profileStacks.slice(0,3).map(compactProfileStack):[], + performanceWindows:Array.isArray(perf.performanceWindows)?perf.performanceWindows.slice(0,3).map(compactPerformanceWindow):[], + 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):[], captures:captureRows.slice(-3).map(compactPerfCapture), + sourceAttribution:perf.sourceAttribution&&typeof perf.sourceAttribution==='object'?{sourceMapStatus:perf.sourceAttribution.sourceMapStatus??null,note:short(perf.sourceAttribution.note||'',120),sourceFiles:Array.isArray(perf.sourceAttribution.sourceFiles)?perf.sourceAttribution.sourceFiles.slice(0,4):[],valuesRedacted:true}:null, findings, toolFindings:performanceToolFindings(), valuesRedacted:true @@ -840,26 +847,41 @@ function renderPerformanceSummary(perf){ 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('window correlation='+String(s.windowCorrelationCount??perf.performanceWindows.length??0)+' covered='+String(s.cpuProfileWindowCoveredCount??0)+' overlapped='+String(s.cpuProfileWindowOverlappedCount??0)+' missed='+String(s.cpuProfileWindowMissedCount??0)+' missing='+String(s.cpuProfileWindowMissingCount??0)+' sourceMap='+String(s.sourceMapStatus||perf.sourceAttribution?.sourceMapStatus||'-')); + 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,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??'-')); + 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??'-')); lines.push('','CPU profile stacks'); if(perf.profileStacks.length===0) lines.push('-'); - for(const item of perf.profileStacks.slice(0,5)){ - const frames=Array.isArray(item.frames)?item.frames.slice(0,5).map((frame)=>short(frame.functionName||'(anonymous)',36)+'@'+short(frame.url||frame.scriptId||'-',54)+':'+String(frame.lineNumber??'-')).join(' <- '):'-'; + 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)):'')); } lines.push('','LoAF script hotspots'); if(perf.scriptHotspots.length===0) lines.push('-'); - for(const item of perf.scriptHotspots.slice(0,10)) lines.push(String(item.totalDurationMs??0)+'ms total count='+String(item.count??0)+' '+short(item.sourceFunctionName||item.invoker||'(anonymous)',44)+' '+short(item.sourceURL||'-',92)+' line='+String(item.lineNumber??'-')); + 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||'-')); + 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 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('; ')); + } lines.push('','Longest events'); - for(const item of [...perf.longTasks.slice(0,4),...perf.longAnimationFrames.slice(0,4),...perf.eventLoopGaps.slice(0,4)].sort((a,b)=>Number(b.durationMs??0)-Number(a.durationMs??0)).slice(0,10)) 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)); + 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)); 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)); + for(const item of perf.findings.slice(0,6)) lines.push(String(item.severity||'-')+': '+String(item.id||item.kind||'-')+' count='+String(item.count??'-')+' '+short(item.summary||item.message||'',120)); 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)); + for(const item of (perf.toolFindings||[]).slice(0,6)) lines.push(String(item.severity||'-')+': '+String(item.id||'-')+' count='+String(item.count??'-')+' '+short(item.summary||'',120)); 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'); } @@ -890,7 +912,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,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})); + 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','network.jsonl','samples.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 index c9a73c04..1d200361 100644 --- a/scripts/src/hwlab-node/web-observe-collect-performance.test.ts +++ b/scripts/src/hwlab-node/web-observe-collect-performance.test.ts @@ -51,6 +51,9 @@ test("performance-summary labels LoAF-only evidence when CPU profile capture is scriptHotspots: [{ sourceFunctionName: "Response.json.then", sourceURL: "https://hwlab.example.test/app.js", + sourceFile: "app.js", + sourceCharPosition: 123456, + sourceMapStatus: "missing", totalDurationMs: 2108, count: 2, valuesRedacted: true, @@ -99,4 +102,159 @@ test("performance-summary labels LoAF-only evidence when CPU profile capture is 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); + assert.match(text, /sourceMap=missing/u); + assert.match(text, /char=123456/u); +}, 20_000); + +test("performance-summary renders CPU profile window misses and source attribution context", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-window-")); + await mkdir(join(stateDir, "analysis"), { recursive: true }); + await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-perf-window-test", status: "completed" }) + "\n"); + await writeFile(join(stateDir, "heartbeat.json"), JSON.stringify({ status: "completed", updatedAt: "2026-07-02T12:08:06Z" }) + "\n"); + await writeFile(join(stateDir, "performance-events.jsonl"), ""); + await writeFile(join(stateDir, "analysis", "report.json"), JSON.stringify({ + manifest: { status: "completed" }, + heartbeat: { status: "completed", updatedAt: "2026-07-02T12:08:06Z" }, + frontendPerformance: { + summary: { + eventCount: 2, + longTaskCount: 0, + longAnimationFrameCount: 1, + eventLoopGapCount: 1, + captureCount: 1, + profileHotspotCount: 1, + scriptHotspotCount: 1, + windowCorrelationCount: 1, + cpuProfileWindowCoveredCount: 0, + cpuProfileWindowOverlappedCount: 0, + cpuProfileWindowMissedCount: 1, + cpuProfileWindowMissingCount: 0, + maxLongAnimationFrameMs: 1811, + maxEventLoopGapMs: 4490, + longAnimationFrameRedMs: 200, + eventLoopGapRedMs: 1000, + cpuProfileStatus: "captured-hotspots-outside-performance-window", + attributionMode: "cpu-profile-missed-performance-window", + sourceMapStatus: "missing", + valuesRedacted: true, + }, + performanceWindows: [{ + kind: "long-animation-frame", + ts: "2026-07-02T12:00:00Z", + startAt: "2026-07-02T12:00:00.000Z", + endAt: "2026-07-02T12:00:01.811Z", + durationMs: 1811, + sampleSeq: 7, + pageRole: "control", + pageId: "page-1", + cpuProfileCoverageStatus: "missed", + coverageReason: "nearest performanceCapture started after this frontend performance window ended", + capture: { + captureId: "perf-late", + commandId: "cmd-late", + startAt: "2026-07-02T12:00:10.000Z", + endAt: "2026-07-02T12:00:18.158Z", + durationMs: 8158, + windowDistanceMs: 8189, + profileSampleCount: 812, + valuesRedacted: true, + }, + topLoafScript: { + sourceFunctionName: "Response.json.then", + sourceURL: "https://hwlab.example.test/assets/CodeWorkbenchView.js", + sourceFile: "CodeWorkbenchView.js", + sourceCharPosition: 456789, + lineNumber: 91, + sourceMapStatus: "missing", + totalDurationMs: 1811, + count: 5, + valuesRedacted: true, + }, + relatedSamples: [{ + ts: "2026-07-02T12:00:01Z", + sampleSeq: 7, + pageRole: "control", + activeSessionId: "ses_test", + traceIds: ["trc_test"], + valuesRedacted: true, + }], + relatedNetwork: [{ + ts: "2026-07-02T12:00:00.500Z", + sampleSeq: 7, + phase: "response", + method: "GET", + status: 200, + urlPath: "/v1/workbench/sessions/ses_test/turns", + bodyByteCount: 1048576, + traceId: "trc_test", + valuesRedacted: true, + }], + valuesRedacted: true, + }], + scriptHotspots: [{ + sourceFunctionName: "Response.json.then", + sourceURL: "https://hwlab.example.test/assets/CodeWorkbenchView.js", + sourceFile: "CodeWorkbenchView.js", + sourceCharPosition: 456789, + lineNumber: 91, + sourceMapStatus: "missing", + totalDurationMs: 1811, + count: 5, + valuesRedacted: true, + }], + profileHotspots: [{ + functionName: "(program)", + sourceFile: "CodeWorkbenchView.js", + sourceMapStatus: "missing", + selfTimeMs: 120, + totalTimeMs: 200, + captureCount: 1, + valuesRedacted: true, + }], + profileStacks: [], + captures: [{ + captureId: "perf-late", + commandId: "cmd-late", + startAt: "2026-07-02T12:00:10.000Z", + endAt: "2026-07-02T12:00:18.158Z", + durationMs: 8158, + profileSampleCount: 812, + valuesRedacted: true, + }], + longAnimationFrames: [{ ts: "2026-07-02T12:00:00Z", startAt: "2026-07-02T12:00:00.000Z", endAt: "2026-07-02T12:00:01.811Z", kind: "long-animation-frame", durationMs: 1811, sampleSeq: 7, pageRole: "control", scriptCount: 1 }], + sourceAttribution: { + 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: [{ sourceFile: "CodeWorkbenchView.js", count: 2, sourceMapStatus: "missing", valuesRedacted: true }], + valuesRedacted: true, + }, + }, + findings: [], + }) + "\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 ?? ""); + assert.equal(output.summary.cpuProfileWindowMissedCount, 1); + assert.match(text, /window correlation=1 covered=0 overlapped=0 missed=1 missing=0 sourceMap=missing/u); + assert.match(text, /cpuWindow=missed/u); + assert.match(text, /nearest performanceCapture started after/u); + assert.match(text, /CodeWorkbenchView\.js/u); + assert.match(text, /bytes=1048576/u); + assert.match(text, /session=ses_test trace=trc_test/u); }, 20_000);