feat: add web probe performance hotspot analysis
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
// SPEC: pikasTech/unidesk#1436 WEB-PROBE performance analyzer.
|
||||
// Responsibility: Source string for offline frontend long-task/LoAF/profile hotspot analysis.
|
||||
|
||||
export function nodeWebObserveAnalyzerPerformanceSource(): string {
|
||||
return String.raw`
|
||||
function buildFrontendPerformanceReport(rows, artifacts) {
|
||||
const sourceRows = Array.isArray(rows) ? rows : [];
|
||||
const events = [];
|
||||
const drainErrors = [];
|
||||
const captures = [];
|
||||
const scriptGroups = new Map();
|
||||
const profileFunctionGroups = new Map();
|
||||
const profileStackGroups = new Map();
|
||||
for (const row of sourceRows) {
|
||||
if (!row || typeof row !== "object") continue;
|
||||
if (row.type === "performance-drain-error") {
|
||||
drainErrors.push(compactPerformanceDrainError(row));
|
||||
continue;
|
||||
}
|
||||
if (row.type === "performance-capture-completed") {
|
||||
const capture = compactPerformanceCapture(row);
|
||||
captures.push(capture);
|
||||
for (const fn of Array.isArray(row.summary?.topFunctions) ? row.summary.topFunctions : []) mergeProfileFunction(profileFunctionGroups, fn, capture);
|
||||
for (const stack of Array.isArray(row.summary?.topStacks) ? row.summary.topStacks : []) mergeProfileStack(profileStackGroups, stack, capture);
|
||||
continue;
|
||||
}
|
||||
if (row.type !== "performance-event") continue;
|
||||
const perf = row.performance && typeof row.performance === "object" ? row.performance : {};
|
||||
const event = compactPerformanceEventRow(row, perf);
|
||||
if (!event.kind) continue;
|
||||
events.push(event);
|
||||
for (const script of Array.isArray(perf.scripts) ? perf.scripts : []) mergeLoafScript(scriptGroups, script, event);
|
||||
}
|
||||
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");
|
||||
const scriptHotspots = Array.from(scriptGroups.values())
|
||||
.map(finalizeScriptHotspot)
|
||||
.sort((left, right) => Number(right.totalDurationMs ?? 0) - Number(left.totalDurationMs ?? 0) || Number(right.count ?? 0) - Number(left.count ?? 0))
|
||||
.slice(0, 40);
|
||||
const profileHotspots = Array.from(profileFunctionGroups.values())
|
||||
.map(finalizeProfileFunctionHotspot)
|
||||
.sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0) || Number(right.totalTimeMs ?? 0) - Number(left.totalTimeMs ?? 0))
|
||||
.slice(0, 40);
|
||||
const profileStacks = Array.from(profileStackGroups.values())
|
||||
.map(finalizeProfileStackHotspot)
|
||||
.sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0))
|
||||
.slice(0, 30);
|
||||
return {
|
||||
summary: {
|
||||
rowCount: sourceRows.length,
|
||||
eventCount: events.length,
|
||||
longTaskCount: longTasks.length,
|
||||
longAnimationFrameCount: loafs.length,
|
||||
eventLoopGapCount: gaps.length,
|
||||
drainErrorCount: drainErrors.length,
|
||||
captureCount: captures.length,
|
||||
profileHotspotCount: profileHotspots.length,
|
||||
scriptHotspotCount: scriptHotspots.length,
|
||||
longTaskRedMs: alertThresholds.longTaskRedMs,
|
||||
longAnimationFrameRedMs: alertThresholds.longAnimationFrameRedMs,
|
||||
eventLoopGapRedMs: alertThresholds.eventLoopGapRedMs,
|
||||
maxLongTaskMs: frontendPerformanceMaxNumber(longTasks, (item) => item.durationMs),
|
||||
maxLongAnimationFrameMs: frontendPerformanceMaxNumber(loafs, (item) => item.durationMs),
|
||||
maxEventLoopGapMs: frontendPerformanceMaxNumber(gaps, (item) => item.durationMs),
|
||||
captureArtifacts: performanceCaptureArtifacts(artifacts),
|
||||
valuesRedacted: true,
|
||||
},
|
||||
longTasks: longTasks.sort(descDuration).slice(0, 40),
|
||||
longAnimationFrames: loafs.sort(descDuration).slice(0, 40),
|
||||
eventLoopGaps: gaps.sort(descDuration).slice(0, 40),
|
||||
scriptHotspots,
|
||||
profileHotspots,
|
||||
profileStacks,
|
||||
captures: captures.slice(-20),
|
||||
drainErrors: drainErrors.slice(-20),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFrontendPerformanceFindings(report) {
|
||||
const findings = [];
|
||||
const summary = report?.summary || {};
|
||||
const severeLongTasks = (report?.longTasks || []).filter((item) => Number(item.durationMs ?? 0) >= alertThresholds.longTaskRedMs);
|
||||
if (severeLongTasks.length > 0) findings.push({
|
||||
id: "frontend-longtask-red",
|
||||
severity: "red",
|
||||
summary: "PerformanceObserver captured long tasks over YAML budget; use LoAF scripts or CPU profile hotspots for function attribution",
|
||||
count: severeLongTasks.length,
|
||||
budgetMs: alertThresholds.longTaskRedMs,
|
||||
maxDurationMs: summary.maxLongTaskMs,
|
||||
events: severeLongTasks.slice(0, 20),
|
||||
topScripts: (report?.scriptHotspots || []).slice(0, 8),
|
||||
topProfileFunctions: (report?.profileHotspots || []).slice(0, 8),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
const severeLoafs = (report?.longAnimationFrames || []).filter((item) => Number(item.durationMs ?? 0) >= alertThresholds.longAnimationFrameRedMs || Number(item.blockingDurationMs ?? 0) >= alertThresholds.longAnimationFrameRedMs);
|
||||
if (severeLoafs.length > 0) findings.push({
|
||||
id: "frontend-long-animation-frame-red",
|
||||
severity: "red",
|
||||
summary: "Long Animation Frame entries exceeded YAML budget and include script attribution when Chromium exposes it",
|
||||
count: severeLoafs.length,
|
||||
budgetMs: alertThresholds.longAnimationFrameRedMs,
|
||||
maxDurationMs: summary.maxLongAnimationFrameMs,
|
||||
events: severeLoafs.slice(0, 20),
|
||||
topScripts: (report?.scriptHotspots || []).slice(0, 12),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
const severeGaps = (report?.eventLoopGaps || []).filter((item) => Number(item.durationMs ?? 0) >= alertThresholds.eventLoopGapRedMs);
|
||||
if (severeGaps.length > 0) findings.push({
|
||||
id: "frontend-event-loop-gap-red",
|
||||
severity: "red",
|
||||
summary: "web-probe page-side event loop gap probe observed main-thread stalls over YAML budget",
|
||||
count: severeGaps.length,
|
||||
budgetMs: alertThresholds.eventLoopGapRedMs,
|
||||
maxDurationMs: summary.maxEventLoopGapMs,
|
||||
events: severeGaps.slice(0, 20),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
if ((report?.profileHotspots || []).length > 0) findings.push({
|
||||
id: "frontend-cpu-profile-hotspots",
|
||||
severity: severeLongTasks.length > 0 || severeLoafs.length > 0 || severeGaps.length > 0 ? "red" : "info",
|
||||
summary: "CDP CPU profile capture produced function-level hotspots for offline attribution",
|
||||
count: report.profileHotspots.length,
|
||||
captures: (report?.captures || []).slice(-8),
|
||||
topFunctions: report.profileHotspots.slice(0, 12),
|
||||
topStacks: (report?.profileStacks || []).slice(0, 8),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
if ((report?.drainErrors || []).length > 0) findings.push({
|
||||
id: "frontend-performance-probe-drain-errors",
|
||||
severity: "amber",
|
||||
summary: "page-side performance probe had drain errors; improve probe visibility before relying on absence of long-task events",
|
||||
count: report.drainErrors.length,
|
||||
errors: report.drainErrors.slice(0, 12),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
function compactPerformanceEventRow(row, perf) {
|
||||
return {
|
||||
ts: row.ts ?? null,
|
||||
seq: row.seq ?? null,
|
||||
sampleSeq: row.sampleSeq ?? null,
|
||||
sampleGroupSeq: row.sampleGroupSeq ?? null,
|
||||
commandId: row.commandId ?? null,
|
||||
pageRole: row.pageRole ?? null,
|
||||
pageId: row.pageId ?? null,
|
||||
pageEpoch: numberOrNull(row.pageEpoch),
|
||||
kind: perf.kind ?? null,
|
||||
name: limitText(perf.name ?? "", 120),
|
||||
durationMs: numberOrNull(perf.duration),
|
||||
blockingDurationMs: numberOrNull(perf.blockingDuration),
|
||||
startTime: numberOrNull(perf.startTime),
|
||||
thresholdMs: numberOrNull(perf.thresholdMs),
|
||||
path: limitText(perf.path ?? "", 160),
|
||||
url: safeReportUrl(perf.url),
|
||||
scriptCount: Array.isArray(perf.scripts) ? perf.scripts.length : 0,
|
||||
attributionCount: Array.isArray(perf.attribution) ? perf.attribution.length : 0,
|
||||
scripts: (Array.isArray(perf.scripts) ? perf.scripts : []).slice(0, 8).map(compactLoafScript),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compactPerformanceDrainError(row) {
|
||||
return {
|
||||
ts: row?.ts ?? null,
|
||||
sampleSeq: row?.sampleSeq ?? null,
|
||||
pageRole: row?.pageRole ?? null,
|
||||
pageId: row?.pageId ?? null,
|
||||
reason: row?.drain?.reason ?? row?.reason ?? null,
|
||||
message: limitText(row?.drain?.error?.message ?? row?.error?.message ?? "", 200),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compactPerformanceCapture(row) {
|
||||
const artifact = row.artifact && typeof row.artifact === "object" ? row.artifact : {};
|
||||
const summary = row.summary && typeof row.summary === "object" ? row.summary : {};
|
||||
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),
|
||||
profileTotalTimeMs: numberOrNull(summary.totalTimeMs),
|
||||
profileSampleCount: numberOrNull(summary.sampleCount),
|
||||
path: artifact.path ?? null,
|
||||
summaryPath: artifact.summaryPath ?? null,
|
||||
sha256: artifact.sha256 ?? null,
|
||||
summarySha256: artifact.summarySha256 ?? null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compactLoafScript(script) {
|
||||
const value = script && typeof script === "object" ? script : {};
|
||||
return {
|
||||
invoker: limitText(value.invoker ?? "", 160),
|
||||
invokerType: limitText(value.invokerType ?? "", 80),
|
||||
sourceURL: safeReportUrl(value.sourceURL),
|
||||
sourceFunctionName: limitText(value.sourceFunctionName ?? "", 160),
|
||||
lineNumber: numberOrNull(value.lineNumber),
|
||||
columnNumber: numberOrNull(value.columnNumber),
|
||||
durationMs: numberOrNull(value.duration),
|
||||
forcedStyleAndLayoutDurationMs: numberOrNull(value.forcedStyleAndLayoutDuration),
|
||||
pauseDurationMs: numberOrNull(value.pauseDuration),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLoafScript(groups, script, event) {
|
||||
const compact = compactLoafScript(script);
|
||||
const key = [compact.sourceFunctionName || compact.invoker || "(anonymous)", compact.sourceURL || "", compact.lineNumber ?? "", compact.columnNumber ?? ""].join("@");
|
||||
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 });
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
function finalizeScriptHotspot(group) {
|
||||
return {
|
||||
...group,
|
||||
totalDurationMs: roundMs(group.totalDurationMs),
|
||||
maxDurationMs: roundMs(group.maxDurationMs),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProfileFunction(groups, fn, capture) {
|
||||
const value = fn && typeof fn === "object" ? fn : {};
|
||||
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 selfTime = Number(value.selfTimeMs || 0);
|
||||
const totalTime = Number(value.totalTimeMs || 0);
|
||||
group.captureCount += 1;
|
||||
group.sampleCount += Number(value.sampleCount || 0);
|
||||
group.selfTimeMs += selfTime;
|
||||
group.totalTimeMs += totalTime;
|
||||
group.maxSelfTimeMs = Math.max(group.maxSelfTimeMs, selfTime);
|
||||
if (group.captures.length < 8) group.captures.push({ captureId: capture.captureId, commandId: capture.commandId, selfTimeMs: roundMs(selfTime), totalTimeMs: roundMs(totalTime), valuesRedacted: true });
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
function finalizeProfileFunctionHotspot(group) {
|
||||
return {
|
||||
...group,
|
||||
selfTimeMs: roundMs(group.selfTimeMs),
|
||||
totalTimeMs: roundMs(group.totalTimeMs),
|
||||
maxSelfTimeMs: roundMs(group.maxSelfTimeMs),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
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 selfTime = Number(value.selfTimeMs || 0);
|
||||
group.sampleCount += Number(value.sampleCount || 0);
|
||||
group.selfTimeMs += selfTime;
|
||||
group.maxSelfTimeMs = Math.max(group.maxSelfTimeMs, selfTime);
|
||||
if (group.captures.length < 6) group.captures.push({ captureId: capture.captureId, commandId: capture.commandId, selfTimeMs: roundMs(selfTime), valuesRedacted: true });
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
function finalizeProfileStackHotspot(group) {
|
||||
return {
|
||||
...group,
|
||||
selfTimeMs: roundMs(group.selfTimeMs),
|
||||
maxSelfTimeMs: roundMs(group.maxSelfTimeMs),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function performanceCaptureArtifacts(artifacts) {
|
||||
return (Array.isArray(artifacts) ? artifacts : [])
|
||||
.filter((item) => item && item.kind === "performance-cpu-profile")
|
||||
.slice(-20)
|
||||
.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 frontendPerformanceMaxNumber(items, getter) {
|
||||
const selected = maxByNumber(items, getter);
|
||||
return selected ? numberOrNull(getter(selected)) : null;
|
||||
}
|
||||
|
||||
function descDuration(left, right) {
|
||||
return Number(right.durationMs ?? 0) - Number(left.durationMs ?? 0) || String(right.ts || "").localeCompare(String(left.ts || ""));
|
||||
}
|
||||
|
||||
function roundMs(value) {
|
||||
return Number((Number(value || 0)).toFixed(2));
|
||||
}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user