trilium/scripts/analyze-perf.ts
Elian Doran 244294f699
Revert "chore(scripts): remove analyze-perf"
This reverts commit 94d4a307cf6b612acc6d7e0d94f5975532412a5c.
2026-01-11 10:55:47 +02:00

386 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as path from 'path';
import * as http from "http";
import { spawn } from 'child_process';
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 parsePerfFromLines(lines: string[]): PerfEntry[] {
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 / 1000).toFixed(2).padStart(6)}s [${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 / 1000).toFixed(1).padStart(6)}s total (${stats.count.toString().padStart(4)} ops, ${(avgTime / 1000).toFixed(3)}s 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';
// Check package prefixes first
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';
}
// Break down app source files
else if (entry.file.includes('widgets/')) {
if (entry.file.includes('type_widgets/')) {
category = 'App: Type Widgets';
} else if (entry.file.includes('collections/')) {
category = 'App: Collections';
} else if (entry.file.includes('ribbon/')) {
category = 'App: Ribbon';
} else if (entry.file.includes('dialogs/')) {
category = 'App: Dialogs';
} else if (entry.file.includes('launch_bar/')) {
category = 'App: Launch Bar';
} else {
category = 'App: Widgets';
}
} else if (entry.file.includes('services/')) {
category = 'App: Services';
} else if (entry.file.includes('components/')) {
category = 'App: Components';
} else if (entry.file.includes('menus/')) {
category = 'App: Menus';
} else if (entry.file.includes('.css')) {
category = 'CSS';
} else if (entry.file.match(/\.(png|jpg|jpeg|svg|gif|woff|woff2|ttf|eot)$/i)) {
category = 'Assets';
}
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 / 1000).toFixed(1).padStart(6)}s (${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 / 1000).toFixed(1).padStart(6)}s (${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');
}
// Helper to make HTTP request to trigger Vite rendering
async function makeHttpRequest(): Promise<void> {
return new Promise((resolve) => {
console.log('\n🌐 Making request to http://localhost:8080 to trigger Vite...');
const req = http.get('http://localhost:8080', (res: http.IncomingMessage) => {
console.log(` ✅ Response status: ${res.statusCode}`);
console.log(` Response headers:`, res.headers);
let body = '';
res.on('data', (chunk: Buffer | string) => {
body += chunk;
});
res.on('end', () => {
console.log(` Response body length: ${body.length} bytes`);
if (body.length < 1000) {
console.log(` Response body: ${body}`);
} else {
console.log(` Response body preview (first 500 chars):\n${body.substring(0, 500)}`);
}
resolve();
});
});
req.on('error', (err: Error) => {
console.log(` ❌ Request failed: ${err.message}`);
resolve(); // Continue anyway
});
req.setTimeout(5000, () => {
console.log(` ⏱️ Request timed out after 5 seconds`);
req.destroy();
resolve();
});
});
}
// Main - runs pnpm server:start and analyzes output automatically
async function runPerfAnalysis() {
console.log('🚀 Starting pnpm server:start with DEBUG=vite:*...\n');
console.log('⏳ This will take about 60 seconds...\n');
const lines: string[] = [];
let dataCount = 0;
return new Promise<void>((resolve, reject) => {
const child = spawn('pnpm', ['server:start'], {
env: {
...process.env,
DEBUG: 'vite:*',
TRILIUM_GENERAL_NOAUTHENTICATION: '1'
},
shell: true,
cwd: path.join(__dirname, '..'),
stdio: ['ignore', 'pipe', 'pipe']
});
child.stdout?.setEncoding('utf8');
child.stderr?.setEncoding('utf8');
let lastActivityTime = Date.now();
let maxTimeoutHandle: NodeJS.Timeout;
let inactivityCheckInterval: NodeJS.Timeout;
let serverStarted = false;
const finishCollection = () => {
clearTimeout(maxTimeoutHandle);
clearInterval(inactivityCheckInterval);
child.kill('SIGTERM');
// Give it a moment to clean up
setTimeout(() => {
const elapsedSeconds = ((Date.now() - lastActivityTime) / 1000).toFixed(1);
console.log('\n\n✅ Collected output, analyzing...\n');
console.log(` Received ${dataCount} data chunks, ${lines.length} lines`);
console.log(` Last activity was ${elapsedSeconds}s ago`);
const entries = parsePerfFromLines(lines);
console.log(`📊 Found ${entries.length} performance entries`);
if (entries.length === 0) {
console.error('❌ No performance data found');
console.error(' Expected lines like: "vite:load 123.45ms [fs] /path/to/file"');
console.error(`\n Sample lines collected (first 20):`);
lines.slice(0, 20).forEach(line => {
if (line.trim()) console.error(` "${line}"`);
});
reject(new Error('No performance data found'));
return;
}
analyzePerf(entries);
resolve();
}, 1000);
};
child.stdout?.on('data', (data) => {
dataCount++;
lastActivityTime = Date.now();
const text = String(data);
lines.push(...text.split('\n'));
process.stdout.write('.');
// Check if server has started
if (!serverStarted && text.includes('Listening on')) {
serverStarted = true;
// Wait 2 seconds for Vite to initialize, then make a request
setTimeout(async () => {
await makeHttpRequest();
}, 2000);
}
});
child.stderr?.on('data', (data) => {
dataCount++;
lastActivityTime = Date.now();
const text = String(data);
lines.push(...text.split('\n'));
process.stdout.write('.');
});
// Maximum timeout of 60 seconds
maxTimeoutHandle = setTimeout(() => {
console.log('\n⏱ Reached 60 second maximum timeout');
finishCollection();
}, 60000);
// Check every second for 10 seconds of inactivity
inactivityCheckInterval = setInterval(() => {
const inactiveSeconds = (Date.now() - lastActivityTime) / 1000;
if (inactiveSeconds >= 10) {
console.log('\n⏱ No activity for 10 seconds, finishing...');
finishCollection();
}
}, 1000);
child.on('error', (error) => {
console.error(`❌ Failed to start process: ${error.message}`);
reject(error);
});
});
}
runPerfAnalysis().catch(error => {
console.error('❌ Analysis failed:', error);
process.exit(1);
});