@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.
@@ -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
+ });