@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,531 @@
1
+ /* eslint-disable
2
+ @typescript-eslint/no-empty-function,
3
+ @typescript-eslint/no-explicit-any,
4
+ @typescript-eslint/no-unused-vars,
5
+ no-console */
6
+ import {
7
+ describe,
8
+ test,
9
+ expect,
10
+ beforeEach,
11
+ afterEach,
12
+ mock,
13
+ } from 'bun:test';
14
+ import { Effect } from 'effect';
15
+ import { register } from 'prom-client';
16
+
17
+ // eslint-disable-next-line import/no-extraneous-dependencies
18
+ import { useFakeTimers } from '@onebun/core';
19
+
20
+ import {
21
+ MeasureTime,
22
+ CountCalls,
23
+ MeasureGauge,
24
+ InjectMetric,
25
+ WithMetrics,
26
+ measureExecutionTime,
27
+ } from './decorators';
28
+ import { MetricsService, createMetricsService } from './metrics.service';
29
+ import { MetricType } from './types';
30
+
31
+ // Helper to capture console output
32
+ const originalConsoleLog = console.log;
33
+ const originalConsoleWarn = console.warn;
34
+
35
+ describe('Metrics Decorators', () => {
36
+ let capturedLogs: string[] = [];
37
+ let capturedWarns: string[] = [];
38
+ let mockMetricsService: any;
39
+
40
+ beforeEach(() => {
41
+ // Clear prometheus registry to avoid duplicate metrics
42
+ register.clear();
43
+
44
+ capturedLogs = [];
45
+ capturedWarns = [];
46
+
47
+ console.log = (...args: any[]) => {
48
+ capturedLogs.push(args.join(' '));
49
+ };
50
+
51
+ console.warn = (...args: any[]) => {
52
+ capturedWarns.push(args.join(' '));
53
+ };
54
+
55
+ // Mock metrics service
56
+ mockMetricsService = {
57
+ getMetric: mock(() => ({
58
+ observe: mock(() => {}),
59
+ inc: mock(() => {}),
60
+ set: mock(() => {}),
61
+ })),
62
+ };
63
+
64
+ // Set global metrics service
65
+ (globalThis as any).__onebunMetricsService = mockMetricsService;
66
+ });
67
+
68
+ afterEach(() => {
69
+ console.log = originalConsoleLog;
70
+ console.warn = originalConsoleWarn;
71
+ delete (globalThis as any).__onebunMetricsService;
72
+ register.clear();
73
+ });
74
+
75
+ describe('MeasureTime decorator', () => {
76
+ test('should measure execution time for synchronous method', () => {
77
+ class TestService {
78
+ @MeasureTime()
79
+ syncMethod(value: number): number {
80
+ return value * 2;
81
+ }
82
+ }
83
+
84
+ const service = new TestService();
85
+ const result = service.syncMethod(5);
86
+
87
+ expect(result).toBe(10);
88
+ expect(mockMetricsService.getMetric).toHaveBeenCalled();
89
+ });
90
+
91
+ test('should measure execution time for asynchronous method', async () => {
92
+ const timers = useFakeTimers();
93
+
94
+ class TestService {
95
+ @MeasureTime()
96
+ async asyncMethod(value: number): Promise<number> {
97
+ await new Promise(resolve => setTimeout(resolve, 10));
98
+
99
+ return value * 2;
100
+ }
101
+ }
102
+
103
+ const service = new TestService();
104
+ const resultPromise = service.asyncMethod(5);
105
+
106
+ // Advance timers to resolve the setTimeout
107
+ timers.advanceTime(10);
108
+
109
+ const result = await resultPromise;
110
+
111
+ expect(result).toBe(10);
112
+ expect(mockMetricsService.getMetric).toHaveBeenCalled();
113
+
114
+ timers.restore();
115
+ });
116
+
117
+ test('should measure execution time even when method throws', () => {
118
+ class TestService {
119
+ @MeasureTime()
120
+ throwingMethod(): never {
121
+ throw new Error('Test error');
122
+ }
123
+ }
124
+
125
+ const service = new TestService();
126
+ expect(() => service.throwingMethod()).toThrow('Test error');
127
+ expect(mockMetricsService.getMetric).toHaveBeenCalled();
128
+ });
129
+
130
+ test('should measure execution time even when async method rejects', async () => {
131
+ class TestService {
132
+ @MeasureTime()
133
+ async rejectingMethod(): Promise<never> {
134
+ throw new Error('Async error');
135
+ }
136
+ }
137
+
138
+ const service = new TestService();
139
+ await expect(service.rejectingMethod()).rejects.toThrow('Async error');
140
+ expect(mockMetricsService.getMetric).toHaveBeenCalled();
141
+ });
142
+
143
+ test('should use custom metric name when provided', () => {
144
+ class TestService {
145
+ @MeasureTime('custom_metric')
146
+ customMethod(): number {
147
+ return 42;
148
+ }
149
+ }
150
+
151
+ const service = new TestService();
152
+ service.customMethod();
153
+
154
+ expect(mockMetricsService.getMetric).toHaveBeenCalledWith('custom_metric');
155
+ });
156
+
157
+ test('should generate default metric name from class and method', () => {
158
+ class TestService {
159
+ @MeasureTime()
160
+ testMethod(): number {
161
+ return 42;
162
+ }
163
+ }
164
+
165
+ const service = new TestService();
166
+ service.testMethod();
167
+
168
+ expect(mockMetricsService.getMetric).toHaveBeenCalledWith('TestService_testMethod_duration');
169
+ });
170
+
171
+ test('should handle missing metrics service gracefully', () => {
172
+ delete (globalThis as any).__onebunMetricsService;
173
+
174
+ class TestService {
175
+ @MeasureTime()
176
+ testMethod(): number {
177
+ return 42;
178
+ }
179
+ }
180
+
181
+ const service = new TestService();
182
+ expect(() => service.testMethod()).not.toThrow();
183
+ expect(service.testMethod()).toBe(42);
184
+ });
185
+
186
+ test('should handle metric without observe method', () => {
187
+ mockMetricsService.getMetric = mock(() => ({}));
188
+
189
+ class TestService {
190
+ @MeasureTime()
191
+ testMethod(): number {
192
+ return 42;
193
+ }
194
+ }
195
+
196
+ const service = new TestService();
197
+ expect(() => service.testMethod()).not.toThrow();
198
+ expect(service.testMethod()).toBe(42);
199
+ });
200
+ });
201
+
202
+ describe('CountCalls decorator', () => {
203
+ test('should increment counter on method call', () => {
204
+ class TestService {
205
+ @CountCalls()
206
+ countedMethod(value: number): number {
207
+ return value * 2;
208
+ }
209
+ }
210
+
211
+ const service = new TestService();
212
+ const result = service.countedMethod(5);
213
+
214
+ expect(result).toBe(10);
215
+ expect(mockMetricsService.getMetric).toHaveBeenCalled();
216
+ });
217
+
218
+ test('should use custom metric name when provided', () => {
219
+ class TestService {
220
+ @CountCalls('custom_counter')
221
+ customMethod(): number {
222
+ return 42;
223
+ }
224
+ }
225
+
226
+ const service = new TestService();
227
+ service.customMethod();
228
+
229
+ expect(mockMetricsService.getMetric).toHaveBeenCalledWith('custom_counter');
230
+ });
231
+
232
+ test('should generate default counter name from class and method', () => {
233
+ class TestService {
234
+ @CountCalls()
235
+ testMethod(): number {
236
+ return 42;
237
+ }
238
+ }
239
+
240
+ const service = new TestService();
241
+ service.testMethod();
242
+
243
+ expect(mockMetricsService.getMetric).toHaveBeenCalledWith('TestService_testMethod_calls_total');
244
+ });
245
+
246
+ test('should handle missing metrics service gracefully', () => {
247
+ delete (globalThis as any).__onebunMetricsService;
248
+
249
+ class TestService {
250
+ @CountCalls()
251
+ testMethod(): number {
252
+ return 42;
253
+ }
254
+ }
255
+
256
+ const service = new TestService();
257
+ expect(() => service.testMethod()).not.toThrow();
258
+ expect(service.testMethod()).toBe(42);
259
+ });
260
+
261
+ test('should handle metric without inc method', () => {
262
+ mockMetricsService.getMetric = mock(() => ({}));
263
+
264
+ class TestService {
265
+ @CountCalls()
266
+ testMethod(): number {
267
+ return 42;
268
+ }
269
+ }
270
+
271
+ const service = new TestService();
272
+ expect(() => service.testMethod()).not.toThrow();
273
+ expect(service.testMethod()).toBe(42);
274
+ });
275
+
276
+ test('should count calls with labels', () => {
277
+ class TestService {
278
+ @CountCalls('labeled_counter', ['method', 'service'])
279
+ labeledMethod(): number {
280
+ return 42;
281
+ }
282
+ }
283
+
284
+ const service = new TestService();
285
+ service.labeledMethod();
286
+
287
+ expect(mockMetricsService.getMetric).toHaveBeenCalledWith('labeled_counter');
288
+ });
289
+ });
290
+
291
+ describe('MeasureGauge decorator', () => {
292
+ test('should update gauge after synchronous method execution', () => {
293
+ let gaugeValue = 100;
294
+ const getValue = () => gaugeValue;
295
+
296
+ class TestService {
297
+ @MeasureGauge('test_gauge', getValue)
298
+ updateGaugeMethod(): void {
299
+ gaugeValue = 200;
300
+ }
301
+ }
302
+
303
+ const service = new TestService();
304
+ service.updateGaugeMethod();
305
+
306
+ expect(mockMetricsService.getMetric).toHaveBeenCalledWith('test_gauge');
307
+ });
308
+
309
+ test('should update gauge after asynchronous method execution', async () => {
310
+ const timers = useFakeTimers();
311
+
312
+ let gaugeValue = 100;
313
+ const getValue = () => gaugeValue;
314
+
315
+ class TestService {
316
+ @MeasureGauge('test_gauge', getValue)
317
+ async updateGaugeMethodAsync(): Promise<void> {
318
+ await new Promise(resolve => setTimeout(resolve, 10));
319
+ gaugeValue = 200;
320
+ }
321
+ }
322
+
323
+ const service = new TestService();
324
+ const resultPromise = service.updateGaugeMethodAsync();
325
+
326
+ // Advance timers to resolve the setTimeout
327
+ timers.advanceTime(10);
328
+
329
+ await resultPromise;
330
+
331
+ expect(mockMetricsService.getMetric).toHaveBeenCalledWith('test_gauge');
332
+
333
+ timers.restore();
334
+ });
335
+
336
+ test('should handle getValue function throwing error', () => {
337
+ const getValue = () => {
338
+ throw new Error('getValue error');
339
+ };
340
+
341
+ class TestService {
342
+ @MeasureGauge('test_gauge', getValue)
343
+ testMethod(): number {
344
+ return 42;
345
+ }
346
+ }
347
+
348
+ const service = new TestService();
349
+ service.testMethod();
350
+
351
+ expect(capturedWarns).toContain('Failed to update gauge test_gauge: Error: getValue error');
352
+ });
353
+
354
+ test('should handle missing metrics service gracefully', () => {
355
+ delete (globalThis as any).__onebunMetricsService;
356
+ const getValue = () => 100;
357
+
358
+ class TestService {
359
+ @MeasureGauge('test_gauge', getValue)
360
+ testMethod(): number {
361
+ return 42;
362
+ }
363
+ }
364
+
365
+ const service = new TestService();
366
+ expect(() => service.testMethod()).not.toThrow();
367
+ expect(service.testMethod()).toBe(42);
368
+ });
369
+
370
+ test('should handle metric without set method', () => {
371
+ mockMetricsService.getMetric = mock(() => ({}));
372
+ const getValue = () => 100;
373
+
374
+ class TestService {
375
+ @MeasureGauge('test_gauge', getValue)
376
+ testMethod(): number {
377
+ return 42;
378
+ }
379
+ }
380
+
381
+ const service = new TestService();
382
+ expect(() => service.testMethod()).not.toThrow();
383
+ expect(service.testMethod()).toBe(42);
384
+ });
385
+ });
386
+
387
+ describe('InjectMetric decorator', () => {
388
+ test('should log metric injection information', () => {
389
+ class TestService {
390
+ @InjectMetric({
391
+ name: 'test_metric',
392
+ help: 'Test metric',
393
+ type: MetricType.COUNTER,
394
+ })
395
+ injectedCounter: any;
396
+ }
397
+
398
+ expect(capturedLogs).toContain('Metric test_metric will be injected into TestService.injectedCounter');
399
+ });
400
+
401
+ test('should handle complex metric configuration', () => {
402
+ class TestService {
403
+ @InjectMetric({
404
+ name: 'complex_metric',
405
+ help: 'Complex metric with labels',
406
+ type: MetricType.HISTOGRAM,
407
+ labelNames: ['method', 'status'],
408
+ buckets: [0.1, 0.5, 1, 5],
409
+ })
410
+ complexMetric: any;
411
+ }
412
+
413
+ expect(capturedLogs).toContain('Metric complex_metric will be injected into TestService.complexMetric');
414
+ });
415
+ });
416
+
417
+ describe('WithMetrics decorator', () => {
418
+ test('should apply metrics to class with default options', () => {
419
+ @WithMetrics()
420
+ class TestService {
421
+ getValue(): number {
422
+ return 42;
423
+ }
424
+ }
425
+
426
+ const service = new TestService();
427
+ expect(service.getValue()).toBe(42);
428
+ expect(capturedLogs).toContain('WithMetrics applied to TestService with prefix: none');
429
+ });
430
+
431
+ test('should apply metrics to class with custom prefix', () => {
432
+ @WithMetrics({ prefix: 'custom_' })
433
+ class TestService {
434
+ getValue(): number {
435
+ return 42;
436
+ }
437
+ }
438
+
439
+ const service = new TestService();
440
+ expect(service.getValue()).toBe(42);
441
+ expect(capturedLogs).toContain('WithMetrics applied to TestService with prefix: custom_');
442
+ });
443
+
444
+ test('should preserve original class functionality', () => {
445
+ @WithMetrics({ prefix: 'test_' })
446
+ class TestService {
447
+ private value = 100;
448
+
449
+ constructor(initialValue?: number) {
450
+ if (initialValue !== undefined) {
451
+ this.value = initialValue;
452
+ }
453
+ }
454
+
455
+ getValue(): number {
456
+ return this.value;
457
+ }
458
+
459
+ setValue(newValue: number): void {
460
+ this.value = newValue;
461
+ }
462
+ }
463
+
464
+ const service = new TestService(200);
465
+ expect(service.getValue()).toBe(200);
466
+
467
+ service.setValue(300);
468
+ expect(service.getValue()).toBe(300);
469
+ });
470
+ });
471
+
472
+ describe('measureExecutionTime Effect function', () => {
473
+ test('should be defined and exported', () => {
474
+ expect(typeof measureExecutionTime).toBe('function');
475
+ expect(measureExecutionTime).toBeDefined();
476
+ });
477
+
478
+ test('should create an Effect that requires MetricsService', () => {
479
+ const testEffect = Effect.succeed(42);
480
+ const measuredEffect = measureExecutionTime('test_metric', testEffect);
481
+
482
+ expect(measuredEffect).toBeDefined();
483
+ expect(typeof measuredEffect).toBe('object');
484
+ });
485
+ });
486
+
487
+ describe('helper functions error handling', () => {
488
+ test('should handle missing global metrics service in various scenarios', () => {
489
+ delete (globalThis as any).__onebunMetricsService;
490
+
491
+ class TestService {
492
+ @MeasureTime()
493
+ @CountCalls()
494
+ testMethod(): number {
495
+ return 42;
496
+ }
497
+ }
498
+
499
+ const service = new TestService();
500
+ expect(() => service.testMethod()).not.toThrow();
501
+ expect(service.testMethod()).toBe(42);
502
+ });
503
+
504
+ test('should handle globalThis being undefined', () => {
505
+ const originalGlobalThis = globalThis;
506
+
507
+ // Mock undefined globalThis
508
+ Object.defineProperty(global, 'globalThis', {
509
+ value: undefined,
510
+ configurable: true,
511
+ });
512
+
513
+ class TestService {
514
+ @MeasureTime()
515
+ testMethod(): number {
516
+ return 42;
517
+ }
518
+ }
519
+
520
+ const service = new TestService();
521
+ expect(() => service.testMethod()).not.toThrow();
522
+ expect(service.testMethod()).toBe(42);
523
+
524
+ // Restore globalThis
525
+ Object.defineProperty(global, 'globalThis', {
526
+ value: originalGlobalThis,
527
+ configurable: true,
528
+ });
529
+ });
530
+ });
531
+ });