chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,977 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Memory Leak Detector for Trigger.dev Webapp
|
||||
*
|
||||
* This script starts the server, performs memory snapshots, executes API requests,
|
||||
* and analyzes memory usage patterns to detect potential memory leaks.
|
||||
*
|
||||
* Usage: node memory-leak-detector.js [options]
|
||||
*/
|
||||
|
||||
const { spawn, exec: _exec } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const { performance } = require("perf_hooks");
|
||||
|
||||
class MemoryLeakDetector {
|
||||
constructor(options = {}) {
|
||||
// Create timestamped directory for this run
|
||||
const runTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const baseDir = options.baseDir || "./.memory-snapshots";
|
||||
const runDir = `${baseDir}/${runTimestamp}-${options.label || "memory-leak-detector"}`;
|
||||
|
||||
this.options = {
|
||||
// Server configuration
|
||||
serverPort: options.serverPort || 3030,
|
||||
serverStartTimeout: options.serverStartTimeout || 30000,
|
||||
|
||||
// Sentry configuration
|
||||
sentryDsn: options.sentryDsn || "",
|
||||
|
||||
// Memory snapshot configuration
|
||||
heapSnapshotPath: `${runDir}/snapshots`,
|
||||
adminToken: options.adminToken, // Bearer token for admin API access
|
||||
|
||||
apiKey: options.apiKey,
|
||||
|
||||
// Testing configuration
|
||||
warmupRequests: options.warmupRequests || 10,
|
||||
testRequests: options.testRequests || 100,
|
||||
requestDelay: options.requestDelay || 50, // ms between requests
|
||||
requestTimeout: options.requestTimeout || 5000,
|
||||
|
||||
// API endpoints to test (configurable)
|
||||
apiEndpoints: options.apiEndpoints || ["/api/v1/runs"],
|
||||
postApiEndpoints: options.postApiEndpoints || ["/api/v1/mock"],
|
||||
|
||||
// Memory analysis thresholds
|
||||
memoryLeakThreshold: options.memoryLeakThreshold || 50, // MB increase
|
||||
heapGrowthThreshold: options.heapGrowthThreshold || 0.2, // 20% growth
|
||||
|
||||
// Output configuration
|
||||
verbose: options.verbose || false,
|
||||
outputFile: `${runDir}/memory-leak-report.json`,
|
||||
runDir: runDir,
|
||||
runTimestamp: runTimestamp,
|
||||
|
||||
...options,
|
||||
};
|
||||
|
||||
this.serverProcess = null;
|
||||
this.results = {
|
||||
startTime: new Date().toISOString(),
|
||||
runTimestamp: runTimestamp,
|
||||
runDirectory: runDir,
|
||||
serverInfo: {},
|
||||
snapshots: [],
|
||||
testPhases: [], // Track each phase of testing
|
||||
totalRequests: {
|
||||
warmup: 0,
|
||||
phase1: 0,
|
||||
phase2: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
},
|
||||
memoryAnalysis: {},
|
||||
recommendations: [],
|
||||
};
|
||||
}
|
||||
|
||||
log(message, level = "info") {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
|
||||
if (level === "error") {
|
||||
console.error(`${prefix} ${message}`);
|
||||
} else if (this.options.verbose || level === "info") {
|
||||
console.log(`${prefix} ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSnapshotDir() {
|
||||
if (!fs.existsSync(this.options.runDir)) {
|
||||
fs.mkdirSync(this.options.runDir, { recursive: true });
|
||||
this.log(`Created run directory: ${this.options.runDir}`);
|
||||
}
|
||||
if (!fs.existsSync(this.options.heapSnapshotPath)) {
|
||||
fs.mkdirSync(this.options.heapSnapshotPath, { recursive: true });
|
||||
this.log(`Created snapshot directory: ${this.options.heapSnapshotPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async takeHeapSnapshot(label) {
|
||||
const timestamp = Date.now();
|
||||
const filename = `heap-${label}-${timestamp}.heapsnapshot`;
|
||||
const filepath = path.join(this.options.heapSnapshotPath, filename);
|
||||
|
||||
try {
|
||||
let snapshotData;
|
||||
|
||||
if (this.options.adminToken) {
|
||||
this.log(`Running GC before snapshot...`);
|
||||
await this.runGc();
|
||||
|
||||
// Use the admin API endpoint to get actual V8 heap snapshot
|
||||
this.log(`Taking V8 heap snapshot via admin API: ${label}...`);
|
||||
|
||||
snapshotData = await this.takeV8HeapSnapshot(label, filepath, timestamp);
|
||||
} else {
|
||||
// Fallback to basic memory usage info
|
||||
this.log(`Taking basic memory snapshot: ${label} (no admin token provided)...`);
|
||||
|
||||
const memUsage = process.memoryUsage();
|
||||
snapshotData = {
|
||||
label,
|
||||
timestamp,
|
||||
filename: filename.replace(".heapsnapshot", ".json"),
|
||||
filepath: filepath.replace(".heapsnapshot", ".json"),
|
||||
processMemory: memUsage,
|
||||
type: "basic",
|
||||
};
|
||||
|
||||
fs.writeFileSync(snapshotData.filepath, JSON.stringify(snapshotData, null, 2));
|
||||
}
|
||||
|
||||
this.results.snapshots.push(snapshotData);
|
||||
this.log(`Snapshot completed: ${label}`);
|
||||
|
||||
return snapshotData;
|
||||
} catch (error) {
|
||||
this.log(`Failed to take heap snapshot: ${error.message}`, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async takeV8HeapSnapshot(label, filepath, timestamp) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestOptions = {
|
||||
hostname: "localhost",
|
||||
port: this.options.serverPort,
|
||||
path: "/admin/api/v1/snapshot",
|
||||
method: "GET",
|
||||
timeout: 120000, // Heap snapshots can take a while
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.adminToken}`,
|
||||
"User-Agent": "memory-leak-detector/1.0",
|
||||
},
|
||||
};
|
||||
|
||||
const req = http.request(requestOptions, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Admin API returned ${res.statusCode}: ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const writeStream = fs.createWriteStream(filepath);
|
||||
let downloadedBytes = 0;
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
downloadedBytes += chunk.length;
|
||||
this.log(`Downloaded ${downloadedBytes} bytes`);
|
||||
writeStream.write(chunk);
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
writeStream.end();
|
||||
|
||||
const snapshotData = {
|
||||
label,
|
||||
timestamp,
|
||||
filename: path.basename(filepath),
|
||||
filepath,
|
||||
size: downloadedBytes,
|
||||
type: "v8-heapsnapshot",
|
||||
};
|
||||
|
||||
this.log(`V8 heap snapshot saved: ${Math.round(downloadedBytes / 1024 / 1024)}MB`);
|
||||
resolve(snapshotData);
|
||||
});
|
||||
|
||||
res.on("error", (error) => {
|
||||
writeStream.destroy();
|
||||
fs.unlink(filepath, () => {}); // Clean up partial file
|
||||
reject(error);
|
||||
});
|
||||
|
||||
writeStream.on("error", (error) => {
|
||||
res.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
reject(new Error(`Failed to connect to admin API: ${error.message}`));
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Admin API request timeout"));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async runGc() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestOptions = {
|
||||
hostname: "localhost",
|
||||
port: this.options.serverPort,
|
||||
path: "/admin/api/v1/gc",
|
||||
method: "GET",
|
||||
timeout: 120000, // GC can take a while
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.adminToken}`,
|
||||
"User-Agent": "memory-leak-detector/1.0",
|
||||
},
|
||||
};
|
||||
|
||||
const req = http.request(requestOptions, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Admin API returned ${res.statusCode}: ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
this.log(`GC run completed`);
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
res.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
reject(new Error(`Failed to connect to admin API: ${error.message}`));
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Admin API request timeout"));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async startServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.log("Starting server...");
|
||||
|
||||
// First, build the project
|
||||
const buildProcess = spawn("npm", ["run", "build"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: this.options.verbose ? "inherit" : "pipe",
|
||||
});
|
||||
|
||||
buildProcess.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Build failed with code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
this.log("Build completed, starting server...");
|
||||
|
||||
const nodePath = path.resolve(process.cwd(), "../../node_modules/.pnpm/node_modules");
|
||||
|
||||
this.log(`Using NODE_PATH: ${nodePath}`);
|
||||
|
||||
// Start the server with memory inspection flags
|
||||
this.serverProcess = spawn(
|
||||
"node",
|
||||
["--max-old-space-size=16384", "--expose-gc", "./build/server.js"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: nodePath,
|
||||
PORT: this.options.serverPort,
|
||||
// Disable Sentry to prevent memory leaks from it
|
||||
SENTRY_DSN: this.options.sentryDsn,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let serverReady = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!serverReady) {
|
||||
this.serverProcess.kill();
|
||||
reject(new Error("Server failed to start within timeout"));
|
||||
}
|
||||
}, this.options.serverStartTimeout);
|
||||
|
||||
this.serverProcess.stdout.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
if (this.options.verbose) {
|
||||
console.log("SERVER:", output.trim());
|
||||
}
|
||||
|
||||
if (output.includes("server ready") && !serverReady) {
|
||||
serverReady = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
this.results.serverInfo = {
|
||||
port: this.options.serverPort,
|
||||
startTime: Date.now(),
|
||||
nodeVersion: process.version,
|
||||
};
|
||||
|
||||
this.log(`Server started on port ${this.options.serverPort}`);
|
||||
|
||||
// Wait a bit more for server to fully initialize
|
||||
setTimeout(() => resolve(), 2000);
|
||||
}
|
||||
});
|
||||
|
||||
this.serverProcess.stderr.on("data", (data) => {
|
||||
const error = data.toString();
|
||||
if (this.options.verbose || error.includes("Error")) {
|
||||
console.error("SERVER ERROR:", error.trim());
|
||||
}
|
||||
});
|
||||
|
||||
this.serverProcess.on("close", (code) => {
|
||||
if (!serverReady && code !== 0) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Server process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async makeRequest(endpoint, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestOptions = {
|
||||
hostname: "localhost",
|
||||
port: this.options.serverPort,
|
||||
path: endpoint,
|
||||
method: options.method || "GET",
|
||||
timeout: this.options.requestTimeout,
|
||||
headers: {
|
||||
"User-Agent": "memory-leak-detector/1.0",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.options.apiKey}`,
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
const req = http.request(requestOptions, (res) => {
|
||||
let data = "";
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: data,
|
||||
endpoint: endpoint,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
reject({ error, endpoint });
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject({ error: new Error("Request timeout"), endpoint });
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async performRequestPhase(phaseName, numRequests) {
|
||||
this.log(`Starting ${phaseName} phase with ${numRequests} GET requests...`);
|
||||
|
||||
const startTime = performance.now();
|
||||
let successfulRequests = 0;
|
||||
let failedRequests = 0;
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < numRequests; i++) {
|
||||
const endpoint = this.options.apiEndpoints[i % this.options.apiEndpoints.length];
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest(endpoint);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
successfulRequests++;
|
||||
} else {
|
||||
failedRequests++;
|
||||
errors.push({
|
||||
endpoint,
|
||||
statusCode: response.statusCode,
|
||||
error: `HTTP ${response.statusCode}`,
|
||||
phase: phaseName,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failedRequests++;
|
||||
errors.push({
|
||||
endpoint: error.endpoint,
|
||||
error: error.error?.message || "Unknown error",
|
||||
phase: phaseName,
|
||||
});
|
||||
}
|
||||
|
||||
// Add delay between requests
|
||||
if (i < numRequests - 1) {
|
||||
await this.delay(this.options.requestDelay);
|
||||
}
|
||||
|
||||
// Progress reporting
|
||||
if (this.options.verbose && numRequests > 25 && (i + 1) % 25 === 0) {
|
||||
this.log(`${phaseName}: ${i + 1}/${numRequests} requests completed`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log(`Continuing with ${phaseName} phase with ${numRequests} POST requests...`);
|
||||
|
||||
for (let i = 0; i < numRequests; i++) {
|
||||
const endpoint = this.options.postApiEndpoints[i % this.options.postApiEndpoints.length];
|
||||
|
||||
try {
|
||||
// Send a LARGE body to try and trigger a memory leak
|
||||
const response = await this.makeRequest(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
Array.from({ length: 1000 }, (_, index) => ({
|
||||
id: index,
|
||||
name: `Mock ${index}`,
|
||||
description: `Mock ${index} description`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
uuid: crypto.randomUUID(),
|
||||
}))
|
||||
),
|
||||
});
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
successfulRequests++;
|
||||
} else {
|
||||
failedRequests++;
|
||||
errors.push({
|
||||
endpoint,
|
||||
statusCode: response.statusCode,
|
||||
error: `HTTP ${response.statusCode}`,
|
||||
phase: phaseName,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failedRequests++;
|
||||
errors.push({
|
||||
endpoint: error.endpoint,
|
||||
error: error.error?.message || "Unknown error",
|
||||
phase: phaseName,
|
||||
});
|
||||
}
|
||||
|
||||
// Add delay between requests
|
||||
if (i < numRequests - 1) {
|
||||
await this.delay(this.options.requestDelay);
|
||||
}
|
||||
|
||||
// Progress reporting
|
||||
if (this.options.verbose && numRequests > 25 && (i + 1) % 25 === 0) {
|
||||
this.log(`${phaseName}: ${i + 1}/${numRequests} requests completed`);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
const phaseResults = {
|
||||
phase: phaseName,
|
||||
total: numRequests,
|
||||
successful: successfulRequests,
|
||||
failed: failedRequests,
|
||||
errors: errors.slice(0, 5), // Keep only first 5 errors per phase
|
||||
duration: Math.round(duration),
|
||||
requestsPerSecond: Math.round((numRequests / duration) * 1000),
|
||||
};
|
||||
|
||||
this.results.testPhases.push(phaseResults);
|
||||
|
||||
// Update totals
|
||||
this.results.totalRequests.successful += successfulRequests;
|
||||
this.results.totalRequests.failed += failedRequests;
|
||||
this.results.totalRequests.errors.push(...errors.slice(0, 3));
|
||||
|
||||
if (phaseName === "warmup") {
|
||||
this.results.totalRequests.warmup = numRequests;
|
||||
} else if (phaseName === "load-test-1") {
|
||||
this.results.totalRequests.phase1 = numRequests;
|
||||
} else if (phaseName === "load-test-2") {
|
||||
this.results.totalRequests.phase2 = numRequests;
|
||||
}
|
||||
|
||||
this.log(
|
||||
`${phaseName} completed: ${successfulRequests}/${numRequests} successful (${Math.round(
|
||||
duration
|
||||
)}ms total)`
|
||||
);
|
||||
|
||||
return phaseResults;
|
||||
}
|
||||
|
||||
analyzeMemoryUsage() {
|
||||
if (this.results.snapshots.length < 3) {
|
||||
this.log("Need at least 3 snapshots to analyze memory usage", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot1 = this.results.snapshots[0]; // after warmup
|
||||
const snapshot2 = this.results.snapshots[1]; // after first load test
|
||||
const snapshot3 = this.results.snapshots[2]; // after second load test
|
||||
|
||||
let analysis = {};
|
||||
|
||||
// Handle different snapshot types
|
||||
if (
|
||||
snapshot1.type === "v8-heapsnapshot" &&
|
||||
snapshot2.type === "v8-heapsnapshot" &&
|
||||
snapshot3.type === "v8-heapsnapshot"
|
||||
) {
|
||||
const size1 = snapshot1.size || 0;
|
||||
const size2 = snapshot2.size || 0;
|
||||
const size3 = snapshot3.size || 0;
|
||||
|
||||
const growth1to2 = size2 - size1;
|
||||
const growth2to3 = size3 - size2;
|
||||
const totalGrowth = size3 - size1;
|
||||
|
||||
const growth1to2Percent = size1 > 0 ? (growth1to2 / size1) * 100 : 0;
|
||||
const growth2to3Percent = size2 > 0 ? (growth2to3 / size2) * 100 : 0;
|
||||
const totalGrowthPercent = size1 > 0 ? (totalGrowth / size1) * 100 : 0;
|
||||
|
||||
analysis = {
|
||||
snapshotAnalysis: {
|
||||
type: "v8-heapsnapshot",
|
||||
phase1: {
|
||||
size: Math.round(size1 / 1024 / 1024),
|
||||
file: snapshot1.filename,
|
||||
},
|
||||
phase2: {
|
||||
size: Math.round(size2 / 1024 / 1024),
|
||||
file: snapshot2.filename,
|
||||
growthFromPhase1: Math.round(growth1to2 / 1024 / 1024),
|
||||
growthPercentFromPhase1: Math.round(growth1to2Percent * 100) / 100,
|
||||
},
|
||||
phase3: {
|
||||
size: Math.round(size3 / 1024 / 1024),
|
||||
file: snapshot3.filename,
|
||||
growthFromPhase2: Math.round(growth2to3 / 1024 / 1024),
|
||||
growthPercentFromPhase2: Math.round(growth2to3Percent * 100) / 100,
|
||||
},
|
||||
total: {
|
||||
growth: Math.round(totalGrowth / 1024 / 1024),
|
||||
growthPercent: Math.round(totalGrowthPercent * 100) / 100,
|
||||
},
|
||||
},
|
||||
snapshots: this.results.snapshots.length,
|
||||
snapshotPaths: this.results.snapshots.map((s) => s.filepath),
|
||||
};
|
||||
|
||||
// Use total growth for recommendations
|
||||
var heapGrowth = totalGrowth;
|
||||
var heapGrowthPercent = totalGrowthPercent;
|
||||
} else if (snapshot1.processMemory && snapshot2.processMemory && snapshot3.processMemory) {
|
||||
// Traditional process memory analysis with 3 snapshots
|
||||
const heap1 = snapshot1.processMemory.heapUsed;
|
||||
const heap2 = snapshot2.processMemory.heapUsed;
|
||||
const heap3 = snapshot3.processMemory.heapUsed;
|
||||
const rss1 = snapshot1.processMemory.rss;
|
||||
const rss2 = snapshot2.processMemory.rss;
|
||||
const rss3 = snapshot3.processMemory.rss;
|
||||
|
||||
const heapGrowth1to2 = heap2 - heap1;
|
||||
const heapGrowth2to3 = heap3 - heap2;
|
||||
const totalHeapGrowth = heap3 - heap1;
|
||||
const rssGrowth1to2 = rss2 - rss1;
|
||||
const rssGrowth2to3 = rss3 - rss2;
|
||||
const totalRssGrowth = rss3 - rss1;
|
||||
|
||||
analysis = {
|
||||
heapUsage: {
|
||||
phase1: Math.round(heap1 / 1024 / 1024),
|
||||
phase2: Math.round(heap2 / 1024 / 1024),
|
||||
phase3: Math.round(heap3 / 1024 / 1024),
|
||||
growth1to2: Math.round(heapGrowth1to2 / 1024 / 1024),
|
||||
growth2to3: Math.round(heapGrowth2to3 / 1024 / 1024),
|
||||
totalGrowth: Math.round(totalHeapGrowth / 1024 / 1024),
|
||||
totalGrowthPercent: Math.round((totalHeapGrowth / heap1) * 100 * 100) / 100,
|
||||
},
|
||||
rssUsage: {
|
||||
phase1: Math.round(rss1 / 1024 / 1024),
|
||||
phase2: Math.round(rss2 / 1024 / 1024),
|
||||
phase3: Math.round(rss3 / 1024 / 1024),
|
||||
growth1to2: Math.round(rssGrowth1to2 / 1024 / 1024),
|
||||
growth2to3: Math.round(rssGrowth2to3 / 1024 / 1024),
|
||||
totalGrowth: Math.round(totalRssGrowth / 1024 / 1024),
|
||||
totalGrowthPercent: Math.round((totalRssGrowth / rss1) * 100 * 100) / 100,
|
||||
},
|
||||
snapshots: this.results.snapshots.length,
|
||||
};
|
||||
|
||||
var heapGrowth = totalHeapGrowth;
|
||||
var heapGrowthPercent = (totalHeapGrowth / heap1) * 100;
|
||||
} else {
|
||||
this.log("Mixed or incompatible snapshot types - cannot analyze memory growth", "warn");
|
||||
analysis = {
|
||||
error: "Incompatible snapshot types",
|
||||
snapshots: this.results.snapshots.length,
|
||||
snapshotTypes: this.results.snapshots.map((s) => ({ label: s.label, type: s.type })),
|
||||
};
|
||||
this.results.memoryAnalysis = analysis;
|
||||
return;
|
||||
}
|
||||
|
||||
this.results.memoryAnalysis = analysis;
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations = [];
|
||||
|
||||
if (Math.abs(heapGrowth) > this.options.memoryLeakThreshold * 1024 * 1024) {
|
||||
recommendations.push({
|
||||
type: "warning",
|
||||
message: `Significant heap growth detected: ${Math.round(
|
||||
heapGrowth / 1024 / 1024
|
||||
)}MB increase`,
|
||||
suggestion: "Consider investigating object retention and event listener cleanup",
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.abs(heapGrowthPercent) > this.options.heapGrowthThreshold * 100) {
|
||||
recommendations.push({
|
||||
type: "warning",
|
||||
message: `High heap growth percentage: ${heapGrowthPercent.toFixed(1)}%`,
|
||||
suggestion: "Review memory allocation patterns and garbage collection behavior",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.results.totalRequests.failed >
|
||||
(this.results.totalRequests.phase1 + this.results.totalRequests.phase2) * 0.1
|
||||
) {
|
||||
recommendations.push({
|
||||
type: "error",
|
||||
message: `High request failure rate: ${this.results.totalRequests.failed}/${
|
||||
this.results.totalRequests.phase1 + this.results.totalRequests.phase2
|
||||
}`,
|
||||
suggestion: "Fix failing endpoints before analyzing memory patterns",
|
||||
});
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push({
|
||||
type: "info",
|
||||
message: "No obvious memory leaks detected in this test run",
|
||||
suggestion: "Consider running longer tests or testing different API endpoints",
|
||||
});
|
||||
}
|
||||
|
||||
this.results.recommendations = recommendations;
|
||||
|
||||
// Log analysis results
|
||||
this.log("=== Memory Analysis Results ===");
|
||||
|
||||
if (this.results.memoryAnalysis.snapshotAnalysis) {
|
||||
const analysis = this.results.memoryAnalysis.snapshotAnalysis;
|
||||
this.log(`V8 Heap Snapshot Analysis:`);
|
||||
this.log(` Phase 1 (after warmup): ${analysis.phase1.size}MB`);
|
||||
this.log(
|
||||
` Phase 2 (after load test 1): ${analysis.phase2.size}MB (${
|
||||
analysis.phase2.growthPercentFromPhase1 > 0 ? "+" : ""
|
||||
}${analysis.phase2.growthPercentFromPhase1}%)`
|
||||
);
|
||||
this.log(
|
||||
` Phase 3 (after load test 2): ${analysis.phase3.size}MB (${
|
||||
analysis.phase3.growthPercentFromPhase2 > 0 ? "+" : ""
|
||||
}${analysis.phase3.growthPercentFromPhase2}%)`
|
||||
);
|
||||
this.log(
|
||||
` Total Growth: ${analysis.total.growthPercent > 0 ? "+" : ""}${
|
||||
analysis.total.growth
|
||||
}MB (${analysis.total.growthPercent}%)`
|
||||
);
|
||||
|
||||
this.log(`\nSnapshot files saved:`);
|
||||
this.log(` 1. ${analysis.phase1.file}`);
|
||||
this.log(` 2. ${analysis.phase2.file}`);
|
||||
this.log(` 3. ${analysis.phase3.file}`);
|
||||
this.log(`\n💡 Analyze snapshots in Chrome DevTools:`);
|
||||
this.log(` 1. Open Chrome DevTools → Memory tab`);
|
||||
this.log(` 2. Load all 3 .heapsnapshot files`);
|
||||
this.log(` 3. Compare snapshots to identify memory leaks`);
|
||||
this.log(` 4. Focus on comparing snapshot 1 vs 3 for overall growth`);
|
||||
} else if (this.results.memoryAnalysis.heapUsage) {
|
||||
const heap = this.results.memoryAnalysis.heapUsage;
|
||||
const rss = this.results.memoryAnalysis.rssUsage;
|
||||
this.log(`Heap Usage Analysis:`);
|
||||
this.log(` Phase 1: ${heap.phase1}MB`);
|
||||
this.log(
|
||||
` Phase 2: ${heap.phase2}MB (${heap.growth1to2 > 0 ? "+" : ""}${heap.growth1to2}MB)`
|
||||
);
|
||||
this.log(
|
||||
` Phase 3: ${heap.phase3}MB (${heap.growth2to3 > 0 ? "+" : ""}${heap.growth2to3}MB)`
|
||||
);
|
||||
this.log(
|
||||
` Total Growth: ${heap.totalGrowthPercent > 0 ? "+" : ""}${heap.totalGrowth}MB (${
|
||||
heap.totalGrowthPercent
|
||||
}%)`
|
||||
);
|
||||
|
||||
this.log(`RSS Usage Analysis:`);
|
||||
this.log(` Phase 1: ${rss.phase1}MB`);
|
||||
this.log(` Phase 2: ${rss.phase2}MB (${rss.growth1to2 > 0 ? "+" : ""}${rss.growth1to2}MB)`);
|
||||
this.log(` Phase 3: ${rss.phase3}MB (${rss.growth2to3 > 0 ? "+" : ""}${rss.growth2to3}MB)`);
|
||||
this.log(
|
||||
` Total Growth: ${rss.totalGrowthPercent > 0 ? "+" : ""}${rss.totalGrowth}MB (${
|
||||
rss.totalGrowthPercent
|
||||
}%)`
|
||||
);
|
||||
}
|
||||
|
||||
recommendations.forEach((rec, i) => {
|
||||
this.log(
|
||||
`${i + 1}. [${rec.type.toUpperCase()}] ${rec.message}`,
|
||||
rec.type === "error" ? "error" : "info"
|
||||
);
|
||||
this.log(` Suggestion: ${rec.suggestion}`, "info");
|
||||
});
|
||||
}
|
||||
|
||||
async generateReport() {
|
||||
const reportData = {
|
||||
...this.results,
|
||||
endTime: new Date().toISOString(),
|
||||
configuration: {
|
||||
serverPort: this.options.serverPort,
|
||||
testRequests: this.options.testRequests,
|
||||
warmupRequests: this.options.warmupRequests,
|
||||
apiEndpoints: this.options.apiEndpoints,
|
||||
memoryLeakThreshold: this.options.memoryLeakThreshold,
|
||||
heapGrowthThreshold: this.options.heapGrowthThreshold,
|
||||
adminToken: this.options.adminToken ? "[REDACTED]" : null,
|
||||
runDirectory: this.options.runDir,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
fs.writeFileSync(this.options.outputFile, JSON.stringify(reportData, null, 2));
|
||||
this.log(`Report saved to: ${this.options.outputFile}`);
|
||||
} catch (error) {
|
||||
this.log(`Failed to save report: ${error.message}`, "error");
|
||||
}
|
||||
|
||||
return reportData;
|
||||
}
|
||||
|
||||
async delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
if (this.serverProcess && !this.serverProcess.killed) {
|
||||
this.log("Stopping server...");
|
||||
this.serverProcess.kill("SIGTERM");
|
||||
|
||||
// Wait a bit for graceful shutdown
|
||||
await this.delay(2000);
|
||||
|
||||
if (!this.serverProcess.killed) {
|
||||
this.serverProcess.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run() {
|
||||
try {
|
||||
this.log("=== Memory Leak Detection Started ===");
|
||||
this.log(`Run directory: ${this.options.runDir}`);
|
||||
|
||||
await this.ensureSnapshotDir();
|
||||
await this.startServer();
|
||||
|
||||
// Wait for server to fully initialize
|
||||
await this.delay(3000);
|
||||
|
||||
// Phase 1: Warmup requests + first snapshot
|
||||
this.log("\n=== Phase 1: Warmup ===");
|
||||
await this.performRequestPhase("warmup", this.options.warmupRequests);
|
||||
|
||||
// Force GC and wait before snapshot
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await this.delay(1000);
|
||||
}
|
||||
await this.takeHeapSnapshot("after-warmup");
|
||||
|
||||
// Phase 2: First load test + second snapshot
|
||||
this.log("\n=== Phase 2: Load Test 1 ===");
|
||||
await this.performRequestPhase("load-test-1", this.options.testRequests);
|
||||
|
||||
// Force GC and wait before snapshot
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await this.delay(1000);
|
||||
}
|
||||
await this.takeHeapSnapshot("after-load-test-1");
|
||||
|
||||
// Phase 3: Second load test + third snapshot
|
||||
this.log("\n=== Phase 3: Load Test 2 ===");
|
||||
await this.performRequestPhase("load-test-2", this.options.testRequests);
|
||||
|
||||
// Force GC and wait before final snapshot
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
await this.delay(2000);
|
||||
}
|
||||
await this.takeHeapSnapshot("after-load-test-2");
|
||||
|
||||
// Analyze results
|
||||
this.analyzeMemoryUsage();
|
||||
|
||||
// Generate report
|
||||
await this.generateReport();
|
||||
|
||||
this.log("\n=== Memory Leak Detection Completed ===");
|
||||
this.log(`📁 All results saved to: ${this.options.runDir}`);
|
||||
this.log(`📊 Report: ${this.options.outputFile}`);
|
||||
this.log(`📈 Snapshots: ${this.options.heapSnapshotPath}`);
|
||||
|
||||
if (
|
||||
this.results.snapshots.length === 3 &&
|
||||
this.results.snapshots[0].type === "v8-heapsnapshot"
|
||||
) {
|
||||
this.log(`\n🔍 Next steps:`);
|
||||
this.log(`1. Analyze snapshots in Chrome DevTools Memory tab`);
|
||||
this.log(`2. Compare snapshot 1 vs 3 to identify memory leaks`);
|
||||
this.log(`3. Look for growing Sentry-related objects`);
|
||||
this.log(`4. Run with SENTRY_DSN enabled to compare results`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Detection failed: ${error.message}`, "error");
|
||||
throw error;
|
||||
} finally {
|
||||
await this.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {};
|
||||
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const arg = args[i];
|
||||
const value = args[i + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--port":
|
||||
options.serverPort = parseInt(value);
|
||||
break;
|
||||
case "--requests":
|
||||
options.testRequests = parseInt(value);
|
||||
break;
|
||||
case "--delay":
|
||||
options.requestDelay = parseInt(value);
|
||||
break;
|
||||
case "--endpoints":
|
||||
options.apiEndpoints = value.split(",");
|
||||
break;
|
||||
case "--threshold":
|
||||
options.memoryLeakThreshold = parseFloat(value);
|
||||
break;
|
||||
case "--output":
|
||||
options.outputFile = value;
|
||||
break;
|
||||
case "--token":
|
||||
options.adminToken = value;
|
||||
break;
|
||||
case "--api-key":
|
||||
options.apiKey = value;
|
||||
break;
|
||||
case "--sentry-dsn":
|
||||
options.sentryDsn = value;
|
||||
break;
|
||||
case "--label":
|
||||
options.label = value;
|
||||
break;
|
||||
case "--verbose":
|
||||
options.verbose = true;
|
||||
i--; // No value for this flag
|
||||
break;
|
||||
case "--help":
|
||||
console.log(`
|
||||
Memory Leak Detector Usage:
|
||||
|
||||
node memory-leak-detector.js [options]
|
||||
|
||||
Options:
|
||||
--port <number> Server port (default: 3030)
|
||||
--requests <number> Number of test requests (default: 100)
|
||||
--delay <ms> Delay between requests (default: 50)
|
||||
--endpoints <list> Comma-separated API endpoints to test
|
||||
--label <string> Label for the run
|
||||
--threshold <MB> Memory leak threshold in MB (default: 50)
|
||||
--token <string> Admin Bearer token for V8 heap snapshots
|
||||
--api-key <string> API key for API requests
|
||||
--sentry-dsn <string> Sentry DSN for memory leak detection
|
||||
--output <file> Output report file (default: memory-leak-report.json)
|
||||
--verbose Enable verbose logging
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
node memory-leak-detector.js --verbose --requests 200
|
||||
node memory-leak-detector.js --token "your-admin-token" --api-key "your-api-key" --verbose
|
||||
node memory-leak-detector.js --endpoints "/api/v1/whoami,/api/v1/projects" --threshold 25
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Main execution
|
||||
if (require.main === module) {
|
||||
const options = parseArgs();
|
||||
const detector = new MemoryLeakDetector(options);
|
||||
|
||||
detector
|
||||
.run()
|
||||
.then(() => {
|
||||
console.log("\n✅ Memory leak detection completed successfully!");
|
||||
console.log(`📁 Results directory: ${detector.options.runDir}`);
|
||||
console.log(`📊 Report: ${detector.options.outputFile}`);
|
||||
console.log(`📈 Snapshots: ${detector.options.heapSnapshotPath}`);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n❌ Memory leak detection failed:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = MemoryLeakDetector;
|
||||
Reference in New Issue
Block a user