1174 lines
55 KiB
TypeScript
1174 lines
55 KiB
TypeScript
// 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`
|
|
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 = [];
|
|
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);
|
|
}
|
|
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");
|
|
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);
|
|
const performanceWindows = buildFrontendPerformanceWindows({ events, captures, profileHotspots, profileStacks, scriptHotspots, samples: sourceSamples, network: sourceNetwork });
|
|
const sourceAttribution = buildFrontendSourceAttribution({ scriptHotspots, profileHotspots, profileStacks, sourceMapState });
|
|
const attribution = frontendPerformanceAttributionStatus({ captures, profileHotspots, profileStacks, scriptHotspots, longTasks, loafs, gaps, performanceWindows });
|
|
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),
|
|
cpuProfileStatus: attribution.cpuProfileStatus,
|
|
attributionMode: attribution.attributionMode,
|
|
noCpuProfile: attribution.noCpuProfile,
|
|
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,
|
|
},
|
|
longTasks: longTasks.sort(descDuration).slice(0, 40),
|
|
longAnimationFrames: loafs.sort(descDuration).slice(0, 40),
|
|
eventLoopGaps: gaps.sort(descDuration).slice(0, 40),
|
|
scriptHotspots,
|
|
profileHotspots,
|
|
profileStacks,
|
|
performanceWindows,
|
|
sourceAttribution,
|
|
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 (summary.noCpuProfile === true && (severeLongTasks.length > 0 || severeLoafs.length > 0 || severeGaps.length > 0)) findings.push({
|
|
id: "frontend-performance-loaf-only-no-cpu-profile",
|
|
severity: "amber",
|
|
summary: "frontend performance evidence is LoAF/LongTask/event-loop only because no completed CPU profile capture is present; do not cite CPU profile hotspots for this run",
|
|
count: 1,
|
|
attributionMode: summary.attributionMode || "loaf-only-no-cpu-profile",
|
|
cpuProfileStatus: summary.cpuProfileStatus || "missing",
|
|
captureCount: summary.captureCount ?? 0,
|
|
windowCorrelations: (report?.performanceWindows || []).slice(0, 8),
|
|
longTaskCount: summary.longTaskCount ?? 0,
|
|
longAnimationFrameCount: summary.longAnimationFrameCount ?? 0,
|
|
eventLoopGapCount: summary.eventLoopGapCount ?? 0,
|
|
topScripts: (report?.scriptHotspots || []).slice(0, 12),
|
|
nextAction: "Run an explicit performanceCapture command and re-run observe analyze before making CPU-profile hotspot claims; existing LoAF scripts remain valid browser-side attribution.",
|
|
valuesRedacted: true,
|
|
});
|
|
if ((report?.drainErrors || []).length > 0) findings.push({
|
|
id: "frontend-performance-probe-drain-errors",
|
|
severity: "amber",
|
|
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 frontendPerformanceAttributionStatus(input) {
|
|
const captureCount = Array.isArray(input?.captures) ? input.captures.length : 0;
|
|
const profileHotspotCount = Array.isArray(input?.profileHotspots) ? input.profileHotspots.length : 0;
|
|
const profileStackCount = Array.isArray(input?.profileStacks) ? input.profileStacks.length : 0;
|
|
const scriptHotspotCount = Array.isArray(input?.scriptHotspots) ? input.scriptHotspots.length : 0;
|
|
const eventCount =
|
|
(Array.isArray(input?.longTasks) ? input.longTasks.length : 0) +
|
|
(Array.isArray(input?.loafs) ? input.loafs.length : 0) +
|
|
(Array.isArray(input?.gaps) ? input.gaps.length : 0);
|
|
const hasCpuProfile = captureCount > 0;
|
|
const hasCpuProfileHotspots = profileHotspotCount > 0 || profileStackCount > 0;
|
|
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 ? (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
|
|
? 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,
|
|
};
|
|
}
|
|
return {
|
|
cpuProfileStatus: "missing",
|
|
attributionMode: eventCount > 0 || scriptHotspotCount > 0 ? "loaf-only-no-cpu-profile" : "no-frontend-performance-evidence",
|
|
noCpuProfile: true,
|
|
loafOnly: eventCount > 0 || scriptHotspotCount > 0,
|
|
cpuProfileHotspotEvidence: false,
|
|
evidenceNote: eventCount > 0 || scriptHotspotCount > 0
|
|
? "LongTask/LoAF/event-loop evidence is present, but no completed performanceCapture CPU profile exists"
|
|
: "no frontend performance events or completed performanceCapture CPU profile exist",
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactPerformanceEventRow(row, perf) {
|
|
const window = performanceEventWindow(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),
|
|
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,
|
|
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 : {};
|
|
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,
|
|
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),
|
|
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,
|
|
summarySha256: artifact.summarySha256 ?? null,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
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),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function mergeLoafScript(groups, script, event) {
|
|
const compact = compactLoafScript(script);
|
|
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, 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,
|
|
totalDurationMs: roundMs(group.totalDurationMs),
|
|
maxDurationMs: roundMs(group.maxDurationMs),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
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, 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;
|
|
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 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;
|
|
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 }));
|
|
}
|
|
|
|
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 : [])
|
|
.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"
|
|
? sameWindowProfileEvidence(event, selectedCapture, input)
|
|
: 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,
|
|
profileTimelineStatus: capture.profileTimelineStatus ?? null,
|
|
profileTimelineSampleCount: capture.profileTimelineSampleCount ?? null,
|
|
profileTimelineTotalTimeMs: capture.profileTimelineTotalTimeMs ?? null,
|
|
profileTimelineStartAt: capture.profileTimelineStartAt ?? null,
|
|
profileTimelineEndAt: capture.profileTimelineEndAt ?? 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;
|
|
}
|
|
|
|
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) => {
|
|
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);
|
|
}
|
|
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: 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,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
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: resolved?.status === "unmapped" ? "unmapped" : "missing",
|
|
sourceAttributionMode: resolved?.status === "unmapped" ? "source-map-loaded-no-match" : "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;
|
|
}
|
|
|
|
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));
|
|
}
|
|
`;
|
|
}
|