@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,249 @@
1
+ /* eslint-disable
2
+ @typescript-eslint/no-explicit-any,
3
+ @typescript-eslint/naming-convention */
4
+ // Decorators work with any class/method types - this is standard for TypeScript decorators
5
+ // PascalCase naming is standard for decorator functions (e.g., @Component, @Injectable)
6
+
7
+ import { Effect } from 'effect';
8
+
9
+ import type { CustomMetricConfig } from './types';
10
+
11
+ import { MetricsService } from './metrics.service';
12
+
13
+ /**
14
+ * Decorator for measuring method execution time
15
+ */
16
+ export function MeasureTime(metricName?: string, labels?: string[]): MethodDecorator {
17
+ return (
18
+ target: any,
19
+ propertyKey: string | symbol,
20
+ descriptor: PropertyDescriptor,
21
+ ): PropertyDescriptor => {
22
+ const originalMethod = descriptor.value;
23
+ const methodName = metricName || `${target.constructor.name}_${String(propertyKey)}_duration`;
24
+
25
+ descriptor.value = function (...args: any[]): any {
26
+ const startTime = Date.now();
27
+
28
+ try {
29
+ const result = originalMethod.apply(this, args);
30
+
31
+ if (result instanceof Promise) {
32
+ return result
33
+ .then((res) => {
34
+ recordDuration(methodName, startTime, labels);
35
+
36
+ return res;
37
+ })
38
+ .catch((err) => {
39
+ recordDuration(methodName, startTime, labels);
40
+ throw err;
41
+ });
42
+ } else {
43
+ recordDuration(methodName, startTime, labels);
44
+
45
+ return result;
46
+ }
47
+ } catch (err) {
48
+ recordDuration(methodName, startTime, labels);
49
+ throw err;
50
+ }
51
+ };
52
+
53
+ return descriptor;
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Decorator for counting method calls
59
+ */
60
+ export function CountCalls(metricName?: string, labels?: string[]): MethodDecorator {
61
+ return (
62
+ target: any,
63
+ propertyKey: string | symbol,
64
+ descriptor: PropertyDescriptor,
65
+ ): PropertyDescriptor => {
66
+ const originalMethod = descriptor.value;
67
+ const counterName =
68
+ metricName || `${target.constructor.name}_${String(propertyKey)}_calls_total`;
69
+
70
+ descriptor.value = function (...args: any[]): any {
71
+ incrementCounter(counterName, labels);
72
+
73
+ return originalMethod.apply(this, args);
74
+ };
75
+
76
+ return descriptor;
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Decorator for measuring gauge values
82
+ */
83
+ export function MeasureGauge(
84
+ metricName: string,
85
+ getValue: () => number,
86
+ labels?: string[],
87
+ ): MethodDecorator {
88
+ return (
89
+ target: any,
90
+ propertyKey: string | symbol,
91
+ descriptor: PropertyDescriptor,
92
+ ): PropertyDescriptor => {
93
+ const originalMethod = descriptor.value;
94
+
95
+ descriptor.value = function (...args: any[]): any {
96
+ const result = originalMethod.apply(this, args);
97
+
98
+ // Update gauge after method execution
99
+ const updateGauge = (): void => {
100
+ try {
101
+ const value = getValue();
102
+ setGaugeValue(metricName, value, labels);
103
+ } catch (error) {
104
+ // eslint-disable-next-line no-console
105
+ console.warn(`Failed to update gauge ${metricName}:`, error);
106
+ }
107
+ };
108
+
109
+ if (result instanceof Promise) {
110
+ return result.then((res) => {
111
+ updateGauge();
112
+
113
+ return res;
114
+ });
115
+ } else {
116
+ updateGauge();
117
+
118
+ return result;
119
+ }
120
+ };
121
+
122
+ return descriptor;
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Decorator for automatic metric creation and injection
128
+ */
129
+ export function InjectMetric(config: CustomMetricConfig): PropertyDecorator {
130
+ return (
131
+ target: any,
132
+ propertyKey: string | symbol,
133
+ ): void => {
134
+ // For now, just log the configuration
135
+ // eslint-disable-next-line no-console
136
+ console.log(
137
+ `Metric ${config.name} will be injected into ${target.constructor.name}.${String(propertyKey)}`,
138
+ );
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Class decorator for automatic metric initialization
144
+ */
145
+ export function WithMetrics(
146
+ options: { prefix?: string } = {},
147
+ ): <T extends new (...args: any[]) => any>(constructor: T) => T {
148
+ return <T extends new (...args: any[]) => any>(constructor: T): T =>
149
+ class extends constructor {
150
+ constructor(...args: any[]) {
151
+ super(...args);
152
+ // For now, just log initialization
153
+ // eslint-disable-next-line no-console
154
+ console.log(
155
+ `WithMetrics applied to ${constructor.name} with prefix: ${options.prefix || 'none'}`,
156
+ );
157
+ }
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Helper functions for metric operations
163
+ */
164
+ function recordDuration(metricName: string, startTime: number, labels?: string[]): void {
165
+ const metricsService = getMetricsService();
166
+ if (!metricsService) {
167
+ return;
168
+ }
169
+
170
+ const duration = (Date.now() - startTime) / 1000;
171
+ const histogram = metricsService.getMetric(metricName);
172
+
173
+ if (histogram && typeof histogram.observe === 'function') {
174
+ histogram.observe(labels ? { labels: labels.join(',') } : {}, duration);
175
+ }
176
+ }
177
+
178
+ function incrementCounter(metricName: string, labels?: string[]): void {
179
+ const metricsService = getMetricsService();
180
+ if (!metricsService) {
181
+ return;
182
+ }
183
+
184
+ const counter = metricsService.getMetric(metricName);
185
+
186
+ if (counter && typeof counter.inc === 'function') {
187
+ counter.inc(labels ? { labels: labels.join(',') } : {});
188
+ }
189
+ }
190
+
191
+ function setGaugeValue(metricName: string, value: number, labels?: string[]): void {
192
+ const metricsService = getMetricsService();
193
+ if (!metricsService) {
194
+ return;
195
+ }
196
+
197
+ const gauge = metricsService.getMetric(metricName);
198
+
199
+ if (gauge && typeof gauge.set === 'function') {
200
+ gauge.set(labels ? { labels: labels.join(',') } : {}, value);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Get metrics service from global context
206
+ * This is a temporary solution until proper DI is implemented
207
+ */
208
+ function getMetricsService(): any {
209
+ if (typeof globalThis !== 'undefined') {
210
+ return (globalThis as any).__onebunMetricsService;
211
+ }
212
+
213
+ return undefined;
214
+ }
215
+
216
+ /**
217
+ * Effect-based decorators
218
+ */
219
+ export const measureExecutionTime = <A, E, R>(
220
+ metricName: string,
221
+ effect: Effect.Effect<A, E, R>,
222
+ ): Effect.Effect<A, E, R | MetricsService> =>
223
+ Effect.gen(function* () {
224
+ const metricsService = yield* MetricsService;
225
+ const startTime = Date.now();
226
+
227
+ try {
228
+ const result = yield* effect;
229
+ const duration = (Date.now() - startTime) / 1000;
230
+
231
+ // Record to histogram if exists
232
+ const histogram = metricsService.getMetric(metricName);
233
+ if (histogram && typeof histogram.observe === 'function') {
234
+ histogram.observe({}, duration);
235
+ }
236
+
237
+ return result;
238
+ } catch (error) {
239
+ const duration = (Date.now() - startTime) / 1000;
240
+
241
+ // Still record the duration even on error
242
+ const histogram = metricsService.getMetric(metricName);
243
+ if (histogram && typeof histogram.observe === 'function') {
244
+ histogram.observe({ status: 'error' }, duration);
245
+ }
246
+
247
+ throw error;
248
+ }
249
+ });
@@ -0,0 +1,357 @@
1
+ import {
2
+ describe,
3
+ test,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ } from 'bun:test';
8
+ import { Effect } from 'effect';
9
+ import { register } from 'prom-client';
10
+
11
+ import type { MetricsOptions } from './types';
12
+
13
+ import {
14
+ DEFAULT_METRICS_OPTIONS,
15
+ createDefaultMetricsService,
16
+ makeDefaultMetricsService,
17
+ MetricsService,
18
+ MetricType,
19
+ } from './index';
20
+
21
+ describe('Metrics Index Module', () => {
22
+ beforeEach(() => {
23
+ register.clear();
24
+ });
25
+
26
+ afterEach(() => {
27
+ register.clear();
28
+ });
29
+
30
+ describe('DEFAULT_METRICS_OPTIONS', () => {
31
+ test('should export default metrics options with correct values', () => {
32
+ expect(DEFAULT_METRICS_OPTIONS).toBeDefined();
33
+ expect(DEFAULT_METRICS_OPTIONS.enabled).toBe(true);
34
+ expect(DEFAULT_METRICS_OPTIONS.path).toBe('/metrics');
35
+ expect(DEFAULT_METRICS_OPTIONS.collectHttpMetrics).toBe(true);
36
+ expect(DEFAULT_METRICS_OPTIONS.collectSystemMetrics).toBe(true);
37
+ expect(DEFAULT_METRICS_OPTIONS.collectGcMetrics).toBe(true);
38
+ expect(DEFAULT_METRICS_OPTIONS.prefix).toBe('onebun_');
39
+ expect(DEFAULT_METRICS_OPTIONS.systemMetricsInterval).toBe(5000);
40
+ expect(Array.isArray(DEFAULT_METRICS_OPTIONS.httpDurationBuckets)).toBe(true);
41
+ expect(DEFAULT_METRICS_OPTIONS.httpDurationBuckets.length).toBeGreaterThan(0);
42
+ });
43
+
44
+ test('should be a const object with readonly properties', () => {
45
+ // Test that the object properties are defined and accessible
46
+ expect(DEFAULT_METRICS_OPTIONS.enabled).toBeDefined();
47
+ expect(DEFAULT_METRICS_OPTIONS.path).toBeDefined();
48
+ expect(DEFAULT_METRICS_OPTIONS.prefix).toBeDefined();
49
+
50
+ // Test that it's exported as a const (structural check)
51
+ expect(typeof DEFAULT_METRICS_OPTIONS).toBe('object');
52
+ expect(DEFAULT_METRICS_OPTIONS).not.toBeNull();
53
+ });
54
+ });
55
+
56
+ describe('createDefaultMetricsService', () => {
57
+ test('should create metrics service with default options', async () => {
58
+ const serviceEffect = createDefaultMetricsService();
59
+ const service = await Effect.runPromise(serviceEffect);
60
+
61
+ expect(service).toBeDefined();
62
+ expect(typeof service.getMetrics).toBe('function');
63
+ expect(typeof service.getContentType).toBe('function');
64
+ expect(typeof service.recordHttpRequest).toBe('function');
65
+ });
66
+
67
+ test('should create metrics service with override options', async () => {
68
+ const overrides: Partial<MetricsOptions> = {
69
+ enabled: false,
70
+ prefix: 'custom_',
71
+ collectHttpMetrics: false,
72
+ };
73
+
74
+ const serviceEffect = createDefaultMetricsService(overrides);
75
+ const service = await Effect.runPromise(serviceEffect);
76
+
77
+ expect(service).toBeDefined();
78
+
79
+ // Test that disabled service returns empty metrics
80
+ const metrics = await service.getMetrics();
81
+ expect(metrics).toBe('');
82
+ });
83
+
84
+ test('should merge override options with defaults', async () => {
85
+ const overrides: Partial<MetricsOptions> = {
86
+ prefix: 'test_',
87
+ systemMetricsInterval: 10000,
88
+ };
89
+
90
+ const serviceEffect = createDefaultMetricsService(overrides);
91
+ const service = await Effect.runPromise(serviceEffect);
92
+
93
+ expect(service).toBeDefined();
94
+ // The service should still be enabled (from defaults) but with custom prefix
95
+ const metrics = await service.getMetrics();
96
+ expect(typeof metrics).toBe('string');
97
+ });
98
+
99
+ test('should handle empty override options', async () => {
100
+ const serviceEffect = createDefaultMetricsService({});
101
+ const service = await Effect.runPromise(serviceEffect);
102
+
103
+ expect(service).toBeDefined();
104
+ expect(typeof service.getMetrics).toBe('function');
105
+ });
106
+
107
+ test('should handle undefined override options', async () => {
108
+ const serviceEffect = createDefaultMetricsService(undefined);
109
+ const service = await Effect.runPromise(serviceEffect);
110
+
111
+ expect(service).toBeDefined();
112
+ expect(typeof service.getMetrics).toBe('function');
113
+ });
114
+
115
+ test('should handle complex override options', async () => {
116
+ const overrides: Partial<MetricsOptions> = {
117
+ enabled: true,
118
+ path: '/custom-metrics',
119
+ defaultLabels: { service: 'test', version: '1.0' },
120
+ collectHttpMetrics: true,
121
+ collectSystemMetrics: false,
122
+ collectGcMetrics: false,
123
+ systemMetricsInterval: 30000,
124
+ prefix: 'myapp_',
125
+ httpDurationBuckets: [0.1, 0.5, 1.0],
126
+ };
127
+
128
+ const serviceEffect = createDefaultMetricsService(overrides);
129
+ const service = await Effect.runPromise(serviceEffect);
130
+
131
+ expect(service).toBeDefined();
132
+
133
+ // Test that the service works with custom options
134
+ const counter = service.createCounter({
135
+ name: 'test_counter',
136
+ help: 'Test counter',
137
+ });
138
+ expect(counter).toBeDefined();
139
+ expect(typeof counter.inc).toBe('function');
140
+ });
141
+ });
142
+
143
+ describe('makeDefaultMetricsService', () => {
144
+ test('should create metrics service layer with default options', async () => {
145
+ const layer = makeDefaultMetricsService();
146
+ expect(layer).toBeDefined();
147
+
148
+ const program = Effect.gen(function* () {
149
+ const service = yield* MetricsService;
150
+
151
+ return service;
152
+ });
153
+
154
+ const service = await Effect.runPromise(
155
+ Effect.provide(program, layer),
156
+ );
157
+
158
+ expect(service).toBeDefined();
159
+ expect(typeof service.getMetrics).toBe('function');
160
+ expect(typeof service.getContentType).toBe('function');
161
+ });
162
+
163
+ test('should create metrics service layer with override options', async () => {
164
+ const overrides: Partial<MetricsOptions> = {
165
+ enabled: true,
166
+ prefix: 'layer_test_',
167
+ collectSystemMetrics: false,
168
+ };
169
+
170
+ const layer = makeDefaultMetricsService(overrides);
171
+ expect(layer).toBeDefined();
172
+
173
+ const program = Effect.gen(function* () {
174
+ const service = yield* MetricsService;
175
+
176
+ return service;
177
+ });
178
+
179
+ const service = await Effect.runPromise(
180
+ Effect.provide(program, layer),
181
+ );
182
+
183
+ expect(service).toBeDefined();
184
+ });
185
+
186
+ test('should handle empty override options in layer', async () => {
187
+ const layer = makeDefaultMetricsService({});
188
+ expect(layer).toBeDefined();
189
+
190
+ const program = Effect.gen(function* () {
191
+ const service = yield* MetricsService;
192
+
193
+ return service;
194
+ });
195
+
196
+ const service = await Effect.runPromise(
197
+ Effect.provide(program, layer),
198
+ );
199
+
200
+ expect(service).toBeDefined();
201
+ });
202
+
203
+ test('should handle undefined override options in layer', async () => {
204
+ const layer = makeDefaultMetricsService(undefined);
205
+ expect(layer).toBeDefined();
206
+
207
+ const program = Effect.gen(function* () {
208
+ const service = yield* MetricsService;
209
+
210
+ return service;
211
+ });
212
+
213
+ const service = await Effect.runPromise(
214
+ Effect.provide(program, layer),
215
+ );
216
+
217
+ expect(service).toBeDefined();
218
+ });
219
+
220
+ test('should create working layer with metrics functionality', async () => {
221
+ const overrides: Partial<MetricsOptions> = {
222
+ prefix: 'layer_',
223
+ enabled: true,
224
+ };
225
+
226
+ const layer = makeDefaultMetricsService(overrides);
227
+
228
+ const program = Effect.gen(function* () {
229
+ const service = yield* MetricsService;
230
+
231
+ // Test creating a metric
232
+ const counter = service.createCounter({
233
+ name: 'layer_test_counter',
234
+ help: 'Test counter for layer',
235
+ });
236
+
237
+ return { service, counter };
238
+ });
239
+
240
+ const result = await Effect.runPromise(
241
+ Effect.provide(program, layer),
242
+ );
243
+
244
+ expect(result.service).toBeDefined();
245
+ expect(result.counter).toBeDefined();
246
+ expect(typeof result.counter.inc).toBe('function');
247
+ });
248
+ });
249
+
250
+ describe('exported types and functions', () => {
251
+ test('should export MetricType enum', () => {
252
+ expect(MetricType).toBeDefined();
253
+ expect(MetricType.COUNTER).toBe(MetricType.COUNTER);
254
+ expect(MetricType.GAUGE).toBe(MetricType.GAUGE);
255
+ expect(MetricType.HISTOGRAM).toBe(MetricType.HISTOGRAM);
256
+ expect(MetricType.SUMMARY).toBe(MetricType.SUMMARY);
257
+ });
258
+
259
+ test('should export MetricsService', () => {
260
+ expect(MetricsService).toBeDefined();
261
+ });
262
+
263
+ test('should export all expected functions and types', () => {
264
+ // Test that all main exports are available
265
+ expect(typeof createDefaultMetricsService).toBe('function');
266
+ expect(typeof makeDefaultMetricsService).toBe('function');
267
+ expect(DEFAULT_METRICS_OPTIONS).toBeDefined();
268
+ expect(MetricType).toBeDefined();
269
+ expect(MetricsService).toBeDefined();
270
+ });
271
+ });
272
+
273
+ describe('require calls in convenience functions', () => {
274
+ test('createDefaultMetricsService should handle require properly', async () => {
275
+ // This tests that the require('./metrics.service') works correctly
276
+ const serviceEffect = createDefaultMetricsService({ enabled: true });
277
+
278
+ // Should not throw and should return a valid Effect
279
+ expect(serviceEffect).toBeDefined();
280
+ expect(typeof serviceEffect).toBe('object');
281
+
282
+ const service = await Effect.runPromise(serviceEffect);
283
+ expect(service).toBeDefined();
284
+ });
285
+
286
+ test('makeDefaultMetricsService should handle require properly', async () => {
287
+ // This tests that the require('./metrics.service') works correctly
288
+ const layer = makeDefaultMetricsService({ enabled: true });
289
+
290
+ // Should not throw and should return a valid Layer
291
+ expect(layer).toBeDefined();
292
+ expect(typeof layer).toBe('object');
293
+
294
+ const program = Effect.gen(function* () {
295
+ return yield* MetricsService;
296
+ });
297
+
298
+ const service = await Effect.runPromise(
299
+ Effect.provide(program, layer),
300
+ );
301
+ expect(service).toBeDefined();
302
+ });
303
+ });
304
+
305
+ describe('integration with other metrics components', () => {
306
+ test('should work with decorators', async () => {
307
+ const serviceEffect = createDefaultMetricsService({
308
+ enabled: true,
309
+ prefix: 'integration_',
310
+ });
311
+
312
+ const service = await Effect.runPromise(serviceEffect);
313
+
314
+ // Test creating various metric types
315
+ const counter = service.createCounter({
316
+ name: 'decorator_counter',
317
+ help: 'Counter for decorator integration test',
318
+ });
319
+
320
+ const gauge = service.createGauge({
321
+ name: 'decorator_gauge',
322
+ help: 'Gauge for decorator integration test',
323
+ });
324
+
325
+ const histogram = service.createHistogram({
326
+ name: 'decorator_histogram',
327
+ help: 'Histogram for decorator integration test',
328
+ });
329
+
330
+ expect(counter).toBeDefined();
331
+ expect(gauge).toBeDefined();
332
+ expect(histogram).toBeDefined();
333
+ });
334
+
335
+ test('should work with HTTP metrics', async () => {
336
+ const serviceEffect = createDefaultMetricsService({
337
+ enabled: true,
338
+ collectHttpMetrics: true,
339
+ prefix: 'http_',
340
+ });
341
+
342
+ const service = await Effect.runPromise(serviceEffect);
343
+
344
+ // Test recording HTTP metrics
345
+ expect(() => {
346
+ service.recordHttpRequest({
347
+ method: 'GET',
348
+ route: '/test',
349
+ statusCode: 200,
350
+ duration: 0.1,
351
+ controller: 'TestController',
352
+ action: 'test',
353
+ });
354
+ }).not.toThrow();
355
+ });
356
+ });
357
+ });
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * \@onebun/metrics
3
+ *
4
+ * Prometheus-compatible metrics module for OneBun framework
5
+ * Provides automatic HTTP request metrics, system metrics, and custom metrics API
6
+ */
7
+
8
+ import { DEFAULT_HTTP_DURATION_BUCKETS, DEFAULT_SYSTEM_METRICS_INTERVAL } from './types';
9
+
10
+ // Re-export commonly used prom-client types
11
+ export type {
12
+ Counter,
13
+ Gauge,
14
+ Histogram,
15
+ Summary,
16
+ } from 'prom-client';
17
+ // Decorators
18
+ export {
19
+ CountCalls,
20
+ InjectMetric,
21
+ MeasureGauge,
22
+ MeasureTime,
23
+ measureExecutionTime,
24
+ WithMetrics,
25
+ } from './decorators';
26
+ // Core service
27
+ export {
28
+ createMetricsService,
29
+ MetricsService,
30
+ makeMetricsService,
31
+ } from './metrics.service';
32
+
33
+ // Middleware
34
+ export {
35
+ MetricsMiddleware,
36
+ recordHttpMetrics,
37
+ WithMetrics as WithMetricsMiddleware,
38
+ } from './middleware';
39
+ // Types
40
+ export type {
41
+ CustomMetricConfig,
42
+ HttpMetricsData,
43
+ MetricsOptions,
44
+ MetricsRegistry,
45
+ SystemMetricsData,
46
+ } from './types';
47
+ export { MetricType } from './types';
48
+
49
+ /**
50
+ * Default metrics configuration
51
+ */
52
+ export const DEFAULT_METRICS_OPTIONS = {
53
+ enabled: true,
54
+ path: '/metrics',
55
+ defaultLabels: {},
56
+ collectHttpMetrics: true,
57
+ collectSystemMetrics: true,
58
+ collectGcMetrics: true,
59
+ systemMetricsInterval: DEFAULT_SYSTEM_METRICS_INTERVAL,
60
+ prefix: 'onebun_',
61
+ httpDurationBuckets: DEFAULT_HTTP_DURATION_BUCKETS,
62
+ } as const;
63
+
64
+ /**
65
+ * Convenience function to create metrics service with default options
66
+ */
67
+ export const createDefaultMetricsService = (
68
+ overrides: Partial<import('./types').MetricsOptions> = {},
69
+ ): ReturnType<typeof import('./metrics.service').createMetricsService> => {
70
+ const { createMetricsService } = require('./metrics.service');
71
+
72
+ return createMetricsService({
73
+ ...DEFAULT_METRICS_OPTIONS,
74
+ ...overrides,
75
+ });
76
+ };
77
+
78
+ /**
79
+ * Convenience function to create metrics layer with default options
80
+ */
81
+ export const makeDefaultMetricsService = (
82
+ overrides: Partial<import('./types').MetricsOptions> = {},
83
+ ): ReturnType<typeof import('./metrics.service').makeMetricsService> => {
84
+ const { makeMetricsService } = require('./metrics.service');
85
+
86
+ return makeMetricsService({
87
+ ...DEFAULT_METRICS_OPTIONS,
88
+ ...overrides,
89
+ });
90
+ };