@adalo/metrics 0.1.114 → 0.1.116

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,410 @@
1
+ const client = require('prom-client')
2
+ const https = require('https')
3
+
4
+ /**
5
+ * BaseMetricsClient provides common functionality for all metrics clients.
6
+ * Handles registry setup, pushgateway, default labels, and common operations.
7
+ */
8
+ class BaseMetricsClient {
9
+ /**
10
+ * @param {Object} config
11
+ * @param {string} [config.appName] Name of the application
12
+ * @param {string} [config.dynoId] Dyno/instance ID
13
+ * @param {string} [config.processType] Process type (web, worker, etc.)
14
+ * @param {boolean} [config.enabled] Enable metrics collection
15
+ * @param {boolean} [config.logValues] Log metrics values to console
16
+ * @param {string} [config.pushgatewayUrl] PushGateway URL
17
+ * @param {string} [config.pushgatewaySecret] PushGateway secret token
18
+ * @param {number} [config.intervalSec] Interval in seconds for pushing metrics
19
+ * @param {boolean} [config.removeOldMetrics] Enable to clear metrics by service name
20
+ * @param {function} [config.startupValidation] Add to validate on start push.
21
+ */
22
+ constructor(config = {}) {
23
+ this.appName = config.appName || process.env.BUILD_APP_NAME || 'unknown-app'
24
+ this.dynoId = config.dynoId || process.env.HOSTNAME || 'unknown-dyno'
25
+ this.processType =
26
+ config.processType ||
27
+ process.env.BUILD_DYNO_PROCESS_TYPE ||
28
+ 'undefined_build_dyno_type'
29
+ this.enabled = config.enabled ?? process.env.METRICS_ENABLED === 'true'
30
+ this.logValues =
31
+ config.logValues ?? process.env.METRICS_LOG_VALUES === 'true'
32
+ this.pushgatewayUrl =
33
+ config.pushgatewayUrl || process.env.METRICS_PUSHGATEWAY_URL || ''
34
+ this.authToken =
35
+ config.pushgatewaySecret || process.env.METRICS_PUSHGATEWAY_SECRET || ''
36
+ this.intervalSec =
37
+ config.intervalSec ||
38
+ parseInt(process.env.METRICS_INTERVAL_SEC || '', 10) ||
39
+ 15
40
+ this.startupValidation = config.startupValidation
41
+
42
+ this.prefixLogs = `[${this.processType}] [${this.appName}] [${this.dynoId}] [Monitoring]`
43
+
44
+ this._registry = new client.Registry()
45
+ client.collectDefaultMetrics({ register: this._registry })
46
+
47
+ this.defaultLabels = {
48
+ app: this.appName,
49
+ dyno_id: this.dynoId,
50
+ process_type: this.processType,
51
+ }
52
+
53
+ this.gateway = new client.Pushgateway(
54
+ this.pushgatewayUrl,
55
+ {
56
+ headers: { Authorization: `Basic ${this.authToken}` },
57
+ agent: new https.Agent({ keepAlive: true }),
58
+ },
59
+ this._registry
60
+ )
61
+ this.gauges = {}
62
+ this.counters = {}
63
+ this.countersFunctions = {}
64
+
65
+ /** @type {Object<string, function(): number | Promise<number>>} */
66
+ this.gaugeUpdaters = {}
67
+
68
+ this._clearOldWorkers(config.removeOldMetrics)
69
+ this._setCleanupHandlers()
70
+ }
71
+
72
+ /**
73
+ * Create a gauge metric.
74
+ * @param {Object} options - Gauge configuration
75
+ * @param {string} options.name - Name of the gauge
76
+ * @param {string} options.help - Help text describing the gauge
77
+ * @param {function(): number|Promise<number>} [options.updateFn] - Optional function returning the gauge value
78
+ * @param {string[]} [options.labelNames] - Optional custom label names
79
+ * @returns {import('prom-client').Gauge} The created Prometheus gauge
80
+ */
81
+ createGauge = ({
82
+ name,
83
+ help,
84
+ updateFn,
85
+ labelNames = Object.keys(this.defaultLabels),
86
+ }) => {
87
+ if (this.gauges[name]) return this.gauges[name]
88
+
89
+ const g = new client.Gauge({
90
+ name,
91
+ help,
92
+ labelNames,
93
+ registers: [this._registry],
94
+ })
95
+ this.gauges[name] = g
96
+
97
+ if (updateFn && typeof updateFn === 'function') {
98
+ this.gaugeUpdaters[name] = updateFn
99
+ }
100
+
101
+ return g
102
+ }
103
+
104
+ /**
105
+ * Create a Prometheus Counter metric.
106
+ *
107
+ * @param {Object} params - Counter configuration
108
+ * @param {string} params.name - Metric name
109
+ * @param {string} params.help - Metric description
110
+ * @param {string[]} [params.labelNames] - Optional list of label names. Defaults to this.defaultLabels keys.
111
+ *
112
+ * @returns {(labels?: Object, incrementValue?: number) => void}
113
+ * A function to increment the counter.
114
+ * Usage: (labels?, incrementValue?)
115
+ */
116
+ createCounter({ name, help, labelNames = Object.keys(this.defaultLabels) }) {
117
+ if (this.counters[name]) return this.countersFunctions[name]
118
+
119
+ const c = new client.Counter({
120
+ name,
121
+ help,
122
+ labelNames,
123
+ registers: [this._registry],
124
+ })
125
+ this.counters[name] = c
126
+
127
+ this.countersFunctions = {
128
+ ...this.countersFunctions,
129
+ [name]: (data = {}, value = 1) => {
130
+ c.inc({ ...this.defaultLabels, ...data }, value)
131
+ },
132
+ }
133
+
134
+ return this.countersFunctions[name]
135
+ }
136
+
137
+ /**
138
+ * Clear all collected counters
139
+ */
140
+ clearAllCounters = () => {
141
+ if (this.metricsLogValues) {
142
+ console.log('Counters to clear: ', Object.keys(this.counters))
143
+ }
144
+ Object.values(this.counters).forEach(counter => counter.reset())
145
+ }
146
+
147
+ /**
148
+ * Push all gauges and counters to PushGateway and optionally log.
149
+ */
150
+ pushMetrics = async () => {
151
+ try {
152
+ for (const [name, updateFn] of Object.entries(this.gaugeUpdaters)) {
153
+ try {
154
+ if (!updateFn) {
155
+ return
156
+ }
157
+ const result = updateFn()
158
+ const val = result instanceof Promise ? await result : result
159
+ if (val !== undefined) this.gauges[name].set(this.defaultLabels, val)
160
+ } catch (err) {
161
+ console.error(
162
+ `${this.prefixLogs} Failed to update gauge ${name}:`,
163
+ err
164
+ )
165
+ }
166
+ }
167
+
168
+ await this.gatewayPush()
169
+ this.clearAllCounters()
170
+
171
+ if (this.logValues) {
172
+ const metrics = await this._registry.getMetricsAsJSON()
173
+ console.log(
174
+ `${this.prefixLogs} Metrics:\n`,
175
+ JSON.stringify(metrics, null, 2)
176
+ )
177
+ }
178
+ } catch (err) {
179
+ console.error(`${this.prefixLogs} Failed to push metrics:`, err)
180
+ }
181
+ }
182
+
183
+ _startPush = (interval = this.intervalSec, customPushMetics = undefined) => {
184
+ if (!this.enabled) {
185
+ console.warn(`${this.prefixLogs} Metrics disabled`)
186
+ return
187
+ }
188
+
189
+ if (this.startupValidation && !this.startupValidation()) {
190
+ return
191
+ }
192
+
193
+ if (customPushMetics && typeof customPushMetics === 'function') {
194
+ setInterval(() => customPushMetics(), interval * 1000)
195
+ } else {
196
+ setInterval(() => {
197
+ this.pushMetrics().catch(err => {
198
+ console.error(`${this.prefixLogs} Failed to push metrics:`, err)
199
+ })
200
+ }, interval * 1000)
201
+ }
202
+
203
+ console.warn(
204
+ `${this.prefixLogs} Metrics collection started. (interval: ${this.intervalSec}s)`
205
+ )
206
+ }
207
+
208
+ /**
209
+ * Start periodic metrics collection and push.
210
+ *
211
+ * This method wraps the internal `_startPush` method.
212
+ * If a `customPushMetrics` function is provided, it will be executed
213
+ * at the given interval instead of the default `pushMetrics` behavior.
214
+ *
215
+ * @param {number} [interval=this.intervalSec] - Interval in seconds between pushes.
216
+ * @param {() => void | Promise<void>} [customPushMetrics] - Optional custom push function. If provided, Prometheus push is skipped.
217
+ */
218
+ startPush = (interval, customPushMetics = undefined) => {
219
+ this._startPush(interval, customPushMetics)
220
+ }
221
+
222
+ /**
223
+ * Cleanup metrics and exit process.
224
+ * @returns {Promise<void>}
225
+ */
226
+ cleanup = async () => {
227
+ if (this.enabled) {
228
+ await this.gatewayDelete()
229
+ }
230
+ process.exit(0)
231
+ }
232
+
233
+ /**
234
+ * Remove old/stale dyno/instance metrics from PushGateway.
235
+ *
236
+ * Compares existing PushGateway metrics for this job and deletes any instances
237
+ * that do not match the current dynoId.
238
+ *
239
+ * @param {boolean} removeOldMetrics If true, performs cleanup; otherwise does nothing
240
+ * @returns {Promise<void>}
241
+ * @private
242
+ */
243
+ _clearOldWorkers = async removeOldMetrics => {
244
+ if (!removeOldMetrics) return
245
+
246
+ try {
247
+ const url = `${this.pushgatewayUrl}/metrics`
248
+ const res = await fetch(url, {
249
+ headers: {
250
+ Authorization: `Basic ${this.authToken}`,
251
+ Accept: 'text/plain',
252
+ },
253
+ })
254
+
255
+ if (!res.ok) {
256
+ console.error(
257
+ `${this.prefixLogs} Failed to fetch metrics: ${res.status}`
258
+ )
259
+ return
260
+ }
261
+
262
+ const text = await res.text()
263
+
264
+ const metricRegex = /([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}/gm
265
+ const labelRegex = /(\w+)="([^"]*)"/g
266
+
267
+ const uniqueLabelSets = new Set()
268
+
269
+ let match
270
+ // eslint-disable-next-line no-cond-assign
271
+ while ((match = metricRegex.exec(text)) !== null) {
272
+ const rawLabels = match[2]
273
+ let lr
274
+ const labels = {}
275
+
276
+ // eslint-disable-next-line no-cond-assign
277
+ while ((lr = labelRegex.exec(rawLabels)) !== null) {
278
+ // eslint-disable-next-line prefer-destructuring
279
+ labels[lr[1]] = lr[2]
280
+ }
281
+
282
+ if (
283
+ labels.job === this.appName &&
284
+ labels.process_type === this.processType
285
+ ) {
286
+ uniqueLabelSets.add(JSON.stringify(labels))
287
+ }
288
+ }
289
+
290
+ if (uniqueLabelSets.size === 0) {
291
+ console.log(
292
+ `${this.prefixLogs} No metrics found for job ${this.appName}`
293
+ )
294
+ return
295
+ }
296
+
297
+ const oldLabelSets = [...uniqueLabelSets]
298
+ .map(s => JSON.parse(s))
299
+ .filter(
300
+ labels =>
301
+ labels.instance &&
302
+ labels.instance !== this.dynoId &&
303
+ labels.process_type === this.processType
304
+ )
305
+
306
+ if (oldLabelSets.length === 0) {
307
+ console.log(`${this.prefixLogs} No old dynos to delete.`)
308
+ return
309
+ }
310
+
311
+ for (const labels of oldLabelSets) {
312
+ try {
313
+ await this.gatewayDelete({ jobName: this.appName, groupings: labels })
314
+ console.log(
315
+ `${this.prefixLogs} Deleted metrics for dyno: ${
316
+ labels.instance
317
+ }, labels: ${Object.keys(labels)} `
318
+ )
319
+ } catch (err) {
320
+ console.error(
321
+ `${this.prefixLogs} Failed to delete metrics for ${labels.instance}:`,
322
+ err
323
+ )
324
+ }
325
+ }
326
+
327
+ console.log(
328
+ `${this.prefixLogs} Cleared all old instances for job ${this.appName}`
329
+ )
330
+ } catch (err) {
331
+ console.error(`${this.prefixLogs} Error deleting old metrics:`, err)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Delete metrics for this job/instance from PushGateway.
337
+ *
338
+ * @param {Object} [params]
339
+ * @param {string} [params.jobName] Job name (defaults to appName)
340
+ * @param {Object} [params.groupings] Grouping labels
341
+ * @param {string} [params.groupings.process_type] Process type label
342
+ * @param {string} [params.groupings.instance] Instance/dyno ID
343
+ * @returns {Promise<void>}
344
+ */
345
+ gatewayDelete = async (params = {}) => {
346
+ return this.gateway.delete({
347
+ jobName: params.jobName || this.appName,
348
+ groupings: params.groupings || {
349
+ process_type: this.processType,
350
+ instance: this.dynoId,
351
+ },
352
+ })
353
+ }
354
+
355
+ /**
356
+ * Push metrics to PushGateway.
357
+ *
358
+ * @param {object} [params]
359
+ * @param {string} [params.jobName]
360
+ * @param {object} [params.groupings]
361
+ * @returns {Promise<void>}
362
+ */
363
+ gatewayPush = async (params = {}) => {
364
+ const groupings = {
365
+ process_type: this.processType,
366
+ instance: this.dynoId,
367
+ ...(params.groupings || {}),
368
+ }
369
+ return this.gateway.push({
370
+ jobName: params.jobName || this.appName,
371
+ groupings,
372
+ })
373
+ }
374
+
375
+ /**
376
+ * Merge the default metric labels (`app`, `dyno_id`, `process_type`)
377
+ * with custom label names.
378
+ *
379
+ * @param {string[]} labels Additional label names
380
+ * @returns {string[]} Combined label names
381
+ */
382
+ withDefaultLabels = (labels = []) => {
383
+ return [...Object.keys(this.defaultLabels), ...labels]
384
+ }
385
+
386
+ getDefaultLabels = (labels = []) => {
387
+ return this.defaultLabels
388
+ }
389
+
390
+ _setCleanupHandlers = () => {
391
+ process.on('SIGINT', this.cleanup)
392
+ process.on('SIGTERM', this.cleanup)
393
+ }
394
+
395
+ // GETTERS
396
+
397
+ get metricsEnabled() {
398
+ return this.enabled
399
+ }
400
+
401
+ get metricsLogValues() {
402
+ return this.logValues
403
+ }
404
+
405
+ get registry() {
406
+ return this._registry
407
+ }
408
+ }
409
+
410
+ module.exports = { BaseMetricsClient }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './baseMetricsClient'
1
2
  export * from './metricsClient'
2
3
  export * from './metricsRedisClient'
3
4
  export * from './metricsQueueRedisClient'