UNPKG

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
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';