From 545464efeea8ab80584e4c2ff612a4235b2da037 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 10 Jan 2026 21:10:50 +0200 Subject: [PATCH] chore(scripts): add a script to anlayze performance logs --- scripts/analyze-perf.ts | 231 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 scripts/analyze-perf.ts diff --git a/scripts/analyze-perf.ts b/scripts/analyze-perf.ts new file mode 100644 index 000000000..754a7ee7f --- /dev/null +++ b/scripts/analyze-perf.ts @@ -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(); + 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(); + 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(); + 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);