express-performance-toolkit 1.0.0 → 2.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 +119 -76
- package/dashboard-ui/README.md +73 -0
- package/dashboard-ui/eslint.config.js +23 -0
- package/dashboard-ui/index.html +13 -0
- package/dashboard-ui/package-lock.json +3382 -0
- package/dashboard-ui/package.json +32 -0
- package/dashboard-ui/src/App.css +184 -0
- package/dashboard-ui/src/App.tsx +182 -0
- package/dashboard-ui/src/components/BlockedModal.tsx +108 -0
- package/dashboard-ui/src/components/CachePanel.tsx +45 -0
- package/dashboard-ui/src/components/HealthCharts.tsx +142 -0
- package/dashboard-ui/src/components/InsightsPanel.tsx +49 -0
- package/dashboard-ui/src/components/KpiGrid.tsx +178 -0
- package/dashboard-ui/src/components/LiveLogs.tsx +76 -0
- package/dashboard-ui/src/components/Login.tsx +83 -0
- package/dashboard-ui/src/components/RoutesTable.tsx +110 -0
- package/dashboard-ui/src/hooks/useMetrics.ts +131 -0
- package/dashboard-ui/src/index.css +652 -0
- package/dashboard-ui/src/main.tsx +10 -0
- package/dashboard-ui/src/pages/InsightsPage.tsx +42 -0
- package/dashboard-ui/src/pages/LogsPage.tsx +26 -0
- package/dashboard-ui/src/pages/OverviewPage.tsx +32 -0
- package/dashboard-ui/src/pages/RoutesPage.tsx +26 -0
- package/dashboard-ui/src/utils/formatters.ts +27 -0
- package/dashboard-ui/tsconfig.app.json +28 -0
- package/dashboard-ui/tsconfig.json +7 -0
- package/dashboard-ui/tsconfig.node.json +26 -0
- package/dashboard-ui/vite.config.ts +12 -0
- package/dist/analyzer.d.ts +6 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +70 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/dashboard/dashboardRouter.d.ts +4 -4
- package/dist/dashboard/dashboardRouter.d.ts.map +1 -1
- package/dist/dashboard/dashboardRouter.js +67 -21
- package/dist/dashboard/dashboardRouter.js.map +1 -1
- package/dist/dashboard-ui/assets/index-CX-zE-Qy.css +1 -0
- package/dist/dashboard-ui/assets/index-Q9TGkd8n.js +41 -0
- package/dist/dashboard-ui/index.html +14 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -11
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +3 -3
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +167 -9
- package/dist/logger.js.map +1 -1
- package/dist/queryHelper.d.ts.map +1 -1
- package/dist/queryHelper.js +1 -0
- package/dist/queryHelper.js.map +1 -1
- package/dist/rateLimit.d.ts +5 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +67 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/store.d.ts +9 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +147 -25
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -1
- package/example/server.ts +68 -37
- package/package.json +9 -6
- package/src/analyzer.ts +78 -0
- package/src/dashboard/dashboardRouter.ts +88 -23
- package/src/index.ts +70 -30
- package/src/logger.ts +177 -13
- package/src/queryHelper.ts +2 -0
- package/src/rateLimit.ts +86 -0
- package/src/store.ts +136 -27
- package/src/types.ts +98 -0
- package/tests/analyzer.test.ts +108 -0
- package/tests/auth.test.ts +79 -0
- package/tests/bandwidth.test.ts +72 -0
- package/tests/integration.test.ts +51 -54
- package/tests/rateLimit.test.ts +57 -0
- package/tests/store.test.ts +37 -18
- package/tsconfig.json +1 -0
- package/src/dashboard/dashboard.html +0 -756
package/src/rateLimit.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { MetricsStore } from "./store";
|
|
3
|
+
import { RateLimitOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
interface RateLimitTracker {
|
|
6
|
+
count: number;
|
|
7
|
+
resetTime: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createRateLimiter(
|
|
11
|
+
store: MetricsStore,
|
|
12
|
+
options: RateLimitOptions | boolean | undefined
|
|
13
|
+
) {
|
|
14
|
+
const enabled = typeof options === "boolean" ? options : options?.enabled !== false;
|
|
15
|
+
if (!enabled) {
|
|
16
|
+
return (req: Request, res: Response, next: NextFunction) => next();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const opts = typeof options === "object" ? options : {};
|
|
20
|
+
const windowMs = opts.windowMs || 60000;
|
|
21
|
+
const max = opts.max || 100;
|
|
22
|
+
const statusCode = opts.statusCode || 429;
|
|
23
|
+
const message = opts.message || "Too many requests, please try again later.";
|
|
24
|
+
|
|
25
|
+
// In-memory store mapping IP addresses to tracking objects
|
|
26
|
+
const hits = new Map<string, RateLimitTracker>();
|
|
27
|
+
|
|
28
|
+
// Cleanup interval to prevent memory leaks from inactive IPs
|
|
29
|
+
setInterval(() => {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
for (const [ip, tracker] of hits.entries()) {
|
|
32
|
+
if (now > tracker.resetTime) {
|
|
33
|
+
hits.delete(ip);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}, windowMs).unref(); // .unref() ensures this interval doesn't keep the Node process alive
|
|
37
|
+
|
|
38
|
+
const exclude = opts.exclude || [];
|
|
39
|
+
|
|
40
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
41
|
+
// Check exclusions
|
|
42
|
+
const path = req.path;
|
|
43
|
+
const isExcluded = exclude.some((pattern) => {
|
|
44
|
+
if (typeof pattern === "string") return path.startsWith(pattern);
|
|
45
|
+
if (pattern instanceof RegExp) return pattern.test(path);
|
|
46
|
+
return false;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (isExcluded) return next();
|
|
50
|
+
|
|
51
|
+
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const routeKey = `${req.method} ${req.route ? req.route.path : req.path}`;
|
|
54
|
+
|
|
55
|
+
let tracker = hits.get(ip);
|
|
56
|
+
|
|
57
|
+
if (!tracker || now > tracker.resetTime) {
|
|
58
|
+
// Create new tracker or reset expired tracker
|
|
59
|
+
tracker = {
|
|
60
|
+
count: 1,
|
|
61
|
+
resetTime: now + windowMs,
|
|
62
|
+
};
|
|
63
|
+
hits.set(ip, tracker);
|
|
64
|
+
return next();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
tracker.count++;
|
|
68
|
+
|
|
69
|
+
if (tracker.count > max) {
|
|
70
|
+
store.recordRateLimitHit(routeKey, ip, req.method, req.path);
|
|
71
|
+
|
|
72
|
+
res.setHeader("Retry-After", Math.ceil((tracker.resetTime - now) / 1000));
|
|
73
|
+
res.setHeader("X-RateLimit-Limit", max);
|
|
74
|
+
res.setHeader("X-RateLimit-Remaining", 0);
|
|
75
|
+
res.setHeader("X-RateLimit-Reset", Math.ceil(tracker.resetTime / 1000));
|
|
76
|
+
|
|
77
|
+
res.status(statusCode).send(message);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
res.setHeader("X-RateLimit-Limit", max);
|
|
82
|
+
res.setHeader("X-RateLimit-Remaining", max - tracker.count);
|
|
83
|
+
res.setHeader("X-RateLimit-Reset", Math.ceil(tracker.resetTime / 1000));
|
|
84
|
+
next();
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { LogEntry, Metrics, RouteStats } from
|
|
1
|
+
import { LogEntry, Metrics, RouteStats, BlockedEvent } from "./types";
|
|
2
|
+
import { monitorEventLoopDelay } from "perf_hooks";
|
|
3
|
+
import { analyzeMetrics } from "./analyzer";
|
|
4
|
+
import * as v8 from "v8";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* In-memory metrics store — shared state between all middleware components.
|
|
@@ -6,13 +9,19 @@ import { LogEntry, Metrics, RouteStats } from './types';
|
|
|
6
9
|
*/
|
|
7
10
|
export class MetricsStore {
|
|
8
11
|
private maxLogs: number;
|
|
12
|
+
private maxRoutes: number = 200; // Cap unique routes to prevent memory leaks
|
|
9
13
|
private logs: LogEntry[];
|
|
14
|
+
private blockedEvents: BlockedEvent[] = [];
|
|
15
|
+
private histogram: ReturnType<typeof monitorEventLoopDelay>;
|
|
10
16
|
private stats: {
|
|
11
17
|
totalRequests: number;
|
|
12
18
|
totalResponseTime: number;
|
|
13
19
|
slowRequests: number;
|
|
20
|
+
highQueryRequests: number;
|
|
21
|
+
rateLimitHits: number;
|
|
14
22
|
cacheHits: number;
|
|
15
23
|
cacheMisses: number;
|
|
24
|
+
totalBytesSent: number;
|
|
16
25
|
cacheSize: number;
|
|
17
26
|
statusCodes: Record<number, number>;
|
|
18
27
|
routes: Record<string, RouteStats>;
|
|
@@ -26,21 +35,23 @@ export class MetricsStore {
|
|
|
26
35
|
totalRequests: 0,
|
|
27
36
|
totalResponseTime: 0,
|
|
28
37
|
slowRequests: 0,
|
|
38
|
+
highQueryRequests: 0,
|
|
39
|
+
rateLimitHits: 0,
|
|
29
40
|
cacheHits: 0,
|
|
30
41
|
cacheMisses: 0,
|
|
42
|
+
totalBytesSent: 0,
|
|
31
43
|
cacheSize: 0,
|
|
32
44
|
statusCodes: {},
|
|
33
45
|
routes: {},
|
|
34
46
|
startTime: Date.now(),
|
|
35
47
|
};
|
|
48
|
+
this.histogram = monitorEventLoopDelay({ resolution: 10 });
|
|
49
|
+
this.histogram.enable();
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/** Add a request log entry to the ring buffer. */
|
|
39
|
-
|
|
40
|
-
this.logs.push(
|
|
41
|
-
...entry,
|
|
42
|
-
timestamp: entry.timestamp || Date.now(),
|
|
43
|
-
});
|
|
53
|
+
recordLog(log: LogEntry): void {
|
|
54
|
+
this.logs.push(log);
|
|
44
55
|
|
|
45
56
|
// Ring buffer — drop oldest entries when full
|
|
46
57
|
if (this.logs.length > this.maxLogs) {
|
|
@@ -49,35 +60,101 @@ export class MetricsStore {
|
|
|
49
60
|
|
|
50
61
|
// Update aggregate stats
|
|
51
62
|
this.stats.totalRequests++;
|
|
52
|
-
this.stats.totalResponseTime +=
|
|
63
|
+
this.stats.totalResponseTime += log.responseTime || 0;
|
|
53
64
|
|
|
54
65
|
// Track status codes
|
|
55
|
-
|
|
56
|
-
this.stats.statusCodes[code] = (this.stats.statusCodes[code] || 0) + 1;
|
|
66
|
+
this.stats.totalBytesSent += log.bytesSent;
|
|
57
67
|
|
|
58
68
|
// Track per-route stats
|
|
59
|
-
|
|
69
|
+
// Use normalized path (routePattern) if available, fallback to actual path
|
|
70
|
+
const routeKey = `${log.method} ${log.routePattern || log.path}`;
|
|
71
|
+
|
|
60
72
|
if (!this.stats.routes[routeKey]) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
// Prevent unbounded growth of the routes map
|
|
74
|
+
if (Object.keys(this.stats.routes).length >= this.maxRoutes) {
|
|
75
|
+
// Fallback to a catch-all for excessive new routes
|
|
76
|
+
const othersKey = `${log.method} [Other]`;
|
|
77
|
+
if (!this.stats.routes[othersKey]) {
|
|
78
|
+
this.stats.routes[othersKey] = this.createNewRouteStats();
|
|
79
|
+
}
|
|
80
|
+
this.updateRouteStats(this.stats.routes[othersKey], log);
|
|
81
|
+
} else {
|
|
82
|
+
this.stats.routes[routeKey] = this.createNewRouteStats();
|
|
83
|
+
this.updateRouteStats(this.stats.routes[routeKey], log);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
this.updateRouteStats(this.stats.routes[routeKey], log);
|
|
67
87
|
}
|
|
68
|
-
|
|
88
|
+
|
|
89
|
+
const code = log.statusCode;
|
|
90
|
+
this.stats.statusCodes[code] = (this.stats.statusCodes[code] || 0) + 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private createNewRouteStats(): RouteStats {
|
|
94
|
+
return {
|
|
95
|
+
count: 0,
|
|
96
|
+
totalTime: 0,
|
|
97
|
+
slowCount: 0,
|
|
98
|
+
highQueryCount: 0,
|
|
99
|
+
rateLimitHits: 0,
|
|
100
|
+
avgTime: 0,
|
|
101
|
+
totalBytes: 0,
|
|
102
|
+
avgSize: 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private updateRouteStats(route: RouteStats, log: LogEntry): void {
|
|
69
107
|
route.count++;
|
|
70
|
-
route.totalTime +=
|
|
108
|
+
route.totalTime += log.responseTime;
|
|
109
|
+
route.totalBytes += log.bytesSent;
|
|
71
110
|
route.avgTime = Math.round(route.totalTime / route.count);
|
|
72
|
-
|
|
111
|
+
route.avgSize = Math.round(route.totalBytes / route.count);
|
|
112
|
+
|
|
113
|
+
if (log.slow) {
|
|
114
|
+
this.stats.slowRequests++;
|
|
73
115
|
route.slowCount++;
|
|
74
116
|
}
|
|
117
|
+
|
|
118
|
+
if (log.highQueries) {
|
|
119
|
+
this.stats.highQueryRequests++;
|
|
120
|
+
route.highQueryCount++;
|
|
121
|
+
}
|
|
75
122
|
}
|
|
76
123
|
|
|
77
124
|
recordSlowRequest(): void {
|
|
78
125
|
this.stats.slowRequests++;
|
|
79
126
|
}
|
|
80
127
|
|
|
128
|
+
recordRateLimitHit(
|
|
129
|
+
routeKey: string,
|
|
130
|
+
ip: string,
|
|
131
|
+
method: string,
|
|
132
|
+
path: string,
|
|
133
|
+
): void {
|
|
134
|
+
this.stats.rateLimitHits++;
|
|
135
|
+
|
|
136
|
+
// Track blocked event details
|
|
137
|
+
this.blockedEvents.push({
|
|
138
|
+
ip,
|
|
139
|
+
path,
|
|
140
|
+
method,
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Keep only last 100 blocked events
|
|
145
|
+
if (this.blockedEvents.length > 100) {
|
|
146
|
+
this.blockedEvents.shift();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!this.stats.routes[routeKey]) {
|
|
150
|
+
if (Object.keys(this.stats.routes).length >= this.maxRoutes) {
|
|
151
|
+
return; // Don't track rate limits for new routes if at capacity
|
|
152
|
+
}
|
|
153
|
+
this.stats.routes[routeKey] = this.createNewRouteStats();
|
|
154
|
+
}
|
|
155
|
+
this.stats.routes[routeKey].rateLimitHits++;
|
|
156
|
+
}
|
|
157
|
+
|
|
81
158
|
recordCacheHit(): void {
|
|
82
159
|
this.stats.cacheHits++;
|
|
83
160
|
}
|
|
@@ -90,6 +167,13 @@ export class MetricsStore {
|
|
|
90
167
|
this.stats.cacheSize = size;
|
|
91
168
|
}
|
|
92
169
|
|
|
170
|
+
private calculateCacheHitRate(): number {
|
|
171
|
+
const cacheTotal = this.stats.cacheHits + this.stats.cacheMisses;
|
|
172
|
+
return cacheTotal > 0
|
|
173
|
+
? Math.round((this.stats.cacheHits / cacheTotal) * 100)
|
|
174
|
+
: 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
93
177
|
/** Get all metrics data for the dashboard. */
|
|
94
178
|
getMetrics(): Metrics {
|
|
95
179
|
const avgResponseTime =
|
|
@@ -97,25 +181,44 @@ export class MetricsStore {
|
|
|
97
181
|
? Math.round(this.stats.totalResponseTime / this.stats.totalRequests)
|
|
98
182
|
: 0;
|
|
99
183
|
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
cacheTotal > 0
|
|
103
|
-
? Math.round((this.stats.cacheHits / cacheTotal) * 100)
|
|
104
|
-
: 0;
|
|
184
|
+
const mem = process.memoryUsage();
|
|
185
|
+
const heapStats = v8.getHeapStatistics();
|
|
105
186
|
|
|
106
|
-
|
|
187
|
+
const metrics: Metrics = {
|
|
107
188
|
uptime: Date.now() - this.stats.startTime,
|
|
108
189
|
totalRequests: this.stats.totalRequests,
|
|
109
190
|
avgResponseTime,
|
|
110
191
|
slowRequests: this.stats.slowRequests,
|
|
192
|
+
highQueryRequests: this.stats.highQueryRequests,
|
|
193
|
+
rateLimitHits: this.stats.rateLimitHits,
|
|
111
194
|
cacheHits: this.stats.cacheHits,
|
|
112
195
|
cacheMisses: this.stats.cacheMisses,
|
|
113
|
-
cacheHitRate,
|
|
114
|
-
cacheSize:
|
|
196
|
+
cacheHitRate: this.calculateCacheHitRate(),
|
|
197
|
+
cacheSize: 0, // Will be populated in index.ts if cache is enabled
|
|
198
|
+
totalBytesSent: this.stats.totalBytesSent,
|
|
199
|
+
avgResponseSize:
|
|
200
|
+
this.stats.totalRequests > 0
|
|
201
|
+
? Math.round(this.stats.totalBytesSent / this.stats.totalRequests)
|
|
202
|
+
: 0,
|
|
203
|
+
insights: [], // Placeholder, filled below
|
|
204
|
+
eventLoopLag: Math.round(this.histogram.mean / 1e6),
|
|
205
|
+
memoryUsage: {
|
|
206
|
+
rss: mem.rss,
|
|
207
|
+
heapTotal: mem.heapTotal,
|
|
208
|
+
heapUsed: mem.heapUsed,
|
|
209
|
+
heapLimit: heapStats.heap_size_limit,
|
|
210
|
+
external: mem.external,
|
|
211
|
+
},
|
|
115
212
|
statusCodes: { ...this.stats.statusCodes },
|
|
116
213
|
routes: { ...this.stats.routes },
|
|
117
214
|
recentLogs: this.logs.slice(-100),
|
|
215
|
+
blockedEvents: [...this.blockedEvents],
|
|
118
216
|
};
|
|
217
|
+
|
|
218
|
+
// Generate insights
|
|
219
|
+
metrics.insights = analyzeMetrics(metrics);
|
|
220
|
+
|
|
221
|
+
return metrics;
|
|
119
222
|
}
|
|
120
223
|
|
|
121
224
|
/** Reset all metrics. */
|
|
@@ -124,11 +227,17 @@ export class MetricsStore {
|
|
|
124
227
|
this.stats.totalRequests = 0;
|
|
125
228
|
this.stats.totalResponseTime = 0;
|
|
126
229
|
this.stats.slowRequests = 0;
|
|
230
|
+
this.stats.highQueryRequests = 0;
|
|
231
|
+
this.stats.rateLimitHits = 0;
|
|
127
232
|
this.stats.cacheHits = 0;
|
|
128
233
|
this.stats.cacheMisses = 0;
|
|
129
234
|
this.stats.cacheSize = 0;
|
|
130
235
|
this.stats.statusCodes = {};
|
|
131
236
|
this.stats.routes = {};
|
|
132
237
|
this.stats.startTime = Date.now();
|
|
238
|
+
this.blockedEvents = [];
|
|
239
|
+
this.histogram.disable();
|
|
240
|
+
this.histogram = monitorEventLoopDelay({ resolution: 10 });
|
|
241
|
+
this.histogram.enable();
|
|
133
242
|
}
|
|
134
243
|
}
|
package/src/types.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface LoggerOptions {
|
|
|
43
43
|
slowThreshold?: number;
|
|
44
44
|
/** Log to console (default: true) */
|
|
45
45
|
console?: boolean;
|
|
46
|
+
/** Log to file path (optional) */
|
|
47
|
+
file?: string;
|
|
48
|
+
/** Enable automatic daily log rotation (appends YYYY-MM-DD to filename) (default: false) */
|
|
49
|
+
rotation?: boolean;
|
|
50
|
+
/** Delete log files older than this many days (default: 7). Requires rotation: true */
|
|
51
|
+
maxDays?: number;
|
|
46
52
|
/** Custom log formatter */
|
|
47
53
|
formatter?: (entry: LogEntry) => string;
|
|
48
54
|
}
|
|
@@ -54,11 +60,37 @@ export interface QueryHelperOptions {
|
|
|
54
60
|
threshold?: number;
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
export interface DashboardAuthOptions {
|
|
64
|
+
/** Admin username (default: 'admin') */
|
|
65
|
+
username?: string;
|
|
66
|
+
/** Admin password (default: 'perf-toolkit') */
|
|
67
|
+
password?: string;
|
|
68
|
+
/** Secret key for session cookie (default: 'toolkit-secret') */
|
|
69
|
+
secret?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
export interface DashboardOptions {
|
|
58
73
|
/** Enable dashboard (default: true) */
|
|
59
74
|
enabled?: boolean;
|
|
60
75
|
/** Dashboard mount path (default: '/__perf') */
|
|
61
76
|
path?: string;
|
|
77
|
+
/** Authentication settings. If provided, user must login to see dashboard. */
|
|
78
|
+
auth?: DashboardAuthOptions;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RateLimitOptions {
|
|
82
|
+
/** Enable rate limiting (default: false) */
|
|
83
|
+
enabled?: boolean;
|
|
84
|
+
/** Time window in milliseconds (default: 60000 - 1 minute) */
|
|
85
|
+
windowMs?: number;
|
|
86
|
+
/** Maximum number of requests per windowMs (default: 100) */
|
|
87
|
+
max?: number;
|
|
88
|
+
/** Response status code (default: 429) */
|
|
89
|
+
statusCode?: number;
|
|
90
|
+
/** Response message string or object */
|
|
91
|
+
message?: string | object;
|
|
92
|
+
/** URL patterns to exclude from rate limiting (e.g. ['/__perf*']) */
|
|
93
|
+
exclude?: (string | RegExp)[];
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
export interface ToolkitOptions {
|
|
@@ -72,6 +104,8 @@ export interface ToolkitOptions {
|
|
|
72
104
|
queryHelper?: boolean | QueryHelperOptions;
|
|
73
105
|
/** Performance dashboard */
|
|
74
106
|
dashboard?: boolean | DashboardOptions;
|
|
107
|
+
/** Rate limiting */
|
|
108
|
+
rateLimit?: boolean | RateLimitOptions;
|
|
75
109
|
/** Max log entries to keep in memory (default: 1000) */
|
|
76
110
|
maxLogs?: number;
|
|
77
111
|
}
|
|
@@ -81,22 +115,71 @@ export interface ToolkitOptions {
|
|
|
81
115
|
export interface LogEntry {
|
|
82
116
|
method: string;
|
|
83
117
|
path: string;
|
|
118
|
+
routePattern?: string;
|
|
84
119
|
statusCode: number;
|
|
85
120
|
responseTime: number;
|
|
86
121
|
timestamp: number;
|
|
122
|
+
bytesSent: number;
|
|
87
123
|
slow: boolean;
|
|
88
124
|
cached: boolean;
|
|
89
125
|
queryCount?: number;
|
|
126
|
+
highQueries?: boolean;
|
|
90
127
|
contentLength?: number;
|
|
91
128
|
userAgent?: string;
|
|
92
129
|
ip?: string;
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
export interface BlockedEvent {
|
|
133
|
+
ip: string;
|
|
134
|
+
path: string;
|
|
135
|
+
timestamp: number;
|
|
136
|
+
method: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
95
139
|
export interface RouteStats {
|
|
96
140
|
count: number;
|
|
97
141
|
totalTime: number;
|
|
98
142
|
slowCount: number;
|
|
143
|
+
highQueryCount: number;
|
|
144
|
+
rateLimitHits: number;
|
|
99
145
|
avgTime: number;
|
|
146
|
+
totalBytes: number;
|
|
147
|
+
avgSize: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface Insight {
|
|
151
|
+
type: "info" | "warning" | "error";
|
|
152
|
+
title: string;
|
|
153
|
+
message: string;
|
|
154
|
+
action?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface MetricSnapshot {
|
|
158
|
+
uptime: number;
|
|
159
|
+
totalRequests: number;
|
|
160
|
+
avgResponseTime: number;
|
|
161
|
+
slowRequests: number;
|
|
162
|
+
highQueryRequests: number;
|
|
163
|
+
rateLimitHits: number;
|
|
164
|
+
cacheHits: number;
|
|
165
|
+
cacheMisses: number;
|
|
166
|
+
cacheHitRate: number;
|
|
167
|
+
cacheSize: number;
|
|
168
|
+
totalBytesSent: number;
|
|
169
|
+
avgResponseSize: number;
|
|
170
|
+
insights: Insight[];
|
|
171
|
+
eventLoopLag: number;
|
|
172
|
+
memoryUsage: {
|
|
173
|
+
rss: number;
|
|
174
|
+
heapTotal: number;
|
|
175
|
+
heapUsed: number;
|
|
176
|
+
heapLimit: number; // Added v8 heap limit
|
|
177
|
+
external: number;
|
|
178
|
+
};
|
|
179
|
+
statusCodes: Record<number, number>;
|
|
180
|
+
routes: Record<string, RouteStats>;
|
|
181
|
+
recentLogs: LogEntry[];
|
|
182
|
+
blockedEvents: BlockedEvent[];
|
|
100
183
|
}
|
|
101
184
|
|
|
102
185
|
export interface Metrics {
|
|
@@ -104,13 +187,27 @@ export interface Metrics {
|
|
|
104
187
|
totalRequests: number;
|
|
105
188
|
avgResponseTime: number;
|
|
106
189
|
slowRequests: number;
|
|
190
|
+
highQueryRequests: number;
|
|
191
|
+
rateLimitHits: number;
|
|
107
192
|
cacheHits: number;
|
|
108
193
|
cacheMisses: number;
|
|
109
194
|
cacheHitRate: number;
|
|
110
195
|
cacheSize: number;
|
|
196
|
+
totalBytesSent: number;
|
|
197
|
+
avgResponseSize: number;
|
|
198
|
+
insights: Insight[];
|
|
199
|
+
eventLoopLag: number;
|
|
200
|
+
memoryUsage: {
|
|
201
|
+
rss: number;
|
|
202
|
+
heapTotal: number;
|
|
203
|
+
heapUsed: number;
|
|
204
|
+
heapLimit: number; // Added v8 heap limit
|
|
205
|
+
external: number;
|
|
206
|
+
};
|
|
111
207
|
statusCodes: Record<number, number>;
|
|
112
208
|
routes: Record<string, RouteStats>;
|
|
113
209
|
recentLogs: LogEntry[];
|
|
210
|
+
blockedEvents: BlockedEvent[];
|
|
114
211
|
}
|
|
115
212
|
|
|
116
213
|
export interface CacheEntry {
|
|
@@ -148,6 +245,7 @@ declare global {
|
|
|
148
245
|
perfToolkit?: {
|
|
149
246
|
startTime: number;
|
|
150
247
|
queryCount: number;
|
|
248
|
+
highQueries?: boolean;
|
|
151
249
|
trackQuery: (label?: string) => void;
|
|
152
250
|
};
|
|
153
251
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { analyzeMetrics } from "../src/analyzer";
|
|
2
|
+
import { Metrics } from "../src/types";
|
|
3
|
+
|
|
4
|
+
describe("Smart Insights Engine", () => {
|
|
5
|
+
const mockMetrics: Metrics = {
|
|
6
|
+
uptime: 1000,
|
|
7
|
+
totalRequests: 10,
|
|
8
|
+
avgResponseTime: 100,
|
|
9
|
+
slowRequests: 0,
|
|
10
|
+
highQueryRequests: 0,
|
|
11
|
+
rateLimitHits: 0,
|
|
12
|
+
cacheHits: 0,
|
|
13
|
+
cacheMisses: 10,
|
|
14
|
+
cacheHitRate: 0,
|
|
15
|
+
cacheSize: 0,
|
|
16
|
+
totalBytesSent: 1000,
|
|
17
|
+
avgResponseSize: 100,
|
|
18
|
+
insights: [],
|
|
19
|
+
eventLoopLag: 10,
|
|
20
|
+
memoryUsage: {
|
|
21
|
+
rss: 100,
|
|
22
|
+
heapTotal: 200,
|
|
23
|
+
heapUsed: 50,
|
|
24
|
+
external: 10,
|
|
25
|
+
},
|
|
26
|
+
statusCodes: { 200: 10 },
|
|
27
|
+
routes: {},
|
|
28
|
+
recentLogs: [],
|
|
29
|
+
blockedEvents: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
it("should suggest caching for slow routes", () => {
|
|
33
|
+
const metrics: Metrics = {
|
|
34
|
+
...mockMetrics,
|
|
35
|
+
routes: {
|
|
36
|
+
"GET /api/slow": {
|
|
37
|
+
count: 10,
|
|
38
|
+
totalTime: 8000,
|
|
39
|
+
avgTime: 800,
|
|
40
|
+
slowCount: 10,
|
|
41
|
+
highQueryCount: 0,
|
|
42
|
+
rateLimitHits: 0,
|
|
43
|
+
totalBytes: 1000,
|
|
44
|
+
avgSize: 100,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const insights = analyzeMetrics(metrics);
|
|
50
|
+
expect(insights.some((i) => i.title.includes("Caching Opportunity"))).toBe(
|
|
51
|
+
true
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should warn about N+1 queries", () => {
|
|
56
|
+
const metrics: Metrics = {
|
|
57
|
+
...mockMetrics,
|
|
58
|
+
routes: {
|
|
59
|
+
"GET /api/n-plus-one": {
|
|
60
|
+
count: 10,
|
|
61
|
+
totalTime: 1000,
|
|
62
|
+
avgTime: 100,
|
|
63
|
+
slowCount: 0,
|
|
64
|
+
highQueryCount: 8, // 80%
|
|
65
|
+
rateLimitHits: 0,
|
|
66
|
+
totalBytes: 1000,
|
|
67
|
+
avgSize: 100,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const insights = analyzeMetrics(metrics);
|
|
73
|
+
expect(insights.some((i) => i.title.includes("N+1 Query"))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should warn about heavy payloads", () => {
|
|
77
|
+
const metrics: Metrics = {
|
|
78
|
+
...mockMetrics,
|
|
79
|
+
routes: {
|
|
80
|
+
"GET /api/heavy": {
|
|
81
|
+
count: 10,
|
|
82
|
+
totalTime: 1000,
|
|
83
|
+
avgTime: 100,
|
|
84
|
+
slowCount: 0,
|
|
85
|
+
highQueryCount: 0,
|
|
86
|
+
rateLimitHits: 0,
|
|
87
|
+
totalBytes: 20 * 1024 * 1024,
|
|
88
|
+
avgSize: 2 * 1024 * 1024, // 2MB
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const insights = analyzeMetrics(metrics);
|
|
94
|
+
expect(insights.some((i) => i.title.includes("Heavy Payload"))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should warn about event loop lag", () => {
|
|
98
|
+
const metrics: Metrics = {
|
|
99
|
+
...mockMetrics,
|
|
100
|
+
eventLoopLag: 150,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const insights = analyzeMetrics(metrics);
|
|
104
|
+
expect(insights.some((i) => i.title.includes("Event Loop Lagging"))).toBe(
|
|
105
|
+
true
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import request from "supertest";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { performanceToolkit } from "../src/index";
|
|
4
|
+
|
|
5
|
+
describe("Dashboard Authentication", () => {
|
|
6
|
+
it("should require login if auth is enabled", async () => {
|
|
7
|
+
const app = express();
|
|
8
|
+
const toolkit = performanceToolkit({
|
|
9
|
+
dashboard: {
|
|
10
|
+
enabled: true,
|
|
11
|
+
auth: {
|
|
12
|
+
username: "testuser",
|
|
13
|
+
password: "testpassword",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
app.use(toolkit.middleware);
|
|
19
|
+
app.use('/__perf', toolkit.dashboardRouter);
|
|
20
|
+
|
|
21
|
+
// Should return 401 for protected data
|
|
22
|
+
const resp = await request(app).get("/__perf/api/metrics");
|
|
23
|
+
expect(resp.status).toBe(401);
|
|
24
|
+
|
|
25
|
+
// Try login
|
|
26
|
+
const loginResp = await request(app)
|
|
27
|
+
.post("/__perf/api/login")
|
|
28
|
+
.send({ username: "testuser", password: "testpassword" });
|
|
29
|
+
|
|
30
|
+
expect(loginResp.status).toBe(200);
|
|
31
|
+
expect(loginResp.headers["set-cookie"]).toBeDefined();
|
|
32
|
+
|
|
33
|
+
// With cookie, should work
|
|
34
|
+
const cookie = loginResp.headers["set-cookie"];
|
|
35
|
+
const metricsResp = await request(app)
|
|
36
|
+
.get("/__perf/api/metrics")
|
|
37
|
+
.set("Cookie", cookie);
|
|
38
|
+
|
|
39
|
+
expect(metricsResp.status).toBe(200);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should handle invalid credentials", async () => {
|
|
43
|
+
const app = express();
|
|
44
|
+
const toolkit = performanceToolkit({
|
|
45
|
+
dashboard: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
auth: {
|
|
48
|
+
username: "admin",
|
|
49
|
+
password: "password",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
app.use(toolkit.middleware);
|
|
55
|
+
app.use('/__perf', toolkit.dashboardRouter);
|
|
56
|
+
|
|
57
|
+
const loginResp = await request(app)
|
|
58
|
+
.post("/__perf/api/login")
|
|
59
|
+
.send({ username: "admin", password: "wrong" });
|
|
60
|
+
|
|
61
|
+
expect(loginResp.status).toBe(401);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should allow access if auth is disabled", async () => {
|
|
65
|
+
const app = express();
|
|
66
|
+
const toolkit = performanceToolkit({
|
|
67
|
+
dashboard: {
|
|
68
|
+
enabled: true,
|
|
69
|
+
auth: undefined,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.use(toolkit.middleware);
|
|
74
|
+
app.use("/__perf", toolkit.dashboardRouter);
|
|
75
|
+
|
|
76
|
+
const resp = await request(app).get("/__perf/api/metrics");
|
|
77
|
+
expect(resp.status).toBe(200);
|
|
78
|
+
});
|
|
79
|
+
});
|