@onebun/metrics 0.1.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 +245 -0
- package/package.json +41 -0
- package/src/decorators.test.ts +531 -0
- package/src/decorators.ts +249 -0
- package/src/index.test.ts +357 -0
- package/src/index.ts +90 -0
- package/src/metrics.service.test.ts +374 -0
- package/src/metrics.service.ts +371 -0
- package/src/middleware.test.ts +295 -0
- package/src/middleware.ts +186 -0
- package/src/types.ts +133 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Context,
|
|
3
|
+
Effect,
|
|
4
|
+
Layer,
|
|
5
|
+
} from 'effect';
|
|
6
|
+
import {
|
|
7
|
+
Counter,
|
|
8
|
+
collectDefaultMetrics,
|
|
9
|
+
Gauge,
|
|
10
|
+
Histogram,
|
|
11
|
+
register,
|
|
12
|
+
Summary,
|
|
13
|
+
} from 'prom-client';
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
CustomMetricConfig,
|
|
17
|
+
HttpMetricsData,
|
|
18
|
+
MetricsOptions,
|
|
19
|
+
MetricsRegistry,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_HTTP_DURATION_BUCKETS,
|
|
24
|
+
DEFAULT_METRICS_MAX_AGE_SECONDS,
|
|
25
|
+
DEFAULT_SYSTEM_METRICS_INTERVAL,
|
|
26
|
+
} from './types';
|
|
27
|
+
|
|
28
|
+
/* eslint-disable no-magic-numbers -- Metrics constants defined in one place */
|
|
29
|
+
/**
|
|
30
|
+
* Default histogram buckets for GC metrics
|
|
31
|
+
*/
|
|
32
|
+
const GC_DURATION_BUCKETS = [0.001, 0.01, 0.1, 1, 2, 5];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default histogram buckets for custom metrics
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_CUSTOM_HISTOGRAM_BUCKETS = [0.001, 0.01, 0.1, 1, 10];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default percentiles for summary metrics
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_SUMMARY_PERCENTILES = [0.01, 0.05, 0.5, 0.9, 0.95, 0.99, 0.999];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Default number of age buckets for summary metrics
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_SUMMARY_AGE_BUCKETS = 5;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Microseconds to seconds conversion factor
|
|
51
|
+
*/
|
|
52
|
+
const MICROSECONDS_TO_SECONDS = 1000000;
|
|
53
|
+
/* eslint-enable no-magic-numbers */
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Metrics service interface
|
|
57
|
+
*/
|
|
58
|
+
export interface MetricsService {
|
|
59
|
+
/**
|
|
60
|
+
* Get metrics in Prometheus format
|
|
61
|
+
*/
|
|
62
|
+
getMetrics(): Promise<string>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get content type for metrics response
|
|
66
|
+
*/
|
|
67
|
+
getContentType(): string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Record HTTP request metrics
|
|
71
|
+
*/
|
|
72
|
+
recordHttpRequest(data: HttpMetricsData): void;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a custom counter
|
|
76
|
+
*/
|
|
77
|
+
createCounter(config: Omit<CustomMetricConfig, 'type'>): Counter<string>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a custom gauge
|
|
81
|
+
*/
|
|
82
|
+
createGauge(config: Omit<CustomMetricConfig, 'type'>): Gauge<string>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a custom histogram
|
|
86
|
+
*/
|
|
87
|
+
createHistogram(config: Omit<CustomMetricConfig, 'type'>): Histogram<string>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create a custom summary
|
|
91
|
+
*/
|
|
92
|
+
createSummary(config: Omit<CustomMetricConfig, 'type'>): Summary<string>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get a metric by name
|
|
96
|
+
*/
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
98
|
+
getMetric<T = any>(name: string): T | undefined;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clear all metrics
|
|
102
|
+
*/
|
|
103
|
+
clear(): void;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get metrics registry
|
|
107
|
+
*/
|
|
108
|
+
getRegistry(): MetricsRegistry;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start collecting system metrics
|
|
112
|
+
*/
|
|
113
|
+
startSystemMetricsCollection(): void;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stop collecting system metrics
|
|
117
|
+
*/
|
|
118
|
+
stopSystemMetricsCollection(): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Metrics service tag for dependency injection
|
|
123
|
+
*/
|
|
124
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
125
|
+
export const MetricsService = Context.GenericTag<MetricsService>('@onebun/metrics/MetricsService');
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Metrics service implementation
|
|
129
|
+
*/
|
|
130
|
+
class MetricsServiceImpl implements MetricsService {
|
|
131
|
+
private options: MetricsOptions;
|
|
132
|
+
private httpRequestsTotal!: Counter<string>;
|
|
133
|
+
private httpRequestDuration!: Histogram<string>;
|
|
134
|
+
private systemMemoryUsage!: Gauge<string>;
|
|
135
|
+
private systemCpuUsage!: Gauge<string>;
|
|
136
|
+
private systemUptime!: Gauge<string>;
|
|
137
|
+
private systemMetricsInterval?: Timer;
|
|
138
|
+
private cpuUsageBaseline: NodeJS.CpuUsage;
|
|
139
|
+
|
|
140
|
+
constructor(options: MetricsOptions = {}) {
|
|
141
|
+
this.options = {
|
|
142
|
+
enabled: true,
|
|
143
|
+
path: '/metrics',
|
|
144
|
+
collectHttpMetrics: true,
|
|
145
|
+
collectSystemMetrics: true,
|
|
146
|
+
collectGcMetrics: true,
|
|
147
|
+
systemMetricsInterval: DEFAULT_SYSTEM_METRICS_INTERVAL,
|
|
148
|
+
prefix: 'onebun_',
|
|
149
|
+
httpDurationBuckets: DEFAULT_HTTP_DURATION_BUCKETS,
|
|
150
|
+
...options,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
this.cpuUsageBaseline = process.cpuUsage();
|
|
154
|
+
|
|
155
|
+
if (this.options.enabled) {
|
|
156
|
+
this.initializeMetrics();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private initializeMetrics(): void {
|
|
161
|
+
// Set default labels if provided
|
|
162
|
+
if (this.options.defaultLabels) {
|
|
163
|
+
register.setDefaultLabels(this.options.defaultLabels);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Collect default metrics (GC, etc.)
|
|
167
|
+
if (this.options.collectGcMetrics) {
|
|
168
|
+
collectDefaultMetrics({
|
|
169
|
+
register,
|
|
170
|
+
prefix: this.options.prefix,
|
|
171
|
+
gcDurationBuckets: GC_DURATION_BUCKETS,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Initialize HTTP metrics
|
|
176
|
+
if (this.options.collectHttpMetrics) {
|
|
177
|
+
this.initializeHttpMetrics();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Initialize system metrics
|
|
181
|
+
if (this.options.collectSystemMetrics) {
|
|
182
|
+
this.initializeSystemMetrics();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private initializeHttpMetrics(): void {
|
|
187
|
+
this.httpRequestsTotal = new Counter({
|
|
188
|
+
name: `${this.options.prefix}http_requests_total`,
|
|
189
|
+
help: 'Total number of HTTP requests',
|
|
190
|
+
labelNames: ['method', 'route', 'status_code', 'controller', 'action'],
|
|
191
|
+
registers: [register],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
this.httpRequestDuration = new Histogram({
|
|
195
|
+
name: `${this.options.prefix}http_request_duration_seconds`,
|
|
196
|
+
help: 'HTTP request duration in seconds',
|
|
197
|
+
labelNames: ['method', 'route', 'status_code', 'controller', 'action'],
|
|
198
|
+
buckets: this.options.httpDurationBuckets!,
|
|
199
|
+
registers: [register],
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private initializeSystemMetrics(): void {
|
|
204
|
+
this.systemMemoryUsage = new Gauge({
|
|
205
|
+
name: `${this.options.prefix}memory_usage_bytes`,
|
|
206
|
+
help: 'Memory usage in bytes',
|
|
207
|
+
labelNames: ['type'],
|
|
208
|
+
registers: [register],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
this.systemCpuUsage = new Gauge({
|
|
212
|
+
name: `${this.options.prefix}cpu_usage_ratio`,
|
|
213
|
+
help: 'CPU usage ratio',
|
|
214
|
+
registers: [register],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
this.systemUptime = new Gauge({
|
|
218
|
+
name: `${this.options.prefix}uptime_seconds`,
|
|
219
|
+
help: 'Process uptime in seconds',
|
|
220
|
+
registers: [register],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async getMetrics(): Promise<string> {
|
|
225
|
+
if (!this.options.enabled) {
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return await register.metrics();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getContentType(): string {
|
|
233
|
+
return register.contentType;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
recordHttpRequest(data: HttpMetricsData): void {
|
|
237
|
+
if (!this.options.enabled || !this.options.collectHttpMetrics) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const labels = {
|
|
242
|
+
method: data.method.toUpperCase(),
|
|
243
|
+
route: data.route,
|
|
244
|
+
status_code: data.statusCode.toString(),
|
|
245
|
+
controller: data.controller || 'unknown',
|
|
246
|
+
action: data.action || 'unknown',
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
this.httpRequestsTotal.inc(labels);
|
|
250
|
+
this.httpRequestDuration.observe(labels, data.duration);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
createCounter(config: Omit<CustomMetricConfig, 'type'>): Counter<string> {
|
|
254
|
+
return new Counter({
|
|
255
|
+
name: `${this.options.prefix}${config.name}`,
|
|
256
|
+
help: config.help,
|
|
257
|
+
labelNames: config.labelNames || [],
|
|
258
|
+
registers: [register],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
createGauge(config: Omit<CustomMetricConfig, 'type'>): Gauge<string> {
|
|
263
|
+
return new Gauge({
|
|
264
|
+
name: `${this.options.prefix}${config.name}`,
|
|
265
|
+
help: config.help,
|
|
266
|
+
labelNames: config.labelNames || [],
|
|
267
|
+
registers: [register],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
createHistogram(config: Omit<CustomMetricConfig, 'type'>): Histogram<string> {
|
|
272
|
+
return new Histogram({
|
|
273
|
+
name: `${this.options.prefix}${config.name}`,
|
|
274
|
+
help: config.help,
|
|
275
|
+
labelNames: config.labelNames || [],
|
|
276
|
+
buckets: config.buckets || DEFAULT_CUSTOM_HISTOGRAM_BUCKETS,
|
|
277
|
+
registers: [register],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
createSummary(config: Omit<CustomMetricConfig, 'type'>): Summary<string> {
|
|
282
|
+
return new Summary({
|
|
283
|
+
name: `${this.options.prefix}${config.name}`,
|
|
284
|
+
help: config.help,
|
|
285
|
+
labelNames: config.labelNames || [],
|
|
286
|
+
percentiles: config.percentiles || DEFAULT_SUMMARY_PERCENTILES,
|
|
287
|
+
maxAgeSeconds: config.maxAgeSeconds || DEFAULT_METRICS_MAX_AGE_SECONDS,
|
|
288
|
+
ageBuckets: config.ageBuckets || DEFAULT_SUMMARY_AGE_BUCKETS,
|
|
289
|
+
registers: [register],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
294
|
+
getMetric<T = any>(name: string): T | undefined {
|
|
295
|
+
const fullName = name.startsWith(this.options.prefix!) ? name : `${this.options.prefix}${name}`;
|
|
296
|
+
|
|
297
|
+
return register.getSingleMetric(fullName) as T;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
clear(): void {
|
|
301
|
+
register.clear();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
getRegistry(): MetricsRegistry {
|
|
305
|
+
return {
|
|
306
|
+
getMetrics: () => this.getMetrics(),
|
|
307
|
+
getContentType: () => this.getContentType(),
|
|
308
|
+
clear: () => this.clear(),
|
|
309
|
+
register,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
startSystemMetricsCollection(): void {
|
|
314
|
+
if (!this.options.enabled || !this.options.collectSystemMetrics || this.systemMetricsInterval) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
this.systemMetricsInterval = setInterval(() => {
|
|
319
|
+
this.collectSystemMetrics();
|
|
320
|
+
}, this.options.systemMetricsInterval!);
|
|
321
|
+
|
|
322
|
+
// Collect initial metrics
|
|
323
|
+
this.collectSystemMetrics();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
stopSystemMetricsCollection(): void {
|
|
327
|
+
if (this.systemMetricsInterval) {
|
|
328
|
+
clearInterval(this.systemMetricsInterval);
|
|
329
|
+
delete this.systemMetricsInterval;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private collectSystemMetrics(): void {
|
|
334
|
+
try {
|
|
335
|
+
// Memory metrics
|
|
336
|
+
const memUsage = process.memoryUsage();
|
|
337
|
+
this.systemMemoryUsage.set({ type: 'rss' }, memUsage.rss);
|
|
338
|
+
this.systemMemoryUsage.set({ type: 'heap_used' }, memUsage.heapUsed);
|
|
339
|
+
this.systemMemoryUsage.set({ type: 'heap_total' }, memUsage.heapTotal);
|
|
340
|
+
this.systemMemoryUsage.set({ type: 'external' }, memUsage.external);
|
|
341
|
+
|
|
342
|
+
// CPU metrics
|
|
343
|
+
const cpuUsage = process.cpuUsage(this.cpuUsageBaseline);
|
|
344
|
+
const cpuPercent = (cpuUsage.user + cpuUsage.system) / MICROSECONDS_TO_SECONDS; // Convert microseconds to seconds
|
|
345
|
+
this.systemCpuUsage.set(cpuPercent);
|
|
346
|
+
this.cpuUsageBaseline = process.cpuUsage();
|
|
347
|
+
|
|
348
|
+
// Uptime
|
|
349
|
+
this.systemUptime.set(process.uptime());
|
|
350
|
+
} catch (error) {
|
|
351
|
+
// Silently ignore metrics collection errors
|
|
352
|
+
// eslint-disable-next-line no-console
|
|
353
|
+
console.warn('Failed to collect system metrics:', error);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Create metrics service layer
|
|
360
|
+
*/
|
|
361
|
+
export const makeMetricsService = (
|
|
362
|
+
options?: MetricsOptions,
|
|
363
|
+
): Layer.Layer<MetricsService, never, never> =>
|
|
364
|
+
Layer.succeed(MetricsService, new MetricsServiceImpl(options));
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Create metrics service with configuration
|
|
368
|
+
*/
|
|
369
|
+
export const createMetricsService = (
|
|
370
|
+
options?: MetricsOptions,
|
|
371
|
+
): Effect.Effect<MetricsService, never, never> => Effect.succeed(new MetricsServiceImpl(options));
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/* eslint-disable
|
|
2
|
+
@typescript-eslint/no-empty-function,
|
|
3
|
+
@typescript-eslint/no-explicit-any,
|
|
4
|
+
@typescript-eslint/naming-convention,
|
|
5
|
+
jest/unbound-method */
|
|
6
|
+
import {
|
|
7
|
+
describe,
|
|
8
|
+
test,
|
|
9
|
+
expect,
|
|
10
|
+
beforeEach,
|
|
11
|
+
mock,
|
|
12
|
+
} from 'bun:test';
|
|
13
|
+
|
|
14
|
+
import type { MetricsService } from './metrics.service';
|
|
15
|
+
|
|
16
|
+
import { MetricsMiddleware } from './middleware';
|
|
17
|
+
|
|
18
|
+
describe('MetricsMiddleware', () => {
|
|
19
|
+
let mockMetricsService: MetricsService;
|
|
20
|
+
let middleware: MetricsMiddleware;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
mockMetricsService = {
|
|
24
|
+
recordHttpRequest: mock(() => {}),
|
|
25
|
+
getMetrics: mock(() => Promise.resolve('# metrics data')),
|
|
26
|
+
getContentType: mock(() => 'text/plain'),
|
|
27
|
+
createCounter: mock(() => ({ inc: mock() })),
|
|
28
|
+
createGauge: mock(() => ({ set: mock() })),
|
|
29
|
+
createHistogram: mock(() => ({ observe: mock() })),
|
|
30
|
+
createSummary: mock(() => ({ observe: mock() })),
|
|
31
|
+
getMetric: mock(() => undefined),
|
|
32
|
+
clearMetrics: mock(() => {}),
|
|
33
|
+
getRegistry: mock(() => ({})),
|
|
34
|
+
startSystemMetricsCollection: mock(() => {}),
|
|
35
|
+
stopSystemMetricsCollection: mock(() => {}),
|
|
36
|
+
} as any;
|
|
37
|
+
|
|
38
|
+
middleware = new MetricsMiddleware(mockMetricsService);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('createHttpMetricsMiddleware', () => {
|
|
42
|
+
test('should create middleware function that records HTTP metrics', async () => {
|
|
43
|
+
const middlewareFunc = middleware.createHttpMetricsMiddleware();
|
|
44
|
+
|
|
45
|
+
// Create mock request and response
|
|
46
|
+
const mockRequest = new Request('http://localhost:3000/api/test', {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const mockResponse = new Response('OK', { status: 200 });
|
|
51
|
+
|
|
52
|
+
// Call middleware to get the response handler
|
|
53
|
+
const responseHandler = await middlewareFunc(mockRequest, {
|
|
54
|
+
controller: 'TestController',
|
|
55
|
+
action: 'testAction',
|
|
56
|
+
route: '/api/test',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(typeof responseHandler).toBe('function');
|
|
60
|
+
|
|
61
|
+
// Simulate calling the response handler
|
|
62
|
+
const startTime = Date.now() - 100; // 100ms ago
|
|
63
|
+
responseHandler(mockResponse, startTime);
|
|
64
|
+
|
|
65
|
+
// Verify metrics were recorded
|
|
66
|
+
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
method: 'GET',
|
|
69
|
+
route: '/api/test',
|
|
70
|
+
statusCode: 200,
|
|
71
|
+
controller: 'TestController',
|
|
72
|
+
action: 'testAction',
|
|
73
|
+
duration: expect.any(Number),
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('should use pathname when route is not provided', async () => {
|
|
79
|
+
const middlewareFunc = middleware.createHttpMetricsMiddleware();
|
|
80
|
+
|
|
81
|
+
const mockRequest = new Request('http://localhost:3000/api/users/123', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const mockResponse = new Response('Created', { status: 201 });
|
|
86
|
+
|
|
87
|
+
const responseHandler = await middlewareFunc(mockRequest, {});
|
|
88
|
+
|
|
89
|
+
const startTime = Date.now() - 50;
|
|
90
|
+
responseHandler(mockResponse, startTime);
|
|
91
|
+
|
|
92
|
+
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
method: 'POST',
|
|
95
|
+
route: '/api/users/123',
|
|
96
|
+
statusCode: 201,
|
|
97
|
+
controller: undefined,
|
|
98
|
+
action: undefined,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('should handle different HTTP methods and status codes', async () => {
|
|
104
|
+
const middlewareFunc = middleware.createHttpMetricsMiddleware();
|
|
105
|
+
|
|
106
|
+
const testCases = [
|
|
107
|
+
{ method: 'PUT', status: 204 },
|
|
108
|
+
{ method: 'DELETE', status: 404 },
|
|
109
|
+
{ method: 'PATCH', status: 500 },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const { method, status } of testCases) {
|
|
113
|
+
const mockRequest = new Request('http://localhost:3000/test', {
|
|
114
|
+
method,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const mockResponse = new Response('', { status });
|
|
118
|
+
|
|
119
|
+
const responseHandler = await middlewareFunc(mockRequest, {
|
|
120
|
+
route: '/test',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
responseHandler(mockResponse, Date.now() - 10);
|
|
124
|
+
|
|
125
|
+
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
method,
|
|
128
|
+
statusCode: status,
|
|
129
|
+
route: '/test',
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('should calculate duration correctly', async () => {
|
|
136
|
+
const middlewareFunc = middleware.createHttpMetricsMiddleware();
|
|
137
|
+
|
|
138
|
+
const mockRequest = new Request('http://localhost:3000/test');
|
|
139
|
+
const mockResponse = new Response('OK', { status: 200 });
|
|
140
|
+
|
|
141
|
+
const responseHandler = await middlewareFunc(mockRequest, {});
|
|
142
|
+
|
|
143
|
+
const startTime = Date.now() - 1000; // 1 second ago
|
|
144
|
+
responseHandler(mockResponse, startTime);
|
|
145
|
+
|
|
146
|
+
const call = (mockMetricsService.recordHttpRequest as any).mock.calls[0][0];
|
|
147
|
+
expect(call.duration).toBeGreaterThan(0.9); // Should be around 1 second
|
|
148
|
+
expect(call.duration).toBeLessThan(1.1);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('wrapControllerMethod', () => {
|
|
153
|
+
test('should wrap synchronous method and record metrics', async () => {
|
|
154
|
+
const originalMethod = mock(() => 'result');
|
|
155
|
+
|
|
156
|
+
const wrappedMethod = middleware.wrapControllerMethod(
|
|
157
|
+
originalMethod,
|
|
158
|
+
'TestController',
|
|
159
|
+
'testMethod',
|
|
160
|
+
'/test-route',
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = await wrappedMethod();
|
|
164
|
+
|
|
165
|
+
expect(result).toBe('result');
|
|
166
|
+
expect(originalMethod).toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('should wrap asynchronous method and record metrics', async () => {
|
|
170
|
+
const originalMethod = mock(async () => 'async result');
|
|
171
|
+
|
|
172
|
+
const wrappedMethod = middleware.wrapControllerMethod(
|
|
173
|
+
originalMethod,
|
|
174
|
+
'TestController',
|
|
175
|
+
'asyncMethod',
|
|
176
|
+
'/async-route',
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const result = await wrappedMethod();
|
|
180
|
+
|
|
181
|
+
expect(result).toBe('async result');
|
|
182
|
+
expect(originalMethod).toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('should handle method arguments correctly', async () => {
|
|
186
|
+
const originalMethod = mock((a: number, b: string) => `${a}-${b}`);
|
|
187
|
+
|
|
188
|
+
const wrappedMethod = middleware.wrapControllerMethod(
|
|
189
|
+
originalMethod,
|
|
190
|
+
'TestController',
|
|
191
|
+
'methodWithArgs',
|
|
192
|
+
'/method-with-args',
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const result = await wrappedMethod(42, 'test');
|
|
196
|
+
|
|
197
|
+
expect(result).toBe('42-test');
|
|
198
|
+
expect(originalMethod).toHaveBeenCalledWith(42, 'test');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('should handle method that throws error', async () => {
|
|
202
|
+
const originalMethod = mock(() => {
|
|
203
|
+
throw new Error('Test error');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const wrappedMethod = middleware.wrapControllerMethod(
|
|
207
|
+
originalMethod,
|
|
208
|
+
'TestController',
|
|
209
|
+
'errorMethod',
|
|
210
|
+
'/error-route',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await expect(wrappedMethod()).rejects.toThrow('Test error');
|
|
214
|
+
expect(originalMethod).toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('should handle async method that rejects', async () => {
|
|
218
|
+
const originalMethod = mock(async () => {
|
|
219
|
+
throw new Error('Async error');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const wrappedMethod = middleware.wrapControllerMethod(
|
|
223
|
+
originalMethod,
|
|
224
|
+
'TestController',
|
|
225
|
+
'asyncErrorMethod',
|
|
226
|
+
'/async-error-route',
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
await expect(wrappedMethod()).rejects.toThrow('Async error');
|
|
230
|
+
expect(originalMethod).toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('Class instantiation', () => {
|
|
235
|
+
test('should create MetricsMiddleware instance', () => {
|
|
236
|
+
expect(middleware).toBeInstanceOf(MetricsMiddleware);
|
|
237
|
+
expect(middleware).toBeDefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('should have access to metrics service', () => {
|
|
241
|
+
// Check that the middleware has access to the metrics service
|
|
242
|
+
const middlewareWithService = new MetricsMiddleware(mockMetricsService);
|
|
243
|
+
expect(middlewareWithService).toBeInstanceOf(MetricsMiddleware);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('MetricsMiddleware integration', () => {
|
|
248
|
+
test('should work with real-world request/response cycle', async () => {
|
|
249
|
+
const middlewareFunc = middleware.createHttpMetricsMiddleware();
|
|
250
|
+
|
|
251
|
+
// Simulate a real request cycle
|
|
252
|
+
const request = new Request('http://api.example.com/users/profile', {
|
|
253
|
+
method: 'GET',
|
|
254
|
+
headers: {
|
|
255
|
+
'Authorization': 'Bearer token123',
|
|
256
|
+
'Content-Type': 'application/json',
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const context = {
|
|
261
|
+
controller: 'UserController',
|
|
262
|
+
action: 'getProfile',
|
|
263
|
+
route: '/users/profile',
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const responseHandler = await middlewareFunc(request, context);
|
|
267
|
+
|
|
268
|
+
// Simulate processing time
|
|
269
|
+
const startTime = Date.now() - 150; // 150ms processing time
|
|
270
|
+
|
|
271
|
+
const response = new Response(JSON.stringify({ id: 1, name: 'John' }), {
|
|
272
|
+
status: 200,
|
|
273
|
+
headers: {
|
|
274
|
+
'Content-Type': 'application/json',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
responseHandler(response, startTime);
|
|
279
|
+
|
|
280
|
+
// Verify the metrics were recorded with correct data
|
|
281
|
+
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith({
|
|
282
|
+
method: 'GET',
|
|
283
|
+
route: '/users/profile',
|
|
284
|
+
statusCode: 200,
|
|
285
|
+
duration: expect.any(Number),
|
|
286
|
+
controller: 'UserController',
|
|
287
|
+
action: 'getProfile',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const recordedData = (mockMetricsService.recordHttpRequest as any).mock.calls[0][0];
|
|
291
|
+
expect(recordedData.duration).toBeGreaterThan(0.1); // At least 100ms
|
|
292
|
+
expect(recordedData.duration).toBeLessThan(0.3); // Less than 300ms
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|