@rog0x/mcp-perf-tools 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +210 -0
- package/dist/tools/benchmark.d.ts +27 -0
- package/dist/tools/benchmark.js +82 -0
- package/dist/tools/big-o-estimator.d.ts +13 -0
- package/dist/tools/big-o-estimator.js +80 -0
- package/dist/tools/bundle-analyzer.d.ts +25 -0
- package/dist/tools/bundle-analyzer.js +172 -0
- package/dist/tools/load-tester.d.ts +36 -0
- package/dist/tools/load-tester.js +185 -0
- package/dist/tools/memory-analyzer.d.ts +22 -0
- package/dist/tools/memory-analyzer.js +115 -0
- package/package.json +20 -0
- package/src/index.ts +257 -0
- package/src/tools/benchmark.ts +113 -0
- package/src/tools/big-o-estimator.ts +175 -0
- package/src/tools/bundle-analyzer.ts +195 -0
- package/src/tools/load-tester.ts +216 -0
- package/src/tools/memory-analyzer.ts +145 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as zlib from "node:zlib";
|
|
4
|
+
|
|
5
|
+
export interface ModuleInfo {
|
|
6
|
+
filePath: string;
|
|
7
|
+
sizeBytes: number;
|
|
8
|
+
sizeKB: number;
|
|
9
|
+
/** Percentage of total bundle size */
|
|
10
|
+
percentage: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TreeShakingOpportunity {
|
|
14
|
+
filePath: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BundleAnalysis {
|
|
19
|
+
filePath: string;
|
|
20
|
+
rawSizeBytes: number;
|
|
21
|
+
rawSizeKB: number;
|
|
22
|
+
gzipEstimateBytes: number;
|
|
23
|
+
gzipEstimateKB: number;
|
|
24
|
+
compressionRatio: number;
|
|
25
|
+
moduleCount: number;
|
|
26
|
+
largestModules: ModuleInfo[];
|
|
27
|
+
treeShakingOpportunities: TreeShakingOpportunity[];
|
|
28
|
+
summary: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect import/require patterns to approximate module boundaries.
|
|
33
|
+
* This is a heuristic for bundled JS files.
|
|
34
|
+
*/
|
|
35
|
+
function detectModules(
|
|
36
|
+
content: string,
|
|
37
|
+
bundlePath: string
|
|
38
|
+
): { modules: ModuleInfo[]; treeShaking: TreeShakingOpportunity[] } {
|
|
39
|
+
const modules: ModuleInfo[] = [];
|
|
40
|
+
const treeShaking: TreeShakingOpportunity[] = [];
|
|
41
|
+
const totalSize = Buffer.byteLength(content, "utf-8");
|
|
42
|
+
|
|
43
|
+
// Look for common bundle patterns:
|
|
44
|
+
// Webpack: /***/ "./path/to/module.js": or numbered modules
|
|
45
|
+
// Rollup: define blocks
|
|
46
|
+
// Generic: large comment blocks separating modules
|
|
47
|
+
const webpackModulePattern =
|
|
48
|
+
/\/\*{3}\/ ["']([^"']+)["']:|\/\*{3}\/ (\d+)/g;
|
|
49
|
+
const requirePattern =
|
|
50
|
+
/require\(["']([^"']+)["']\)/g;
|
|
51
|
+
const importPattern =
|
|
52
|
+
/(?:import|export)\s+.*?from\s+["']([^"']+)["']/g;
|
|
53
|
+
|
|
54
|
+
const moduleNames = new Set<string>();
|
|
55
|
+
|
|
56
|
+
let match: RegExpExecArray | null;
|
|
57
|
+
|
|
58
|
+
// Webpack-style modules
|
|
59
|
+
while ((match = webpackModulePattern.exec(content)) !== null) {
|
|
60
|
+
moduleNames.add(match[1] || match[2]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// require() calls
|
|
64
|
+
while ((match = requirePattern.exec(content)) !== null) {
|
|
65
|
+
moduleNames.add(match[1]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ES import/export
|
|
69
|
+
while ((match = importPattern.exec(content)) !== null) {
|
|
70
|
+
moduleNames.add(match[1]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If we found module references, create approximate entries
|
|
74
|
+
for (const name of moduleNames) {
|
|
75
|
+
// Estimate module size by finding its content boundaries (rough heuristic)
|
|
76
|
+
const idx = content.indexOf(name);
|
|
77
|
+
if (idx === -1) continue;
|
|
78
|
+
|
|
79
|
+
// Rough estimate: count bytes between this reference and the next or end
|
|
80
|
+
const estimatedSize = Math.round(totalSize / Math.max(moduleNames.size, 1));
|
|
81
|
+
|
|
82
|
+
modules.push({
|
|
83
|
+
filePath: name,
|
|
84
|
+
sizeBytes: estimatedSize,
|
|
85
|
+
sizeKB: Math.round((estimatedSize / 1024) * 100) / 100,
|
|
86
|
+
percentage:
|
|
87
|
+
Math.round((estimatedSize / totalSize) * 100 * 100) / 100,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// If no patterns matched, treat whole file as single module
|
|
92
|
+
if (modules.length === 0) {
|
|
93
|
+
modules.push({
|
|
94
|
+
filePath: path.basename(bundlePath),
|
|
95
|
+
sizeBytes: totalSize,
|
|
96
|
+
sizeKB: Math.round((totalSize / 1024) * 100) / 100,
|
|
97
|
+
percentage: 100,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tree-shaking opportunity detection
|
|
102
|
+
// Look for unused exports, side-effect patterns, etc.
|
|
103
|
+
const sideEffectPatterns = [
|
|
104
|
+
{ pattern: /\bconsole\.(log|warn|info|debug)\b/g, reason: "console statements found (side effects)" },
|
|
105
|
+
{ pattern: /\bwindow\.\w+\s*=/g, reason: "global window property assignment (side effect)" },
|
|
106
|
+
{ pattern: /\bglobal\.\w+\s*=/g, reason: "global property assignment (side effect)" },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
for (const { pattern, reason } of sideEffectPatterns) {
|
|
110
|
+
const sideMatch = pattern.exec(content);
|
|
111
|
+
if (sideMatch) {
|
|
112
|
+
treeShaking.push({ filePath: bundlePath, reason });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check for duplicate-looking module names (possible duplicates in bundle)
|
|
117
|
+
const baseNames = new Map<string, string[]>();
|
|
118
|
+
for (const name of moduleNames) {
|
|
119
|
+
const base = path.basename(name);
|
|
120
|
+
if (!baseNames.has(base)) baseNames.set(base, []);
|
|
121
|
+
baseNames.get(base)!.push(name);
|
|
122
|
+
}
|
|
123
|
+
for (const [base, paths] of baseNames) {
|
|
124
|
+
if (paths.length > 1) {
|
|
125
|
+
treeShaking.push({
|
|
126
|
+
filePath: base,
|
|
127
|
+
reason: `Possible duplicate: "${base}" appears ${paths.length} times with different paths`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { modules, treeShaking };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function analyzeBundle(filePath: string): BundleAnalysis {
|
|
136
|
+
const resolvedPath = path.resolve(filePath);
|
|
137
|
+
|
|
138
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
139
|
+
throw new Error(`File not found: ${resolvedPath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const content = fs.readFileSync(resolvedPath, "utf-8");
|
|
143
|
+
const rawSizeBytes = Buffer.byteLength(content, "utf-8");
|
|
144
|
+
const rawSizeKB = Math.round((rawSizeBytes / 1024) * 100) / 100;
|
|
145
|
+
|
|
146
|
+
// Gzip estimate
|
|
147
|
+
const gzipped = zlib.gzipSync(content, { level: 6 });
|
|
148
|
+
const gzipEstimateBytes = gzipped.length;
|
|
149
|
+
const gzipEstimateKB =
|
|
150
|
+
Math.round((gzipEstimateBytes / 1024) * 100) / 100;
|
|
151
|
+
const compressionRatio =
|
|
152
|
+
rawSizeBytes > 0
|
|
153
|
+
? Math.round((gzipEstimateBytes / rawSizeBytes) * 100 * 100) / 100
|
|
154
|
+
: 0;
|
|
155
|
+
|
|
156
|
+
const { modules, treeShaking } = detectModules(content, resolvedPath);
|
|
157
|
+
|
|
158
|
+
// Sort modules by size descending
|
|
159
|
+
modules.sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
160
|
+
const largestModules = modules.slice(0, 10);
|
|
161
|
+
|
|
162
|
+
const summary = [
|
|
163
|
+
`Bundle Analysis: ${path.basename(resolvedPath)}`,
|
|
164
|
+
` Raw Size: ${rawSizeKB} KB (${rawSizeBytes} bytes)`,
|
|
165
|
+
` Gzip Estimate: ${gzipEstimateKB} KB (${gzipEstimateBytes} bytes)`,
|
|
166
|
+
` Compression: ${compressionRatio}%`,
|
|
167
|
+
` Modules Detected: ${modules.length}`,
|
|
168
|
+
"",
|
|
169
|
+
"Largest Modules:",
|
|
170
|
+
...largestModules.map(
|
|
171
|
+
(m, i) =>
|
|
172
|
+
` ${i + 1}. ${m.filePath} — ${m.sizeKB} KB (${m.percentage}%)`
|
|
173
|
+
),
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
if (treeShaking.length > 0) {
|
|
177
|
+
summary.push("", "Tree-Shaking Opportunities:");
|
|
178
|
+
for (const opp of treeShaking) {
|
|
179
|
+
summary.push(` - ${opp.filePath}: ${opp.reason}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
filePath: resolvedPath,
|
|
185
|
+
rawSizeBytes,
|
|
186
|
+
rawSizeKB,
|
|
187
|
+
gzipEstimateBytes,
|
|
188
|
+
gzipEstimateKB,
|
|
189
|
+
compressionRatio,
|
|
190
|
+
moduleCount: modules.length,
|
|
191
|
+
largestModules,
|
|
192
|
+
treeShakingOpportunities: treeShaking,
|
|
193
|
+
summary: summary.join("\n"),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
|
|
6
|
+
export interface LoadTestConfig {
|
|
7
|
+
url: string;
|
|
8
|
+
totalRequests: number;
|
|
9
|
+
concurrency: number;
|
|
10
|
+
method?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
body?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RequestResult {
|
|
17
|
+
statusCode: number | null;
|
|
18
|
+
durationMs: number;
|
|
19
|
+
error: string | null;
|
|
20
|
+
byteLength: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LoadTestResult {
|
|
24
|
+
url: string;
|
|
25
|
+
totalRequests: number;
|
|
26
|
+
concurrency: number;
|
|
27
|
+
successCount: number;
|
|
28
|
+
errorCount: number;
|
|
29
|
+
errorRate: number;
|
|
30
|
+
totalDurationMs: number;
|
|
31
|
+
throughputReqPerSec: number;
|
|
32
|
+
avgResponseMs: number;
|
|
33
|
+
minResponseMs: number;
|
|
34
|
+
maxResponseMs: number;
|
|
35
|
+
medianResponseMs: number;
|
|
36
|
+
p95ResponseMs: number;
|
|
37
|
+
p99ResponseMs: number;
|
|
38
|
+
statusCodeDistribution: Record<string, number>;
|
|
39
|
+
totalBytesReceived: number;
|
|
40
|
+
summary: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function percentile(sorted: number[], p: number): number {
|
|
44
|
+
if (sorted.length === 0) return 0;
|
|
45
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
46
|
+
const lower = Math.floor(idx);
|
|
47
|
+
const upper = Math.ceil(idx);
|
|
48
|
+
if (lower === upper) return sorted[lower];
|
|
49
|
+
return sorted[lower] + (sorted[upper] - sorted[lower]) * (idx - lower);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeRequest(config: LoadTestConfig): Promise<RequestResult> {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const parsedUrl = new URL(config.url);
|
|
55
|
+
const isHttps = parsedUrl.protocol === "https:";
|
|
56
|
+
const transport = isHttps ? https : http;
|
|
57
|
+
|
|
58
|
+
const startTime = performance.now();
|
|
59
|
+
const timeout = config.timeoutMs ?? 30000;
|
|
60
|
+
|
|
61
|
+
const options: http.RequestOptions = {
|
|
62
|
+
hostname: parsedUrl.hostname,
|
|
63
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
64
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
65
|
+
method: config.method ?? "GET",
|
|
66
|
+
headers: config.headers ?? {},
|
|
67
|
+
timeout,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const req = transport.request(options, (res) => {
|
|
71
|
+
const chunks: Buffer[] = [];
|
|
72
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
73
|
+
res.on("end", () => {
|
|
74
|
+
const durationMs = performance.now() - startTime;
|
|
75
|
+
const body = Buffer.concat(chunks);
|
|
76
|
+
resolve({
|
|
77
|
+
statusCode: res.statusCode ?? null,
|
|
78
|
+
durationMs,
|
|
79
|
+
error: null,
|
|
80
|
+
byteLength: body.length,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
res.on("error", (err) => {
|
|
84
|
+
resolve({
|
|
85
|
+
statusCode: res.statusCode ?? null,
|
|
86
|
+
durationMs: performance.now() - startTime,
|
|
87
|
+
error: err.message,
|
|
88
|
+
byteLength: 0,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
req.on("error", (err) => {
|
|
94
|
+
resolve({
|
|
95
|
+
statusCode: null,
|
|
96
|
+
durationMs: performance.now() - startTime,
|
|
97
|
+
error: err.message,
|
|
98
|
+
byteLength: 0,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
req.on("timeout", () => {
|
|
103
|
+
req.destroy();
|
|
104
|
+
resolve({
|
|
105
|
+
statusCode: null,
|
|
106
|
+
durationMs: performance.now() - startTime,
|
|
107
|
+
error: "Request timed out",
|
|
108
|
+
byteLength: 0,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (config.body) {
|
|
113
|
+
req.write(config.body);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function runLoadTest(
|
|
121
|
+
config: LoadTestConfig
|
|
122
|
+
): Promise<LoadTestResult> {
|
|
123
|
+
const { totalRequests, concurrency } = config;
|
|
124
|
+
const results: RequestResult[] = [];
|
|
125
|
+
|
|
126
|
+
const overallStart = performance.now();
|
|
127
|
+
|
|
128
|
+
// Process requests in batches of `concurrency`
|
|
129
|
+
let sent = 0;
|
|
130
|
+
while (sent < totalRequests) {
|
|
131
|
+
const batchSize = Math.min(concurrency, totalRequests - sent);
|
|
132
|
+
const batch: Promise<RequestResult>[] = [];
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < batchSize; i++) {
|
|
135
|
+
batch.push(makeRequest(config));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const batchResults = await Promise.all(batch);
|
|
139
|
+
results.push(...batchResults);
|
|
140
|
+
sent += batchSize;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const overallEnd = performance.now();
|
|
144
|
+
const totalDurationMs = overallEnd - overallStart;
|
|
145
|
+
|
|
146
|
+
// Compute metrics
|
|
147
|
+
const durations = results.map((r) => r.durationMs);
|
|
148
|
+
const sortedDurations = [...durations].sort((a, b) => a - b);
|
|
149
|
+
|
|
150
|
+
const successCount = results.filter(
|
|
151
|
+
(r) => r.error === null && r.statusCode !== null && r.statusCode < 400
|
|
152
|
+
).length;
|
|
153
|
+
const errorCount = totalRequests - successCount;
|
|
154
|
+
const errorRate =
|
|
155
|
+
Math.round((errorCount / totalRequests) * 100 * 100) / 100;
|
|
156
|
+
|
|
157
|
+
const statusDist: Record<string, number> = {};
|
|
158
|
+
for (const r of results) {
|
|
159
|
+
const key = r.error ? "error" : String(r.statusCode);
|
|
160
|
+
statusDist[key] = (statusDist[key] ?? 0) + 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const totalBytesReceived = results.reduce((s, r) => s + r.byteLength, 0);
|
|
164
|
+
const throughputReqPerSec =
|
|
165
|
+
totalDurationMs > 0 ? (totalRequests / totalDurationMs) * 1000 : 0;
|
|
166
|
+
|
|
167
|
+
const avgResponseMs =
|
|
168
|
+
durations.reduce((a, b) => a + b, 0) / durations.length;
|
|
169
|
+
|
|
170
|
+
const result: LoadTestResult = {
|
|
171
|
+
url: config.url,
|
|
172
|
+
totalRequests,
|
|
173
|
+
concurrency,
|
|
174
|
+
successCount,
|
|
175
|
+
errorCount,
|
|
176
|
+
errorRate,
|
|
177
|
+
totalDurationMs,
|
|
178
|
+
throughputReqPerSec: Math.round(throughputReqPerSec * 100) / 100,
|
|
179
|
+
avgResponseMs: Math.round(avgResponseMs * 100) / 100,
|
|
180
|
+
minResponseMs: Math.round(sortedDurations[0] * 100) / 100,
|
|
181
|
+
maxResponseMs:
|
|
182
|
+
Math.round(sortedDurations[sortedDurations.length - 1] * 100) / 100,
|
|
183
|
+
medianResponseMs: Math.round(percentile(sortedDurations, 50) * 100) / 100,
|
|
184
|
+
p95ResponseMs: Math.round(percentile(sortedDurations, 95) * 100) / 100,
|
|
185
|
+
p99ResponseMs: Math.round(percentile(sortedDurations, 99) * 100) / 100,
|
|
186
|
+
statusCodeDistribution: statusDist,
|
|
187
|
+
totalBytesReceived,
|
|
188
|
+
summary: "",
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
result.summary = [
|
|
192
|
+
`Load Test Results: ${config.method ?? "GET"} ${config.url}`,
|
|
193
|
+
` Total Requests: ${totalRequests}`,
|
|
194
|
+
` Concurrency: ${concurrency}`,
|
|
195
|
+
` Total Duration: ${totalDurationMs.toFixed(0)} ms`,
|
|
196
|
+
` Throughput: ${result.throughputReqPerSec} req/s`,
|
|
197
|
+
"",
|
|
198
|
+
"Response Times:",
|
|
199
|
+
` Min: ${result.minResponseMs} ms`,
|
|
200
|
+
` Max: ${result.maxResponseMs} ms`,
|
|
201
|
+
` Avg: ${result.avgResponseMs} ms`,
|
|
202
|
+
` Median: ${result.medianResponseMs} ms`,
|
|
203
|
+
` P95: ${result.p95ResponseMs} ms`,
|
|
204
|
+
` P99: ${result.p99ResponseMs} ms`,
|
|
205
|
+
"",
|
|
206
|
+
`Success: ${successCount} Errors: ${errorCount} Error Rate: ${errorRate}%`,
|
|
207
|
+
`Bytes Received: ${totalBytesReceived}`,
|
|
208
|
+
"",
|
|
209
|
+
"Status Code Distribution:",
|
|
210
|
+
...Object.entries(statusDist).map(
|
|
211
|
+
([code, count]) => ` ${code}: ${count}`
|
|
212
|
+
),
|
|
213
|
+
].join("\n");
|
|
214
|
+
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
export interface MemorySnapshot {
|
|
2
|
+
timestamp: number;
|
|
3
|
+
heapUsedMB: number;
|
|
4
|
+
heapTotalMB: number;
|
|
5
|
+
rssMB: number;
|
|
6
|
+
externalMB: number;
|
|
7
|
+
arrayBuffersMB: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MemoryAnalysis {
|
|
11
|
+
current: MemorySnapshot;
|
|
12
|
+
history: MemorySnapshot[];
|
|
13
|
+
trend: "stable" | "growing" | "shrinking" | "insufficient_data";
|
|
14
|
+
growthRateMBPerSec: number | null;
|
|
15
|
+
potentialLeak: boolean;
|
|
16
|
+
summary: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const toMB = (bytes: number): number =>
|
|
20
|
+
Math.round((bytes / 1024 / 1024) * 100) / 100;
|
|
21
|
+
|
|
22
|
+
const snapshotHistory: MemorySnapshot[] = [];
|
|
23
|
+
|
|
24
|
+
export function takeMemorySnapshot(): MemorySnapshot {
|
|
25
|
+
const mem = process.memoryUsage();
|
|
26
|
+
const snapshot: MemorySnapshot = {
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
heapUsedMB: toMB(mem.heapUsed),
|
|
29
|
+
heapTotalMB: toMB(mem.heapTotal),
|
|
30
|
+
rssMB: toMB(mem.rss),
|
|
31
|
+
externalMB: toMB(mem.external),
|
|
32
|
+
arrayBuffersMB: toMB(mem.arrayBuffers),
|
|
33
|
+
};
|
|
34
|
+
snapshotHistory.push(snapshot);
|
|
35
|
+
return snapshot;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getSnapshotHistory(): MemorySnapshot[] {
|
|
39
|
+
return [...snapshotHistory];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearSnapshotHistory(): void {
|
|
43
|
+
snapshotHistory.length = 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function linearRegression(
|
|
47
|
+
xs: number[],
|
|
48
|
+
ys: number[]
|
|
49
|
+
): { slope: number; intercept: number; r2: number } {
|
|
50
|
+
const n = xs.length;
|
|
51
|
+
if (n < 2) return { slope: 0, intercept: ys[0] ?? 0, r2: 0 };
|
|
52
|
+
|
|
53
|
+
const sumX = xs.reduce((a, b) => a + b, 0);
|
|
54
|
+
const sumY = ys.reduce((a, b) => a + b, 0);
|
|
55
|
+
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
|
|
56
|
+
const sumX2 = xs.reduce((a, x) => a + x * x, 0);
|
|
57
|
+
|
|
58
|
+
const denom = n * sumX2 - sumX * sumX;
|
|
59
|
+
if (denom === 0) return { slope: 0, intercept: sumY / n, r2: 0 };
|
|
60
|
+
|
|
61
|
+
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
62
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
63
|
+
|
|
64
|
+
// R-squared
|
|
65
|
+
const meanY = sumY / n;
|
|
66
|
+
const ssTot = ys.reduce((a, y) => a + (y - meanY) ** 2, 0);
|
|
67
|
+
const ssRes = ys.reduce((a, y, i) => a + (y - (slope * xs[i] + intercept)) ** 2, 0);
|
|
68
|
+
const r2 = ssTot === 0 ? 1 : 1 - ssRes / ssTot;
|
|
69
|
+
|
|
70
|
+
return { slope, intercept, r2 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function analyzeMemory(): MemoryAnalysis {
|
|
74
|
+
const current = takeMemorySnapshot();
|
|
75
|
+
const history = getSnapshotHistory();
|
|
76
|
+
|
|
77
|
+
let trend: MemoryAnalysis["trend"] = "insufficient_data";
|
|
78
|
+
let growthRateMBPerSec: number | null = null;
|
|
79
|
+
let potentialLeak = false;
|
|
80
|
+
|
|
81
|
+
if (history.length >= 3) {
|
|
82
|
+
const baseTime = history[0].timestamp;
|
|
83
|
+
const xs = history.map((s) => (s.timestamp - baseTime) / 1000); // seconds
|
|
84
|
+
const ys = history.map((s) => s.heapUsedMB);
|
|
85
|
+
|
|
86
|
+
const reg = linearRegression(xs, ys);
|
|
87
|
+
growthRateMBPerSec = reg.slope;
|
|
88
|
+
|
|
89
|
+
// Classify trend based on slope significance and R^2
|
|
90
|
+
const totalTimeSpan = xs[xs.length - 1] - xs[0];
|
|
91
|
+
if (totalTimeSpan < 0.1) {
|
|
92
|
+
trend = "insufficient_data";
|
|
93
|
+
} else if (Math.abs(reg.slope) < 0.01) {
|
|
94
|
+
trend = "stable";
|
|
95
|
+
} else if (reg.slope > 0) {
|
|
96
|
+
trend = "growing";
|
|
97
|
+
} else {
|
|
98
|
+
trend = "shrinking";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Potential leak: consistent growth with high correlation
|
|
102
|
+
if (trend === "growing" && reg.r2 > 0.7 && reg.slope > 0.1) {
|
|
103
|
+
potentialLeak = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lines: string[] = [
|
|
108
|
+
"Memory Analysis:",
|
|
109
|
+
` Heap Used: ${current.heapUsedMB} MB`,
|
|
110
|
+
` Heap Total: ${current.heapTotalMB} MB`,
|
|
111
|
+
` RSS: ${current.rssMB} MB`,
|
|
112
|
+
` External: ${current.externalMB} MB`,
|
|
113
|
+
` Array Buffers: ${current.arrayBuffersMB} MB`,
|
|
114
|
+
` Snapshots: ${history.length}`,
|
|
115
|
+
` Trend: ${trend}`,
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
if (growthRateMBPerSec !== null) {
|
|
119
|
+
lines.push(` Growth Rate: ${growthRateMBPerSec.toFixed(4)} MB/s`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (potentialLeak) {
|
|
123
|
+
lines.push(" WARNING: Potential memory leak detected (consistent heap growth).");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
current,
|
|
128
|
+
history,
|
|
129
|
+
trend,
|
|
130
|
+
growthRateMBPerSec,
|
|
131
|
+
potentialLeak,
|
|
132
|
+
summary: lines.join("\n"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatSnapshot(snap: MemorySnapshot): string {
|
|
137
|
+
return [
|
|
138
|
+
`Memory Snapshot (${new Date(snap.timestamp).toISOString()}):`,
|
|
139
|
+
` Heap Used: ${snap.heapUsedMB} MB`,
|
|
140
|
+
` Heap Total: ${snap.heapTotalMB} MB`,
|
|
141
|
+
` RSS: ${snap.rssMB} MB`,
|
|
142
|
+
` External: ${snap.externalMB} MB`,
|
|
143
|
+
` Array Buffers: ${snap.arrayBuffersMB} MB`,
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"resolveJsonModule": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|