chore(scripts): add a script to anlayze performance logs

This commit is contained in:
Elian Doran 2026-01-10 21:10:50 +02:00
parent de6a6cbb07
commit 545464efee
No known key found for this signature in database

231
scripts/analyze-perf.ts Normal file
View File

@ -0,0 +1,231 @@
import * as fs from 'fs';
import * as path from 'path';
interface PerfEntry {
operation: string;
time: number;
file: string;
fullLine: string;
}
interface AggregatedStats {
totalTime: number;
count: number;
files: string[];
}
function stripAnsi(text: string): string {
// Remove ANSI escape codes - both the ESC[ format and the literal bracket format
return text.replace(/\x1b\[[0-9;]*m/g, '').replace(/\[(\d+)m/g, '');
}
function parsePerf(filePath: string): PerfEntry[] {
// Read as UTF-16 LE which is what PowerShell's Tee-Object writes
const content = fs.readFileSync(filePath, 'utf16le');
const lines = content.split('\n');
const entries: PerfEntry[] = [];
// Match patterns like: "vite:load 7776.91ms [fs] /path/to/file"
// or "vite:transform 1234.56ms /path/to/file"
const timePattern = /(\d+\.\d+)ms/;
const operationPattern = /vite:(load|transform|resolve|time|import-analysis)/;
for (let line of lines) {
// Strip ANSI color codes
line = stripAnsi(line);
const timeMatch = line.match(timePattern);
const operationMatch = line.match(operationPattern);
if (timeMatch && operationMatch) {
const time = parseFloat(timeMatch[1]);
const operation = operationMatch[1];
// Extract file path - it's usually after the timing and optional [fs]/[plugin] marker
let file = 'unknown';
// Match file paths after [fs] or [plugin] or directly after timing
const pathMatch = line.match(/(?:\[(?:fs|plugin)\]\s+)?([/\\]?[\w/.@-]+\.[a-z]+(?:\?[^\s]*)?)/i);
if (pathMatch) {
file = pathMatch[1];
// Normalize path separators
file = file.replace(/\\/g, '/');
// Remove query params
file = file.replace(/\?.*$/, '');
// Get just the filename for cleaner output
const fileName = path.basename(file);
if (file.includes('node_modules')) {
const nodeModulesMatch = file.match(/node_modules\/([^\/]+)/);
if (nodeModulesMatch) {
file = `npm:${nodeModulesMatch[1]}/${fileName}`;
}
} else if (file.includes('packages/')) {
const packagesMatch = file.match(/packages\/([^\/]+)/);
if (packagesMatch) {
file = `pkg:${packagesMatch[1]}/${fileName}`;
}
} else if (file.includes('.cache/vite/deps/')) {
const depsMatch = file.match(/deps\/([^?]+)/);
if (depsMatch) {
file = `deps:${depsMatch[1]}`;
}
} else if (file.startsWith('/')) {
// Remove leading /src/ or similar
file = file.replace(/^\/src\//, '');
if (!file.includes('/')) {
file = fileName;
}
} else {
file = fileName;
}
}
entries.push({
operation,
time,
file,
fullLine: line.trim()
});
}
}
return entries;
}
function analyzePerf(entries: PerfEntry[]) {
console.log('\n📊 VITE PERFORMANCE ANALYSIS\n');
console.log('='.repeat(80));
// Top 20 slowest individual operations
console.log('\n🐌 TOP 20 SLOWEST OPERATIONS:\n');
const sorted = [...entries].sort((a, b) => b.time - a.time).slice(0, 20);
sorted.forEach((entry, i) => {
console.log(`${String(i + 1).padStart(2)}. ${entry.time.toFixed(2).padStart(8)}ms [${entry.operation.padEnd(15)}] ${entry.file}`);
});
// Aggregate by operation type
console.log('\n⚙ BY OPERATION TYPE:\n');
const byOperation = new Map<string, AggregatedStats>();
for (const entry of entries) {
if (!byOperation.has(entry.operation)) {
byOperation.set(entry.operation, { totalTime: 0, count: 0, files: [] });
}
const stats = byOperation.get(entry.operation)!;
stats.totalTime += entry.time;
stats.count++;
}
const operationsSorted = Array.from(byOperation.entries())
.sort((a, b) => b[1].totalTime - a[1].totalTime);
operationsSorted.forEach(([op, stats]) => {
const avgTime = stats.totalTime / stats.count;
console.log(`${op.padEnd(20)} ${stats.totalTime.toFixed(0).padStart(8)}ms total (${stats.count.toString().padStart(4)} ops, ${avgTime.toFixed(1)}ms avg)`);
});
// Aggregate by package/category
console.log('\n📦 BY PACKAGE/CATEGORY:\n');
const byPackage = new Map<string, AggregatedStats>();
for (const entry of entries) {
let category = 'other';
if (entry.file.startsWith('pkg:ckeditor5')) {
category = 'CKEditor Core';
} else if (entry.file.startsWith('pkg:')) {
category = entry.file.split('/')[0];
} else if (entry.file.startsWith('deps:ckeditor5-premium')) {
category = 'CKEditor Premium';
} else if (entry.file.startsWith('deps:ckeditor5')) {
category = 'CKEditor Core (deps)';
} else if (entry.file.startsWith('deps:@codemirror')) {
category = 'CodeMirror';
} else if (entry.file.startsWith('deps:')) {
category = 'Dependencies';
} else if (entry.file.includes('.css')) {
category = 'CSS';
}
if (!byPackage.has(category)) {
byPackage.set(category, { totalTime: 0, count: 0, files: [] });
}
const stats = byPackage.get(category)!;
stats.totalTime += entry.time;
stats.count++;
if (!stats.files.includes(entry.file)) {
stats.files.push(entry.file);
}
}
const packagesSorted = Array.from(byPackage.entries())
.sort((a, b) => b[1].totalTime - a[1].totalTime);
packagesSorted.forEach(([pkg, stats]) => {
console.log(`${pkg.padEnd(30)} ${stats.totalTime.toFixed(0).padStart(8)}ms (${stats.count.toString().padStart(4)} files)`);
});
// CKEditor breakdown
console.log('\n✏ CKEDITOR PLUGIN BREAKDOWN:\n');
const ckeditorEntries = entries.filter(e =>
e.file.includes('ckeditor5') &&
(e.file.includes('admonition') ||
e.file.includes('footnotes') ||
e.file.includes('math') ||
e.file.includes('mermaid') ||
e.file.includes('keyboard-marker'))
);
const byCKPlugin = new Map<string, AggregatedStats>();
for (const entry of ckeditorEntries) {
let plugin = 'unknown';
if (entry.file.includes('admonition')) plugin = 'admonition';
else if (entry.file.includes('footnotes')) plugin = 'footnotes';
else if (entry.file.includes('math')) plugin = 'math';
else if (entry.file.includes('mermaid')) plugin = 'mermaid';
else if (entry.file.includes('keyboard-marker')) plugin = 'keyboard-marker';
if (!byCKPlugin.has(plugin)) {
byCKPlugin.set(plugin, { totalTime: 0, count: 0, files: [] });
}
const stats = byCKPlugin.get(plugin)!;
stats.totalTime += entry.time;
stats.count++;
}
const pluginsSorted = Array.from(byCKPlugin.entries())
.sort((a, b) => b[1].totalTime - a[1].totalTime);
pluginsSorted.forEach(([plugin, stats]) => {
console.log(`${plugin.padEnd(20)} ${stats.totalTime.toFixed(0).padStart(8)}ms (${stats.count} files)`);
});
// Summary stats
const totalTime = entries.reduce((sum, e) => sum + e.time, 0);
const totalOps = entries.length;
console.log('\n📈 SUMMARY:\n');
console.log(`Total operations: ${totalOps}`);
console.log(`Total time: ${(totalTime / 1000).toFixed(1)}s`);
console.log(`Average per op: ${(totalTime / totalOps).toFixed(1)}ms`);
console.log(`Operations > 500ms: ${entries.filter(e => e.time > 500).length}`);
console.log(`Operations > 1000ms: ${entries.filter(e => e.time > 1000).length}`);
console.log(`Operations > 3000ms: ${entries.filter(e => e.time > 3000).length}`);
console.log('\n' + '='.repeat(80) + '\n');
}
// Main
const perfFile = path.join(process.cwd(), 'perf.txt');
if (!fs.existsSync(perfFile)) {
console.error('❌ perf.txt not found. Run with DEBUG=vite:* first.');
process.exit(1);
}
console.log(`📂 Reading ${perfFile}...`);
const entries = parsePerf(perfFile);
console.log(`📊 Found ${entries.length} performance entries`);
if (entries.length === 0) {
console.error('❌ No performance data found in perf.txt');
console.error(' Expected lines like: "vite:load 123.45ms [fs] /path/to/file"');
process.exit(1);
}
analyzePerf(entries);