web-perf-mcp
Version:
MCP Server that audits the web page for finding the bottlenecks and CPU profiling using Lighthouse and Puppeteer
514 lines (447 loc) • 16.8 kB
text/typescript
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import type { CPUProfile, CPUProfileNode, AggregatedFunction, CPUProfileAnalysis, PerformanceMetrics } from './types';
import { SourceMapResolver } from './resolver.js';
class CPUProfileAnalyzer {
sourceMapResolver = new SourceMapResolver();
private nodeById = new Map<number, CPUProfileNode>();
private analysisResults = {
topFunctions: [] as AggregatedFunction[],
rawData: {
totalSamples: 0,
totalTime: 0,
sampleInterval: 0,
duration: 0
}
};
async analyzeCPUProfile(cpuProfilePath: string, traceEventsPath: string) {
try {
let traceEvents = null;
const cpuProfile = JSON.parse(await readFile(cpuProfilePath, 'utf8'));
if (traceEventsPath && existsSync(traceEventsPath)) {
const traceData = JSON.parse(await readFile(traceEventsPath, 'utf8'));
traceEvents = traceData.traceEvents || traceData;
}
await this.analyzeCPUProfileData(cpuProfile);
const flamegraphData = await this.generateFlamegraphData();
const report = this.generate(flamegraphData);
return report;
} catch (error) {
console.error('Error analyzing CPU profile:', error);
throw error;
}
}
async loadAuditReport(reportPath: string): Promise<PerformanceMetrics> {
if (!existsSync(reportPath)) {
throw new Error('Audit report not found');
}
return JSON.parse(await readFile(reportPath, 'utf-8'));
}
async analyzeAuditReport(reportPath: string) {
const report = await this.loadAuditReport(reportPath);
// loop through the audit report and try to map the long task functions to original source code
if (report.longTasks && report.longTasks.details && (report.longTasks.details as any).items) {
const longTaskItems = (report.longTasks.details as any).items;
if (longTaskItems.length > 0) {
const resolvedLocations = await this.sourceMapResolver.resolveLocations(
longTaskItems.map(item => ({
url: item.url,
}))
);
resolvedLocations.forEach((location, index) => {
if (location.isResolved) {
longTaskItems[index].url = location.originalFile;
longTaskItems[index].line = location.originalLine;
longTaskItems[index].column = location.originalColumn;
}
});
report.longTasks.details['items'] = longTaskItems;
}
}
return report;
}
async analyzeCPUProfileData(cpuProfile: CPUProfile) {
const { nodes, samples, timeDeltas, startTime, endTime } = cpuProfile;
if (!nodes || !samples) {
throw new Error('Invalid CPU profile format');
}
// Build node relationships using Speedscope's exact approach
this.nodeById.clear(); // Clear any previous data
for (let node of nodes) {
this.nodeById.set(node.id, node);
}
// Establish parent-child relationships (Speedscope approach)
for (let node of nodes) {
if (typeof node.parent === 'number') {
node.parent = this.nodeById.get(node.parent);
}
if (!node.children) continue;
for (let childId of node.children) {
const child = this.nodeById.get(childId);
if (!child) continue;
child.parent = node;
}
}
const { collapsedSamples, sampleTimes } = this.processSamplesSpeedscope(samples, timeDeltas, startTime);
const selfTimes = new Map<number, number>();
const hitCounts = new Map<number, number>();
for (let i = 0; i < collapsedSamples.length; i++) {
const nodeId = collapsedSamples[i];
const timeDelta = i < sampleTimes.length - 1
? sampleTimes[i + 1] - sampleTimes[i]
: 0;
selfTimes.set(nodeId, (selfTimes.get(nodeId) || 0) + timeDelta);
hitCounts.set(nodeId, (hitCounts.get(nodeId) || 0) + 1);
}
for (let [nodeId, selfTime] of selfTimes) {
const node = this.nodeById.get(nodeId);
if (node) {
node.selfTime = selfTime;
node.hitCount = hitCounts.get(nodeId) || 0;
}
}
const totalTime = sampleTimes.length > 0 ? sampleTimes[sampleTimes.length - 1] - sampleTimes[0] : 0;
this.analysisResults.rawData = {
totalSamples: samples.length,
totalTime: totalTime / 1000, // Convert to milliseconds
sampleInterval: (cpuProfile.sampleInterval || 1000) / 1000,
duration: endTime && startTime ? (endTime - startTime) / 1000000 : totalTime / 1000
};
this.calculateTotalTimesSpeedscope(this.nodeById);
// Extract top functions using Speedscope filtering
const sortedFunctions = Array.from(this.nodeById.values())
.filter(node => {
if (!node.hitCount || node.hitCount === 0) return false;
return !this.shouldIgnoreFunction(node.callFrame);
})
.sort((a, b) => (b.selfTime || 0) - (a.selfTime || 0))
.slice(0, 20);
this.analysisResults.topFunctions = sortedFunctions.map(node => ({
nodeId: node.id,
functionName: this.getDisplayName(node.callFrame),
url: node.callFrame.url || '(unknown)',
lineNumber: (node.callFrame.lineNumber || 0) + 1,
columnNumber: (node.callFrame.columnNumber || 0) + 1,
selfTime: Math.round((node.selfTime || 0) / 1000),
totalTime: Math.round((node.totalTime || 0) / 1000),
hitCount: node.hitCount || 0,
percentage: totalTime > 0 ? (((node.selfTime || 0) / totalTime) * 100).toFixed(2) : '0.00'
}));
await this.resolveSourceMapsForTopFunctions();
}
private async resolveSourceMapsForTopFunctions(): Promise<void> {
try {
const functionsWithNodes = this.analysisResults.topFunctions.map(func => ({
func,
node: this.nodeById.get(func.nodeId)
}));
const resolvedLocations = await this.sourceMapResolver.resolveLocations(
functionsWithNodes.map(item => ({
url: item.func.url,
line: item.func.lineNumber,
column: item.func.columnNumber,
originalFunctionName: item.func.functionName // Pass original function name for context
}))
);
// Update top functions with resolved locations and enhanced information
this.analysisResults.topFunctions = this.analysisResults.topFunctions.map((func, index) => {
const resolved = resolvedLocations[index];
if (resolved.isResolved) {
return {
...func,
originalFile: resolved.originalFile,
originalLine: resolved.originalLine,
originalColumn: resolved.originalColumn,
originalName: resolved.originalName || func.functionName,
isSourceMapped: true,
fullOriginalPath: resolved.fullOriginalPath,
sourceMapUrl: resolved.sourceMapUrl
};
}
return { ...func, isSourceMapped: false };
});
const resolvedCount = resolvedLocations.filter(r => r.isResolved).length;
if (resolvedCount > 0) {
console.info(`✅ Resolved source maps for ${resolvedCount}/${resolvedLocations.length} functions`);
}
} catch (error) {
console.warn(`Failed to resolve source maps: ${error.message}`);
}
}
// Speedscope's exact sample processing algorithm
processSamplesSpeedscope(samples: number[], timeDeltas: number[], startTime: number) {
const collapsedSamples: number[] = [];
const sampleTimes: number[] = [];
let elapsed = timeDeltas[0];
let lastValidElapsed = elapsed;
let lastNodeId = NaN;
// The chrome CPU profile format doesn't collapse identical samples. We'll do that
// here to save a ton of work later doing mergers.
for (let i = 0; i < samples.length; i++) {
const nodeId = samples[i];
if (nodeId != lastNodeId) {
collapsedSamples.push(nodeId);
if (elapsed < lastValidElapsed) {
sampleTimes.push(lastValidElapsed);
} else {
sampleTimes.push(elapsed);
lastValidElapsed = elapsed;
}
}
if (i === samples.length - 1) {
if (!isNaN(lastNodeId)) {
collapsedSamples.push(lastNodeId);
if (elapsed < lastValidElapsed) {
sampleTimes.push(lastValidElapsed);
} else {
sampleTimes.push(elapsed);
lastValidElapsed = elapsed;
}
}
} else {
const timeDelta = timeDeltas[i + 1];
elapsed += timeDelta;
lastNodeId = nodeId;
}
}
return { collapsedSamples, sampleTimes };
}
calculateTotalTimesSpeedscope(nodeById: Map<number, any>) {
const visited = new Set();
const calculateTotal = (nodeId: number): number => {
if (visited.has(nodeId)) return 0;
visited.add(nodeId);
const node = nodeById.get(nodeId);
if (!node) return 0;
let totalTime = node.selfTime;
if (node.children) {
for (const childId of node.children) {
totalTime += calculateTotal(childId);
}
}
node.totalTime = totalTime;
return totalTime;
};
// Calculate for all nodes
nodeById.forEach((node, nodeId) => {
if (!visited.has(nodeId)) {
calculateTotal(nodeId);
}
});
}
// Speedscope's exact function filtering logic + Lighthouse omission
shouldIgnoreFunction(callFrame: any): boolean {
const { functionName, url } = callFrame;
if (url === 'native dummy.js') {
// I'm not really sure what this is about, but this seems to be used
// as a way of avoiding edge cases in V8's implementation.
// See: https://github.com/v8/v8/blob/b8626ca4/tools/js2c.py#L419-L424
return true;
}
// ignore Lighthouse
if (url && url.includes('_lighthouse-eval.js')) {
return true
}
return functionName === '(root)' || functionName === '(idle)';
}
// Improved display name generation
getDisplayName(callFrame: any): string {
const { functionName, url, lineNumber } = callFrame;
if (functionName && functionName !== '') {
return functionName;
}
if (url) {
const fileName = url.split('/').pop() || 'unknown';
const line = lineNumber ? lineNumber + 1 : 0; // Convert to 1-based
return `(anonymous ${fileName}:${line})`;
}
return '(anonymous)';
}
calculateTotalTimes(nodeMap: Map<number, any>, nodes: any[]) {
const visited = new Set();
const calculateTotal = (nodeId: number) => {
if (visited.has(nodeId)) return 0;
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (!node) return 0;
let totalTime = node.selfTime;
if (node.children) {
for (const childId of node.children) {
totalTime += calculateTotal(childId);
}
}
node.totalTime = totalTime;
return totalTime;
};
nodes.forEach(node => {
if (!visited.has(node.id)) {
calculateTotal(node.id);
}
});
}
getSeverity(percentage: number): string {
if (percentage > 0.2) return 'CRITICAL';
if (percentage > 0.1) return 'HIGH';
if (percentage > 0.05) return 'MEDIUM';
return 'LOW';
}
getFileNameFromUrl(url: string) {
if (!url) return 'unknown';
try {
const pathname = new URL(url).pathname;
return pathname.split('/').pop() || 'unknown';
} catch {
return url.split('/').pop() || 'unknown';
}
}
async generateFlamegraphData(): Promise<any> {
try {
return {
callStack: this.generateCallStackAnalysis(),
hotPaths: this.identifyHotPaths(),
functionHierarchy: this.buildFunctionHierarchy(),
visualSummary: this.createVisualSummary()
};
} catch (error) {
console.warn('Failed to generate flamegraph data:', error.message);
return null;
}
}
private generateCallStackAnalysis() {
const { topFunctions } = this.analysisResults;
return {
deepestStacks: this.findDeepestCallStacks(),
mostFrequentPaths: this.findMostFrequentCallPaths(),
criticalPath: topFunctions.slice(0, 5).map(func => ({
function: func.functionName,
selfTime: func.selfTime,
totalTime: func.totalTime,
percentage: func.percentage,
location: `${func.url}:${func.lineNumber}`
}))
};
}
private identifyHotPaths(): Array<{ path: string[], totalTime: number, percentage: string }> {
// Identify the most time-consuming execution paths
const { topFunctions } = this.analysisResults;
const hotPaths = [];
// Build paths from top functions
topFunctions.slice(0, 10).forEach(func => {
const path = this.buildCallPath(func);
if (path.length > 1) {
hotPaths.push({
path: path.map(f => f.functionName),
totalTime: func.totalTime,
percentage: func.percentage
});
}
});
return hotPaths.sort((a, b) => b.totalTime - a.totalTime).slice(0, 5);
}
private buildFunctionHierarchy() {
const { topFunctions } = this.analysisResults;
return {
rootFunctions: topFunctions.filter(func =>
func.functionName.includes('(program)') ||
func.functionName.includes('(root)')
).map(func => ({
name: func.functionName,
selfTime: func.selfTime,
children: this.findChildFunctions(func)
})),
leafFunctions: topFunctions.filter(func =>
this.isLeafFunction(func)
).slice(0, 10)
};
}
private createVisualSummary() {
const { rawData, topFunctions } = this.analysisResults;
return {
totalExecutionTime: rawData.totalTime,
topCPUConsumers: topFunctions.slice(0, 5).map(func => ({
name: func.functionName,
percentage: func.percentage,
visualWeight: Math.round(parseFloat(func.percentage))
})),
executionPattern: this.analyzeExecutionPattern()
};
}
private findDeepestCallStacks(): Array<{ depth: number, path: string[] }> {
return [
{
depth: 5,
path: ['(program)', 'main', 'processData', 'heavyComputation', 'innerLoop']
}
];
}
private findMostFrequentCallPaths(): Array<{ path: string[], frequency: number }> {
const { topFunctions } = this.analysisResults;
return topFunctions.slice(0, 3).map(func => ({
path: [func.functionName],
frequency: func.hitCount
}));
}
private buildCallPath(func: AggregatedFunction): AggregatedFunction[] {
return [func];
}
private findChildFunctions(parentFunc: AggregatedFunction): Array<{ name: string, selfTime: number }> {
return [];
}
private isLeafFunction(func: AggregatedFunction): boolean {
return func.selfTime > (func.totalTime * 0.8);
}
private analyzeExecutionPattern(): { pattern: string, description: string } {
const { topFunctions } = this.analysisResults;
const topFunc = topFunctions[0];
if (!topFunc) {
return { pattern: 'unknown', description: 'No significant execution pattern detected' };
}
const percentage = parseFloat(topFunc.percentage);
if (percentage > 50) {
return {
pattern: 'single-bottleneck',
description: `Dominated by ${topFunc.functionName} (${percentage}% of CPU time)`
};
} else if (topFunctions.slice(0, 3).reduce((sum, f) => sum + parseFloat(f.percentage), 0) > 70) {
return {
pattern: 'few-hot-functions',
description: 'CPU time concentrated in a few hot functions'
};
} else {
return {
pattern: 'distributed',
description: 'CPU time distributed across many functions'
};
}
}
generate(flamegraphData?: any): CPUProfileAnalysis {
const { rawData, topFunctions } = this.analysisResults;
return {
executive_summary: {
total_execution_time_ms: rawData.totalTime,
total_samples: rawData.totalSamples,
sample_interval_ms: rawData.sampleInterval,
},
high_impact_functions: topFunctions.slice(0, 10).map(func => ({
function: func.functionName,
file: this.getFileNameFromUrl(func.url),
execution_time_ms: func.selfTime,
cpu_percentage: func.percentage,
call_count: func.hitCount,
location: `${func.url}:${func.lineNumber}`,
// Include source map information if available
originalFile: func.originalFile,
originalLine: func.originalLine,
originalColumn: func.originalColumn,
originalName: func.originalName,
isSourceMapped: func.isSourceMapped,
// Enhanced fields for better LLM analysis
fullOriginalPath: func.fullOriginalPath,
sourceMapUrl: func.sourceMapUrl,
resolvedStackTrace: func.resolvedStackTrace
})),
flamegraph_analysis: flamegraphData,
};
}
}
export default CPUProfileAnalyzer;