memory-leak-diagnose
Version:
🔍 A lightweight CLI tool to help developers quickly identify abnormal memory usage in Node.js applications. Supports both self-monitoring and child process monitoring with real-time alerts, JSON output, and heap snapshots.
620 lines (539 loc) • 20.9 kB
JavaScript
const fs = require('fs');
const path = require('path');
const { spawn, exec } = require('child_process');
const pidusage = require('pidusage');
// ANSI color codes for clean CLI output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
gray: '\x1b[90m'
};
// Utility functions
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatTime = (date) => {
return date.toISOString().replace('T', ' ').substring(0, 19);
};
function checkDiskUsage(callback) {
const platform = process.platform;
if (platform === 'darwin' || platform === 'linux') {
// Use df -k to get disk statistics for the root filesystem
exec('df -k /', (err, stdout) => {
if (err) return callback(err);
const lines = stdout.trim().split('\n');
const parts = lines[lines.length - 1].split(/\s+/);
// df returns blocks of 1K, convert to bytes
const total = parseInt(parts[1], 10) * 1024;
const used = parseInt(parts[2], 10) * 1024;
const free = parseInt(parts[3], 10) * 1024;
callback(null, { drive: '/', total, used, free });
});
} else if (platform === 'win32') {
// WMIC gives FreeSpace and Size in bytes already
exec('wmic logicaldisk get size,freespace,caption', (err, stdout) => {
if (err) return callback(err);
// Find the line for the system drive (usually C:) first, else take first line
const lines = stdout.trim().split('\n').slice(1).filter(Boolean);
// Normalize spacing then split
const parsed = lines.map(l => l.trim().split(/\s+/));
const target = parsed.find(arr => /c:/i.test(arr[0])) || parsed[0];
if (!target || target.length < 3) return callback(new Error('Unable to parse WMIC output'));
const drive = target[0].toUpperCase();
const free = parseInt(target[1], 10);
const total = parseInt(target[2], 10);
const used = total - free;
callback(null, { drive, total, used, free });
});
} else {
callback(new Error(`Unsupported platform: ${platform}`));
}
}
// Parse command line arguments
const parseArgs = () => {
const args = process.argv.slice(2);
const options = {
interval: 1000,
threshold: 100,
logFile: null,
json: false,
captureSnapshot: false,
snapshotLabel: null,
help: false,
command: null,
commandArgs: [],
pid: null,
chart: false
};
let i = 0;
while (i < args.length) {
const arg = args[i];
switch (arg) {
case '--help':
case '-h':
options.help = true;
break;
case '--interval':
case '-i':
options.interval = parseInt(args[++i]) || 1000;
break;
case '--threshold':
case '-t':
options.threshold = parseInt(args[++i]) || 100;
break;
case '--log-file':
case '-l':
options.logFile = args[++i];
break;
case '--json':
case '-j':
options.json = true;
break;
case '--capture-snapshot':
case '-s':
options.captureSnapshot = true;
break;
case '--label':
options.snapshotLabel = args[++i];
break;
case '--pid':
options.pid = parseInt(args[++i]);
if (isNaN(options.pid)) {
console.error(`${colors.red}Error: Invalid PID provided${colors.reset}`);
process.exit(1);
}
break;
case '--chart':
case '-c':
options.chart = true;
break;
default:
if (arg.startsWith('-')) {
console.error(`${colors.red}Error: Unknown option ${arg}${colors.reset}`);
process.exit(1);
} else {
// First non-flag argument is the command
if (!options.command) {
options.command = arg;
} else {
// Remaining arguments are command args
options.commandArgs = args.slice(i);
break;
}
}
}
i++;
}
return options;
};
// Display help information
const showHelp = () => {
console.log(`
${colors.bright}memory-leak-diagnose${colors.reset} - A lightweight CLI tool to monitor Node.js memory usage
${colors.cyan}Usage:${colors.reset}
memory-leak-diagnose [options] [command]
memory-leak-diagnose [options] -- [command args...]
${colors.cyan}Options:${colors.reset}
-h, --help Show this help message
-i, --interval <ms> Monitoring interval in milliseconds (default: 1000)
-t, --threshold <mb> Memory threshold in megabytes (default: 100)
-l, --log-file <path> Optional file path to write logs
-j, --json Output structured JSON (for CI/devops)
-s, --capture-snapshot Capture heap snapshot for analysis
--label <text> Label for snapshot (use with --capture-snapshot)
--pid <number> Monitor existing process by PID
-c, --chart Show ASCII live chart of memory usage
${colors.cyan}Examples:${colors.reset}
memory-leak-diagnose # Monitor this tool's memory
memory-leak-diagnose node index.js # Monitor a Node.js app
memory-leak-diagnose npm run dev # Monitor npm script
memory-leak-diagnose --pid 12345 # Monitor existing process by PID
memory-leak-diagnose --chart --interval 500 node server.js # Show live chart
memory-leak-diagnose --interval 500 --threshold 200 node server.js
memory-leak-diagnose --json --log-file memory.log npm start
memory-leak-diagnose --capture-snapshot --label "before-test" node test.js
${colors.cyan}Use Cases:${colors.reset}
• CI memory regression checks: Alert if memory exceeds 300MB after recent commits
• Electron apps: Monitor heapUsed when opening/closing windows
• Long-running scripts: Detect slow memory creep over hours
• Library debugging: Find if your npm package is leaking memory
• Development servers: Monitor memory usage during development
`);
};
// Memory monitoring class
class MemoryMonitor {
constructor(options) {
this.options = options;
this.thresholdBytes = options.threshold * 1024 * 1024;
this.breachCount = 0;
this.startTime = new Date();
this.isRunning = false;
this.intervalId = null;
this.logStream = null;
this.childProcess = null;
this.monitoringChild = false;
this.monitoringPid = false;
this.chartData = [];
this.maxChartPoints = 50; // Keep last 50 data points for chart
if (options.logFile) {
this.logStream = fs.createWriteStream(options.logFile, { flags: 'a' });
}
}
async start() {
if (this.isRunning) return;
this.isRunning = true;
if (this.options.pid) {
await this.validatePid();
} else if (this.options.command) {
await this.spawnChildProcess();
}
this.log('info', `Memory monitoring started at ${formatTime(this.startTime)}`);
this.log('info', `Threshold: ${formatBytes(this.thresholdBytes)}`);
this.log('info', `Interval: ${this.options.interval}ms`);
if (this.monitoringPid) {
this.log('info', `Monitoring existing process PID: ${this.options.pid}`);
} else if (this.monitoringChild) {
this.log('info', `Monitoring child process: ${this.options.command} ${this.options.commandArgs.join(' ')}`);
} else {
this.log('info', 'Monitoring self (no command provided)');
}
this.intervalId = setInterval(() => {
this.checkMemory();
}, this.options.interval);
}
async validatePid() {
try {
// Check if the process exists by trying to get its stats
await pidusage(this.options.pid);
this.monitoringPid = true;
} catch (error) {
this.log('error', `Process with PID ${this.options.pid} not found: ${error.message}`);
console.error(`${colors.red}Process with PID ${this.options.pid} not found: ${error.message}${colors.reset}`);
process.exit(1);
}
}
async spawnChildProcess() {
try {
this.childProcess = spawn(this.options.command, this.options.commandArgs, {
stdio: 'inherit',
shell: true
});
this.monitoringChild = true;
// Handle child process events
this.childProcess.on('error', (error) => {
this.log('error', `Child process error: ${error.message}`);
console.error(`${colors.red}Child process error: ${error.message}${colors.reset}`);
this.stop();
});
this.childProcess.on('exit', (code, signal) => {
const exitInfo = signal ? `signal ${signal}` : `code ${code}`;
this.log('info', `Child process exited with ${exitInfo}`);
console.log(`\n${colors.yellow}Child process exited with ${exitInfo}${colors.reset}`);
this.stop();
});
// Wait a moment for the process to start
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
this.log('error', `Failed to spawn child process: ${error.message}`);
console.error(`${colors.red}Failed to spawn child process: ${error.message}${colors.reset}`);
process.exit(1);
}
}
stop() {
if (!this.isRunning) return;
this.isRunning = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.childProcess && !this.childProcess.killed) {
this.childProcess.kill('SIGTERM');
}
const endTime = new Date();
const duration = Math.round((endTime - this.startTime) / 1000);
this.log('info', `Monitoring stopped after ${duration}s`);
if (this.logStream) {
this.logStream.end();
}
}
async checkMemory() {
try {
let memory;
let pid = process.pid;
let cpuPercent = 0;
if (this.monitoringPid) {
// Monitor existing external PID via pidusage
const stats = await pidusage(this.options.pid);
memory = {
heapUsed: stats.memory || 0,
heapTotal: stats.memory || 0,
rss: stats.memory || 0,
external: 0,
arrayBuffers: 0
};
cpuPercent = stats.cpu || 0;
pid = this.options.pid;
} else if (this.monitoringChild && this.childProcess && !this.childProcess.killed) {
// Monitor child process via pidusage
const stats = await pidusage(this.childProcess.pid);
memory = {
heapUsed: stats.memory || 0,
heapTotal: stats.memory || 0,
rss: stats.memory || 0,
external: 0,
arrayBuffers: 0
};
cpuPercent = stats.cpu || 0;
pid = this.childProcess.pid;
} else {
// Monitor self – use process.memoryUsage for detailed heap metrics + pidusage for CPU
memory = process.memoryUsage();
const stats = await pidusage(process.pid);
cpuPercent = stats.cpu || 0;
}
const timestamp = new Date();
const data = {
timestamp: timestamp.toISOString(),
pid: pid,
memory: {
heapUsed: memory.heapUsed,
heapTotal: memory.heapTotal,
rss: memory.rss,
external: memory.external,
arrayBuffers: memory.arrayBuffers
},
cpu: {
percent: parseFloat(cpuPercent.toFixed(2))
}
};
const isBreach = memory.heapUsed > this.thresholdBytes;
if (isBreach) {
this.breachCount++;
}
// Add to chart data if chart is enabled
if (this.options.chart) {
this.chartData.push({
time: Date.now(),
heapUsed: memory.heapUsed,
rss: memory.rss
});
// Keep only the last maxChartPoints
if (this.chartData.length > this.maxChartPoints) {
this.chartData.shift();
}
}
// Check disk usage
checkDiskUsage((err, disk) => {
if (err) {
this.log('error', `Disk check failed: ${err.message}`);
// Continue without disk data
this.log('data', data);
this.display(data, isBreach);
return;
}
// Add disk data to the data object
data.disk = disk;
this.log('data', data);
this.display(data, isBreach);
});
} catch (error) {
this.log('error', `Error checking memory: ${error.message}`);
console.error(`${colors.red}Error checking memory: ${error.message}${colors.reset}`);
}
}
renderChart() {
if (!this.options.chart || this.chartData.length < 2) return '';
const chartHeight = 8;
const chartWidth = 50;
const thresholdMB = this.thresholdBytes / (1024 * 1024);
// Find min/max values for scaling
const heapValues = this.chartData.map(d => d.heapUsed / (1024 * 1024));
const rssValues = this.chartData.map(d => d.rss / (1024 * 1024));
const maxHeap = Math.max(...heapValues, thresholdMB);
const maxRss = Math.max(...rssValues, thresholdMB);
const maxValue = Math.max(maxHeap, maxRss);
let chart = '\n';
chart += `${colors.cyan}Memory Chart (${chartWidth}s window)${colors.reset}\n`;
// Render chart lines
for (let i = chartHeight - 1; i >= 0; i--) {
const level = (maxValue * i) / chartHeight;
const thresholdLevel = (thresholdMB * chartHeight) / maxValue;
let line = `${level.toFixed(0).padStart(3)}MB `;
for (let j = 0; j < this.chartData.length; j++) {
const heapLevel = (heapValues[j] * chartHeight) / maxValue;
const rssLevel = (rssValues[j] * chartHeight) / maxValue;
if (i === Math.floor(thresholdLevel)) {
line += colors.yellow + '─' + colors.reset; // Threshold line
} else if (i <= Math.floor(heapLevel) && i <= Math.floor(rssLevel)) {
line += colors.red + '█' + colors.reset; // Both heap and RSS
} else if (i <= Math.floor(heapLevel)) {
line += colors.green + '█' + colors.reset; // Heap only
} else if (i <= Math.floor(rssLevel)) {
line += colors.blue + '█' + colors.reset; // RSS only
} else {
line += ' '; // Empty
}
}
chart += line + '\n';
}
// Add legend
chart += `${colors.gray} ${colors.green}█${colors.reset} Heap ${colors.blue}█${colors.reset} RSS ${colors.red}█${colors.reset} Both ${colors.yellow}─${colors.reset} Threshold\n`;
return chart;
}
display(data, isBreach) {
if (this.options.json) {
const jsonOutput = {
timestamp: data.timestamp,
memory: data.memory,
cpu: data.cpu,
disk: data.disk,
threshold: this.thresholdBytes,
breachCount: this.breachCount,
isBreach,
monitoringChild: this.monitoringChild,
monitoringPid: this.monitoringPid,
formatted: {
heapUsed: formatBytes(data.memory.heapUsed),
heapTotal: formatBytes(data.memory.heapTotal),
rss: formatBytes(data.memory.rss),
threshold: formatBytes(this.thresholdBytes)
}
};
console.log(JSON.stringify(jsonOutput));
return;
}
// Compute how many lines we printed last time to clear them (3 status lines + optional chart lines)
const baseLines = 3;
const chartLines = this.options.chart ? 11 : 0; // renderChart() always prints 11 lines when chart enabled
const clearLines = '\r\x1b[K' + '\x1b[1A'.repeat(baseLines + chartLines);
process.stdout.write(clearLines);
// Prepare formatted strings
const heapUsedColor = isBreach ? colors.red : colors.green;
const status = isBreach ? '⚠️ THRESHOLD BREACH' : '✅ Normal';
const statusColor = isBreach ? colors.red : colors.green;
let processInfo = 'Self';
if (this.monitoringPid) {
processInfo = `PID:${data.pid}`;
} else if (this.monitoringChild) {
processInfo = `PID:${data.pid}`;
}
// Disk formatting (might be undefined)
let diskLine = `${colors.gray}Disk:${colors.reset} N/A`;
if (data.disk) {
const driveLabel = data.disk.drive || '/';
const freeGB = (data.disk.free / (1024 ** 3)).toFixed(1);
const totalGB = (data.disk.total / (1024 ** 3)).toFixed(1);
const usagePercent = ((data.disk.used / data.disk.total) * 100).toFixed(1);
diskLine = `${colors.gray}Disk:${colors.reset} Free: ${colors.cyan}${freeGB} GB${colors.reset} / ${totalGB} GB (${usagePercent}%) | Drive: ${driveLabel}`;
}
// Compose lines
const memoryLine = `${colors.cyan}Memory:${colors.reset} HeapUsed: ${heapUsedColor}${formatBytes(data.memory.heapUsed)}${colors.reset} | RSS: ${colors.magenta}${formatBytes(data.memory.rss)}${colors.reset} | ${colors.gray}Process:${colors.reset} ${colors.magenta}${processInfo}${colors.reset} | ${colors.gray}Threshold:${colors.reset} ${colors.yellow}${formatBytes(this.thresholdBytes)}${colors.reset} | ${colors.gray}Breaches:${colors.reset} ${colors.cyan}${this.breachCount}${colors.reset} | ${colors.gray}Status:${colors.reset} ${statusColor}${status}${colors.reset}`;
const cpuLine = `${colors.gray}CPU:${colors.reset} ${colors.yellow}${data.cpu.percent.toFixed(2)}%${colors.reset}`;
process.stdout.write(`${memoryLine}\n${cpuLine}\n${diskLine}`);
// Add chart if enabled
if (this.options.chart) {
process.stdout.write(this.renderChart());
}
}
log(level, message) {
const timestamp = formatTime(new Date());
const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${typeof message === 'object' ? JSON.stringify(message) : message}\n`;
if (this.logStream) {
this.logStream.write(logEntry);
}
if (level === 'error') {
console.error(`${colors.red}${logEntry.trim()}${colors.reset}`);
}
}
captureSnapshot() {
if (!this.options.captureSnapshot) return;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const label = this.options.snapshotLabel ? `-${this.options.snapshotLabel}` : '';
const filename = `heap-snapshot-${timestamp}${label}.json`;
// Always capture the monitoring tool's memory for snapshots
const memory = process.memoryUsage();
const snapshot = {
timestamp: new Date().toISOString(),
label: this.options.snapshotLabel || 'manual-capture',
memory: memory,
formatted: {
heapUsed: formatBytes(memory.heapUsed),
heapTotal: formatBytes(memory.heapTotal),
rss: formatBytes(memory.rss),
external: formatBytes(memory.external),
arrayBuffers: formatBytes(memory.arrayBuffers)
},
session: {
startTime: this.startTime.toISOString(),
breachCount: this.breachCount,
threshold: this.thresholdBytes,
monitoringChild: this.monitoringChild,
monitoringPid: this.monitoringPid,
childCommand: this.options.command,
targetPid: this.options.pid
}
};
try {
fs.writeFileSync(filename, JSON.stringify(snapshot, null, 2));
this.log('info', `Heap snapshot saved to ${filename}`);
console.log(`${colors.green}✓ Heap snapshot saved to ${filename}${colors.reset}`);
} catch (error) {
this.log('error', `Failed to save snapshot: ${error.message}`);
console.error(`${colors.red}✗ Failed to save snapshot: ${error.message}${colors.reset}`);
}
}
}
// Main execution
const main = async () => {
const options = parseArgs();
if (options.help) {
showHelp();
return;
}
// Handle snapshot capture without monitoring
if (options.captureSnapshot && !options.interval) {
const monitor = new MemoryMonitor(options);
monitor.captureSnapshot();
return;
}
const monitor = new MemoryMonitor(options);
// Handle graceful shutdown
const cleanup = () => {
console.log('\n'); // New line after the status display
monitor.stop();
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.on('SIGUSR2', () => {
// Allow manual snapshot capture via SIGUSR2
monitor.captureSnapshot();
});
// Start monitoring
await monitor.start();
// If snapshot capture is requested, do it after a short delay
if (options.captureSnapshot) {
setTimeout(() => {
monitor.captureSnapshot();
}, 2000);
}
};
// Run the tool
if (require.main === module) {
main().catch(error => {
console.error(`${colors.red}Fatal error: ${error.message}${colors.reset}`);
process.exit(1);
});
}
module.exports = { MemoryMonitor, formatBytes, formatTime };