@emartech/aggregate-metric-logger 6.4.0 → 6.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emartech/aggregate-metric-logger",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "description": "aggregate metrics in memory for logging",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -37,10 +37,10 @@
37
37
  "ms": "2.1.3"
38
38
  },
39
39
  "devDependencies": {
40
- "@emartech/rti-server-tools": "11.5.0",
40
+ "@emartech/rti-server-tools": "11.8.1",
41
41
  "@types/jest": "30.0.0",
42
42
  "dotenv-haphap": "3.1.2",
43
- "eslint": "10.8.1",
43
+ "eslint": "10.9.1",
44
44
  "globals": "17.11.0",
45
45
  "jest": "30.4.2",
46
46
  "semantic-release": "25.0.9"
@@ -5,8 +5,15 @@ const { createGcpTimeSeriesPublisher } = require('../gcp-time-series-publisher/g
5
5
  const { createGcpTimeSeriesBuilder } = require('../gcp-time-series-builder/gcp-time-series-builder');
6
6
  const { createCounter } = require('../metric-types/counter/counter');
7
7
  const { createDistribution } = require('../metric-types/distribution/distribution');
8
+ const { createGauge } = require('../metric-types/gauge/gauge');
8
9
  const { isNumber, isPlainObject } = require('lodash');
9
10
 
11
+ const aggregatorFactories = {
12
+ counter: createCounter,
13
+ distribution: createDistribution,
14
+ gauge: createGauge
15
+ };
16
+
10
17
  class GcpMetricCollector {
11
18
  metrics = {};
12
19
  initialized = false;
@@ -33,6 +40,18 @@ class GcpMetricCollector {
33
40
  return this.addToMetric(metricName, labels, 'distribution', value);
34
41
  }
35
42
 
43
+ gauge(metricName, labels = {}, value) {
44
+ if (!isNumber(value)) {
45
+ throw new Error(`${ value } is not a number`);
46
+ }
47
+
48
+ if (!isPlainObject(labels)) {
49
+ throw new Error('labels is not a plain object');
50
+ }
51
+
52
+ this.addToMetric(metricName, labels, 'gauge', value);
53
+ }
54
+
36
55
  start(metricName, labels = {}) {
37
56
  return { metricName, labels, start: Date.now() };
38
57
  }
@@ -55,7 +74,11 @@ class GcpMetricCollector {
55
74
  }
56
75
  const key = `${metricName}_${stringify(labels)}`;
57
76
  if (!this.metrics[key]) {
58
- const aggregator = type === 'counter' ? createCounter() : createDistribution();
77
+ const createAggregator = aggregatorFactories[type];
78
+ if (!createAggregator) {
79
+ throw new Error(`unknown metric type: ${type}`);
80
+ }
81
+ const aggregator = createAggregator();
59
82
  this.metrics[key] = { name: metricName, labels, aggregator };
60
83
  }
61
84
  this.metrics[key].aggregator.add(value);
@@ -72,12 +95,22 @@ class GcpMetricCollector {
72
95
  }
73
96
 
74
97
  async flush() {
98
+ await this.publishPending();
99
+ this.setupNextFlush();
100
+ }
101
+
102
+ async shutdown() {
103
+ clearTimeout(this.timeoutId);
104
+ this.initialized = false;
105
+ await this.publishPending();
106
+ }
107
+
108
+ async publishPending() {
75
109
  const timeSeries = createGcpTimeSeriesBuilder(this.projectId).buildTimeSeries(this.metrics);
76
110
  for (const [, metric] of Object.entries(this.metrics)) {
77
111
  metric.aggregator.advanceTimer();
78
112
  }
79
113
  await createGcpTimeSeriesPublisher(this.projectId).publishTimeSeries(timeSeries);
80
- this.setupNextFlush();
81
114
  }
82
115
  }
83
116
 
@@ -89,10 +122,15 @@ exports.count = (metricName, labels = {}, value = 1) =>
89
122
  exports.measure = (metricName, labels = {}, value = 1) =>
90
123
  collector.measure(metricName, labels, value);
91
124
 
125
+ exports.gauge = (metricName, labels = {}, value) =>
126
+ collector.gauge(metricName, labels, value);
127
+
92
128
  exports.start = (metricName, labels = {}) => collector.start(metricName, labels);
93
129
 
94
130
  exports.stop = measurement => collector.stop(measurement);
95
131
 
96
132
  exports.reset = () => collector.reset();
97
133
 
134
+ exports.shutdown = () => collector.shutdown();
135
+
98
136
  exports.createGcpMetricsCollector = projectId => new GcpMetricCollector(projectId);
@@ -343,6 +343,160 @@ describe('gcpMetricCollector', () => {
343
343
  });
344
344
  });
345
345
 
346
+ describe('gauge', () => {
347
+ it('should send a single gauge value in next flush period', () => {
348
+ const now = new Date('2026-08-25T12:03:17+00:00');
349
+ const oneMinuteInMillis = 60 * 1000;
350
+ jest.useFakeTimers().setSystemTime(now);
351
+ stubCreateTimeSeries();
352
+
353
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 42);
354
+
355
+ expect(getPublishedMetrics()).toEqual([]);
356
+
357
+ jest.advanceTimersByTime(oneMinuteInMillis);
358
+
359
+ expect(getPublishedMetrics()).toEqual([
360
+ [
361
+ {
362
+ resource: {
363
+ labels: {
364
+ project_id: 'dummy-project'
365
+ },
366
+ type: 'global'
367
+ },
368
+ name: 'job_delay',
369
+ labels: { programId: 'A' },
370
+ value: { doubleValue: 42 },
371
+ startTime: now.getTime() + oneMinuteInMillis,
372
+ endTime: now.getTime() + oneMinuteInMillis,
373
+ metricKind: 'GAUGE',
374
+ valueType: 'doubleValue'
375
+ }
376
+ ]
377
+ ]);
378
+ });
379
+
380
+ it('should keep only the last value within a flush period', async () => {
381
+ stubCreateTimeSeries();
382
+
383
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 5);
384
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 9);
385
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 3);
386
+
387
+ jest.advanceTimersByTime(60 * 1000 + 1);
388
+ await flushPromises();
389
+
390
+ expect(getPublishedMetrics()).toMatchObject([
391
+ [{ name: 'job_delay', labels: { programId: 'A' }, value: { doubleValue: 3 } }]
392
+ ]);
393
+ });
394
+
395
+ it('should send a separate metric for each label combo', async () => {
396
+ stubCreateTimeSeries();
397
+
398
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 1);
399
+ gcpMetricCollector.gauge('job_delay', { programId: 'B' }, 2);
400
+
401
+ jest.advanceTimersByTime(60 * 1000 + 1);
402
+ await flushPromises();
403
+
404
+ expect(getPublishedMetrics()).toMatchObject([
405
+ [
406
+ { name: 'job_delay', labels: { programId: 'A' }, value: { doubleValue: 1 } },
407
+ { name: 'job_delay', labels: { programId: 'B' }, value: { doubleValue: 2 } }
408
+ ]
409
+ ]);
410
+ });
411
+
412
+ it('should send metric for given gcp project id', () => {
413
+ stubCreateTimeSeries();
414
+
415
+ gcpMetricCollector
416
+ .createGcpMetricsCollector('test-project')
417
+ .gauge('job_delay', { programId: 'A' }, 42);
418
+
419
+ jest.advanceTimersByTime(60 * 1000 + 1);
420
+
421
+ expect(getPublishedMetrics()).toEqual([
422
+ [
423
+ expect.objectContaining({
424
+ resource: {
425
+ labels: {
426
+ project_id: 'test-project'
427
+ },
428
+ type: 'global'
429
+ }
430
+ })
431
+ ]
432
+ ]);
433
+ });
434
+
435
+ it('should go silent and not republish a stale value when no new value is set', async () => {
436
+ stubCreateTimeSeries();
437
+
438
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 42);
439
+
440
+ jest.advanceTimersByTime(60 * 1000 + 1);
441
+ await flushPromises();
442
+
443
+ jest.advanceTimersByTime(60 * 1000 + 1);
444
+ await flushPromises();
445
+
446
+ expect(getPublishedMetrics()).toMatchObject([
447
+ [{ name: 'job_delay', labels: { programId: 'A' }, value: { doubleValue: 42 } }]
448
+ ]);
449
+ });
450
+
451
+ it('should publish a fresh value after a silent period', async () => {
452
+ stubCreateTimeSeries();
453
+
454
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 42);
455
+
456
+ jest.advanceTimersByTime(60 * 1000 + 1);
457
+ await flushPromises();
458
+
459
+ jest.advanceTimersByTime(60 * 1000 + 1);
460
+ await flushPromises();
461
+
462
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 9);
463
+
464
+ jest.advanceTimersByTime(60 * 1000 + 1);
465
+ await flushPromises();
466
+
467
+ expect(getPublishedMetrics()).toMatchObject([
468
+ [{ value: { doubleValue: 42 } }],
469
+ [{ value: { doubleValue: 9 } }]
470
+ ]);
471
+ });
472
+
473
+ it('should not publish metrics if metric logger is not enabled', async () => {
474
+ process.env.METRIC_LOGGER_ENABLED = 'false';
475
+ stubCreateTimeSeries();
476
+
477
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 42);
478
+ jest.advanceTimersByTime(60 * 1000 + 1);
479
+
480
+ expect(getPublishedMetrics()).toEqual([]);
481
+ });
482
+
483
+ it('should validate labels parameter whether it is an object', async () => {
484
+ process.env.METRIC_LOGGER_ENABLED = 'false';
485
+
486
+ expect(() => {
487
+ gcpMetricCollector.gauge('irrelevant', new Error('not a plain object'), 42);
488
+ }).toThrow(new Error('labels is not a plain object'));
489
+ });
490
+
491
+ it('should validate value parameter whether it is a number', async () => {
492
+ process.env.METRIC_LOGGER_ENABLED = 'false';
493
+
494
+ expect(() => {
495
+ gcpMetricCollector.gauge('irrelevant', {}, '42');
496
+ }).toThrow(new Error('42 is not a number'));
497
+ });
498
+ });
499
+
346
500
  describe('start', () => {
347
501
  it('should return a measurement object with the given tag, params and start time', () => {
348
502
  const fakeCurrentTimestamp = 2323434543;
@@ -383,4 +537,50 @@ describe('gcpMetricCollector', () => {
383
537
  ]);
384
538
  });
385
539
  });
540
+
541
+ describe('shutdown', () => {
542
+ it('should flush pending metrics immediately', async () => {
543
+ stubCreateTimeSeries();
544
+
545
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 42);
546
+
547
+ expect(getPublishedMetrics()).toEqual([]);
548
+
549
+ await gcpMetricCollector.shutdown();
550
+
551
+ expect(getPublishedMetrics()).toMatchObject([
552
+ [{ name: 'job_delay', labels: { programId: 'A' }, value: { doubleValue: 42 } }]
553
+ ]);
554
+ });
555
+
556
+ it('should not schedule another flush after shutdown', async () => {
557
+ stubCreateTimeSeries();
558
+ const collector = gcpMetricCollector.createGcpMetricsCollector('test-project');
559
+ const publishPendingSpy = jest.spyOn(collector, 'publishPending');
560
+
561
+ collector.gauge('job_delay', { programId: 'A' }, 42);
562
+ await collector.shutdown();
563
+
564
+ jest.advanceTimersByTime(60 * 1000 + 1);
565
+ await flushPromises();
566
+
567
+ expect(publishPendingSpy).toHaveBeenCalledTimes(1);
568
+ });
569
+
570
+ it('should resume flushing on the next metric after shutdown', async () => {
571
+ stubCreateTimeSeries();
572
+
573
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 42);
574
+ await gcpMetricCollector.shutdown();
575
+
576
+ gcpMetricCollector.gauge('job_delay', { programId: 'A' }, 9);
577
+ jest.advanceTimersByTime(60 * 1000 + 1);
578
+ await flushPromises();
579
+
580
+ expect(getPublishedMetrics()).toMatchObject([
581
+ [{ value: { doubleValue: 42 } }],
582
+ [{ value: { doubleValue: 9 } }]
583
+ ]);
584
+ });
585
+ });
386
586
  });
@@ -7,30 +7,35 @@ class GcpTimeSeriesBuilder {
7
7
  this.projectId = projectId ?? process.env.MONITORING_PROJECT_ID;
8
8
  }
9
9
  buildTimeSeries(metrics) {
10
- return Object.values(metrics).map(metric => {
11
- const state = metric.aggregator.getState();
12
- return {
13
- metric: {
14
- type: `custom.googleapis.com/${metric.name}`,
15
- labels: { ...metric.labels, hostname: process.env.HOSTNAME }
16
- },
17
- resource: {
18
- type: 'global',
19
- labels: { project_id: this.projectId }
20
- },
21
- points: [
22
- {
23
- interval: {
24
- startTime: { seconds: state.startTime / 1000 },
25
- endTime: { seconds: state.endTime / 1000 }
26
- },
27
- value: state.value
28
- }
29
- ],
30
- metricKind: state.metricKind,
31
- valueType: state.valueType
32
- };
33
- });
10
+ return Object.values(metrics)
11
+ .filter(
12
+ metric =>
13
+ typeof metric.aggregator.hasValue !== 'function' || metric.aggregator.hasValue()
14
+ )
15
+ .map(metric => {
16
+ const state = metric.aggregator.getState();
17
+ return {
18
+ metric: {
19
+ type: `custom.googleapis.com/${metric.name}`,
20
+ labels: { ...metric.labels, hostname: process.env.HOSTNAME }
21
+ },
22
+ resource: {
23
+ type: 'global',
24
+ labels: { project_id: this.projectId }
25
+ },
26
+ points: [
27
+ {
28
+ interval: {
29
+ startTime: { seconds: state.startTime / 1000 },
30
+ endTime: { seconds: state.endTime / 1000 }
31
+ },
32
+ value: state.value
33
+ }
34
+ ],
35
+ metricKind: state.metricKind,
36
+ valueType: state.valueType
37
+ };
38
+ });
34
39
  }
35
40
  }
36
41
 
package/src/index.d.ts CHANGED
@@ -4,6 +4,12 @@ export function count(
4
4
  value?: number
5
5
  ): void;
6
6
 
7
+ export function gauge(
8
+ metricName: string,
9
+ labels: MetricLabels,
10
+ value: number
11
+ ): void;
12
+
7
13
  export function measure(
8
14
  metricName: string,
9
15
  labels?: MetricLabels,
@@ -19,6 +25,8 @@ export function stop(
19
25
  measurement: MetricTimerObject
20
26
  ): void;
21
27
 
28
+ export function shutdown(): Promise<void>;
29
+
22
30
  export type MetricLabels = {
23
31
  [key: string]: string | number | null | undefined
24
32
  };
package/src/index.js CHANGED
@@ -4,8 +4,10 @@ const gcpMetricCollector = require('./gcp-metric-collector/gcp-metric-collector'
4
4
 
5
5
  module.exports = {
6
6
  count: gcpMetricCollector.count,
7
+ gauge: gcpMetricCollector.gauge,
7
8
  measure: gcpMetricCollector.measure,
8
9
  start: gcpMetricCollector.start,
9
10
  stop: gcpMetricCollector.stop,
11
+ shutdown: gcpMetricCollector.shutdown,
10
12
  createGcpMetricsCollector: gcpMetricCollector.createGcpMetricsCollector
11
13
  };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ exports.createGauge = () => {
4
+ let value = null;
5
+
6
+ return {
7
+ getState: () => ({
8
+ startTime: Date.now(),
9
+ endTime: Date.now(),
10
+ metricKind: 'GAUGE',
11
+ valueType: 'doubleValue',
12
+ value: { doubleValue: value }
13
+ }),
14
+ add: newValue => (value = newValue),
15
+ advanceTimer: () => {
16
+ value = null;
17
+ },
18
+ hasValue: () => value !== null
19
+ };
20
+ };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ const { createGauge } = require('./gauge');
4
+
5
+ describe('gauge', () => {
6
+ it('should return an empty gauge if no value was set', () => {
7
+ const now = new Date();
8
+ jest.useFakeTimers().setSystemTime(now);
9
+ const gauge = createGauge();
10
+
11
+ expect(gauge.getState()).toEqual({
12
+ startTime: now.getTime(),
13
+ endTime: now.getTime(),
14
+ metricKind: 'GAUGE',
15
+ valueType: 'doubleValue',
16
+ value: { doubleValue: null }
17
+ });
18
+ expect(gauge.hasValue()).toBe(false);
19
+ });
20
+
21
+ it('should return the same startTime and endTime as it is an instantaneous point', () => {
22
+ jest.useFakeTimers().setSystemTime(new Date(8880));
23
+ const gauge = createGauge();
24
+
25
+ expect(gauge.getState().startTime).toBe(gauge.getState().endTime);
26
+ });
27
+
28
+ it('should store the value when it is set', () => {
29
+ const gauge = createGauge();
30
+
31
+ gauge.add(42);
32
+
33
+ expect(gauge.getState()).toMatchObject({ value: { doubleValue: 42 } });
34
+ expect(gauge.hasValue()).toBe(true);
35
+ });
36
+
37
+ it('should keep only the last value when multiple values are set', () => {
38
+ const gauge = createGauge();
39
+
40
+ gauge.add(5);
41
+ gauge.add(9);
42
+ gauge.add(3);
43
+
44
+ expect(gauge.getState()).toMatchObject({ value: { doubleValue: 3 } });
45
+ });
46
+
47
+ it('should support fractional values', () => {
48
+ const gauge = createGauge();
49
+
50
+ gauge.add(1.5);
51
+
52
+ expect(gauge.getState()).toMatchObject({ value: { doubleValue: 1.5 } });
53
+ });
54
+
55
+ it('should reset the value on advanceTimer call', () => {
56
+ const gauge = createGauge();
57
+
58
+ gauge.add(7);
59
+ gauge.advanceTimer();
60
+
61
+ expect(gauge.getState()).toMatchObject({ value: { doubleValue: null } });
62
+ expect(gauge.hasValue()).toBe(false);
63
+ });
64
+
65
+ it('should store a new value after advanceTimer call', () => {
66
+ const gauge = createGauge();
67
+
68
+ gauge.add(7);
69
+ gauge.advanceTimer();
70
+ gauge.add(2);
71
+
72
+ expect(gauge.getState()).toMatchObject({ value: { doubleValue: 2 } });
73
+ expect(gauge.hasValue()).toBe(true);
74
+ });
75
+
76
+ it('should return current time as endTime', () => {
77
+ jest.useFakeTimers().setSystemTime(new Date(880));
78
+ const gauge = createGauge();
79
+
80
+ jest.advanceTimersByTime(20);
81
+
82
+ expect(gauge.getState()).toMatchObject({ endTime: 900 });
83
+ });
84
+ });