bodhi-node-profiler
Version:
A lightweight, zero-configuration performance profiler for Node.js applications with real-time dashboard
333 lines (290 loc) • 11.3 kB
text/typescript
import express from 'express';
import { ProfilerOptions, ProfilerStats, LoggingConfig, MetricsConfig } from './types';
import winston from 'winston';
import 'winston-daily-rotate-file';
import path from 'path';
import fs from 'fs';
import pidusage from 'pidusage';
import os from 'os';
export class BodhiProfiler {
private options: ProfilerOptions;
private logger: winston.Logger;
private stats: ProfilerStats;
private startTime: number;
private lastCpuUsage = process.cpuUsage();
private lastCpuCheck = Date.now();
private apiResponseTimes: number[];
private maxResponseTimes: number;
constructor(options: ProfilerOptions = {}) {
this.options = {
serviceName: 'app',
enableWebDashboard: true,
port: 45678,
logPath: './logs/profiler',
sampleInterval: 5000,
logging: {
console: false,
file: true,
format: 'json',
rotation: {
maxFiles: '7d',
maxSize: '5m',
datePattern: 'YYYY-MM-DD',
compress: true
},
cleanup: {
enabled: true,
maxAge: '30d'
}
},
metrics: {
cpu: {
enabled: true,
threshold: 70
},
memory: {
enabled: true,
threshold: 80
},
eventLoop: {
enabled: true,
threshold: 100
},
api: {
responseTime: {
enabled: true,
threshold: 1000
},
errorRate: true,
throughput: {
enabled: true,
interval: '1m'
}
}
},
...options
};
this.setupLogger();
this.startTime = Date.now();
this.stats = this.initializeStats();
this.apiResponseTimes = [];
this.maxResponseTimes = 100; // Keep last 100 response times
this.startMetricsCollection();
if (this.options.enableWebDashboard) {
this.setupDashboard();
}
}
private setupLogger() {
const logDir = this.options.logPath!;
const archiveDir = path.join(path.dirname(logDir), 'archive');
// Ensure directories exist
[logDir, archiveDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
const formats = [
winston.format.timestamp(),
winston.format.json()
];
const transports: winston.transport[] = [];
if (this.options.logging?.file) {
transports.push(
new winston.transports.DailyRotateFile({
dirname: logDir,
filename: 'profiler-%DATE%.log',
datePattern: this.options.logging.rotation?.datePattern || 'YYYY-MM-DD',
maxSize: this.options.logging.rotation?.maxSize || '5m',
maxFiles: this.options.logging.rotation?.maxFiles || '7d',
zippedArchive: this.options.logging.rotation?.compress || true
})
);
}
if (this.options.logging?.console) {
transports.push(new winston.transports.Console());
}
this.logger = winston.createLogger({
format: winston.format.combine(...formats),
transports
});
}
private shouldLogMetric(type: keyof MetricsConfig, value: number): boolean {
const config = this.options.metrics?.[type] as any;
if (!config?.enabled) return false;
return value >= (config.threshold || 0);
}
private calculateCpuUsage(): number {
const now = Date.now();
const currentCpuUsage = process.cpuUsage();
const userDiff = currentCpuUsage.user - this.lastCpuUsage.user;
const systemDiff = currentCpuUsage.system - this.lastCpuUsage.system;
const timeDiff = now - this.lastCpuCheck;
// Convert to percentage (0-100)
const cpuPercent = ((userDiff + systemDiff) / (timeDiff * 1000)) * 100;
this.lastCpuUsage = currentCpuUsage;
this.lastCpuCheck = now;
return Math.min(100, Math.max(0, cpuPercent));
}
private async collectMetrics() {
try {
const heapStats = process.memoryUsage();
const totalMem = os.totalmem();
const cpuUsage = this.calculateCpuUsage();
this.stats = {
timestamp: Date.now(),
cpu: {
usage: cpuUsage,
system: cpuUsage * 0.3,
user: cpuUsage * 0.7
},
memory: {
used: heapStats.heapUsed,
total: totalMem,
rss: heapStats.rss,
heapUsed: heapStats.heapUsed,
heapTotal: heapStats.heapTotal,
percentage: ((heapStats.heapUsed / totalMem) * 100).toFixed(1)
},
eventLoop: {
latency: this.getEventLoopLag(),
lag: this.getEventLoopLag()
}
};
console.log('Current Stats:', {
cpu: this.stats.cpu.usage.toFixed(1) + '%',
memory: Math.round(this.stats.memory.heapUsed / (1024 * 1024)) + 'MB',
eventLoop: Math.round(this.stats.eventLoop.latency) + 'ms'
});
// Only log if thresholds are exceeded
if (this.shouldLogMetric('cpu', this.stats.cpu.usage) ||
this.shouldLogMetric('memory', (this.stats.memory.used / this.stats.memory.total) * 100) ||
this.shouldLogMetric('eventLoop', this.stats.eventLoop.latency)) {
this.logger.info('metrics', this.stats);
}
} catch (error) {
console.error('Error collecting metrics:', error);
this.logger.error('Error collecting metrics:', error);
}
}
public cleanupLogs() {
if (!this.options.logging?.cleanup?.enabled) return;
const archiveDir = path.join(path.dirname(this.options.logPath!), 'archive');
if (!fs.existsSync(archiveDir)) return;
const maxAge = this.options.logging.cleanup.maxAge || '30d';
const maxAgeMs = this.parseTimeString(maxAge);
const now = Date.now();
fs.readdirSync(archiveDir)
.forEach(file => {
const filePath = path.join(archiveDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > maxAgeMs) {
fs.unlinkSync(filePath);
}
});
}
private parseTimeString(time: string): number {
const unit = time.slice(-1);
const value = parseInt(time.slice(0, -1));
switch (unit) {
case 'd': return value * 24 * 60 * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'm': return value * 60 * 1000;
case 's': return value * 1000;
default: return value;
}
}
private initializeStats(): ProfilerStats {
return {
timestamp: Date.now(),
cpu: {
usage: 0,
system: 0,
user: 0
},
memory: {
used: 0,
total: 0,
rss: 0,
heapUsed: 0,
heapTotal: 0
},
eventLoop: {
latency: 0,
lag: 0
}
};
}
private startMetricsCollection() {
// Initial collection
this.collectMetrics();
// Regular collection every second
setInterval(() => {
this.collectMetrics();
}, 1000);
}
private setupDashboard() {
const app = express();
const dashboardPath = path.join(__dirname, '..', 'src', 'dashboard');
// Serve static dashboard files
app.use('/profiler', express.static(dashboardPath));
// Stats endpoint
app.get('/profiler/stats', async (req: any, res: any) => {
try {
const systemStats = this.stats;
const avgResponseTime = this.apiResponseTimes.length > 0
? this.apiResponseTimes.reduce((a, b) => a + b, 0) / this.apiResponseTimes.length
: 0;
const uptime = process.uptime();
const uptimeStr = uptime.toFixed(0);
res.json({
cpu: {
percentage: parseFloat(systemStats.cpu.usage.toFixed(1)),
system: systemStats.cpu.system,
user: systemStats.cpu.user
},
memory: {
heapUsed: Math.round(systemStats.memory.heapUsed / 1024 / 1024),
heapTotal: Math.round(systemStats.memory.heapTotal / 1024 / 1024),
percentage: Math.round((systemStats.memory.heapUsed / systemStats.memory.heapTotal) * 100),
rss: Math.round(systemStats.memory.rss / 1024 / 1024)
},
eventLoop: {
latency: systemStats.eventLoop.latency
},
api: {
responseTime: avgResponseTime
},
uptime: uptimeStr,
timestamp: Date.now()
});
} catch (error) {
console.error('Error getting stats:', error);
res.status(500).json({ error: 'Failed to get stats' });
}
});
app.listen(this.options.port, () => {
console.log(`Profiler dashboard available at http://localhost:${this.options.port}/profiler`);
});
}
private getEventLoopLag(): number {
const start = process.hrtime();
const NS_PER_MS = 1e6;
return process.hrtime(start)[1] / NS_PER_MS;
}
public middleware() {
return (req: any, res: any, next: any) => {
const start = process.hrtime();
res.on('finish', () => {
const [seconds, nanoseconds] = process.hrtime(start);
const duration = seconds * 1000 + nanoseconds / 1000000; // Convert to milliseconds
this.apiResponseTimes.push(duration);
// Keep only the last maxResponseTimes entries
if (this.apiResponseTimes.length > this.maxResponseTimes) {
this.apiResponseTimes.shift();
}
});
next();
};
}
}
export * from './types';