@adalo/metrics 0.0.0-staging.1

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.
Files changed (85) hide show
  1. package/.env.example +14 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc +61 -0
  4. package/.github/pull_request_template.md +14 -0
  5. package/.github/workflows/code-style.yml +29 -0
  6. package/.github/workflows/deploy-staging.yml +34 -0
  7. package/.github/workflows/deploy.yml +29 -0
  8. package/.github/workflows/tests.yml +17 -0
  9. package/.idea/codeStyles/Project.xml +101 -0
  10. package/.idea/codeStyles/codeStyleConfig.xml +5 -0
  11. package/.idea/git_toolbox_prj.xml +15 -0
  12. package/.idea/inspectionProfiles/Project_Default.xml +6 -0
  13. package/.idea/jsLibraryMappings.xml +6 -0
  14. package/.idea/prettier.xml +6 -0
  15. package/.idea/vcs.xml +6 -0
  16. package/.prettierrc +10 -0
  17. package/README-health.md +234 -0
  18. package/README.md +120 -0
  19. package/__tests__/metricsRedisClient.test.js +138 -0
  20. package/babel.config.js +20 -0
  21. package/lib/health/databaseChecker.d.ts +43 -0
  22. package/lib/health/databaseChecker.d.ts.map +1 -0
  23. package/lib/health/databaseChecker.js +189 -0
  24. package/lib/health/databaseChecker.js.map +1 -0
  25. package/lib/health/healthCheckCache.d.ts +59 -0
  26. package/lib/health/healthCheckCache.d.ts.map +1 -0
  27. package/lib/health/healthCheckCache.js +187 -0
  28. package/lib/health/healthCheckCache.js.map +1 -0
  29. package/lib/health/healthCheckClient.d.ts +124 -0
  30. package/lib/health/healthCheckClient.d.ts.map +1 -0
  31. package/lib/health/healthCheckClient.js +324 -0
  32. package/lib/health/healthCheckClient.js.map +1 -0
  33. package/lib/health/healthCheckUtils.d.ts +52 -0
  34. package/lib/health/healthCheckUtils.d.ts.map +1 -0
  35. package/lib/health/healthCheckUtils.js +129 -0
  36. package/lib/health/healthCheckUtils.js.map +1 -0
  37. package/lib/health/healthCheckWorker.d.ts +2 -0
  38. package/lib/health/healthCheckWorker.d.ts.map +1 -0
  39. package/lib/health/healthCheckWorker.js +70 -0
  40. package/lib/health/healthCheckWorker.js.map +1 -0
  41. package/lib/index.d.ts +10 -0
  42. package/lib/index.d.ts.map +1 -0
  43. package/lib/index.js +105 -0
  44. package/lib/index.js.map +1 -0
  45. package/lib/metrics/baseMetricsClient.d.ts +174 -0
  46. package/lib/metrics/baseMetricsClient.d.ts.map +1 -0
  47. package/lib/metrics/baseMetricsClient.js +428 -0
  48. package/lib/metrics/baseMetricsClient.js.map +1 -0
  49. package/lib/metrics/metricsClient.d.ts +95 -0
  50. package/lib/metrics/metricsClient.d.ts.map +1 -0
  51. package/lib/metrics/metricsClient.js +239 -0
  52. package/lib/metrics/metricsClient.js.map +1 -0
  53. package/lib/metrics/metricsDatabaseClient.d.ts +74 -0
  54. package/lib/metrics/metricsDatabaseClient.d.ts.map +1 -0
  55. package/lib/metrics/metricsDatabaseClient.js +218 -0
  56. package/lib/metrics/metricsDatabaseClient.js.map +1 -0
  57. package/lib/metrics/metricsQueueRedisClient.d.ts +57 -0
  58. package/lib/metrics/metricsQueueRedisClient.d.ts.map +1 -0
  59. package/lib/metrics/metricsQueueRedisClient.js +277 -0
  60. package/lib/metrics/metricsQueueRedisClient.js.map +1 -0
  61. package/lib/metrics/metricsRedisClient.d.ts +71 -0
  62. package/lib/metrics/metricsRedisClient.d.ts.map +1 -0
  63. package/lib/metrics/metricsRedisClient.js +370 -0
  64. package/lib/metrics/metricsRedisClient.js.map +1 -0
  65. package/lib/redisUtils.d.ts +53 -0
  66. package/lib/redisUtils.d.ts.map +1 -0
  67. package/lib/redisUtils.js +140 -0
  68. package/lib/redisUtils.js.map +1 -0
  69. package/package.json +66 -0
  70. package/scripts/README.md +43 -0
  71. package/scripts/clearMetrics.js +6 -0
  72. package/src/health/databaseChecker.js +183 -0
  73. package/src/health/healthCheckCache.js +216 -0
  74. package/src/health/healthCheckClient.js +347 -0
  75. package/src/health/healthCheckUtils.js +125 -0
  76. package/src/health/healthCheckWorker.js +71 -0
  77. package/src/index.ts +9 -0
  78. package/src/metrics/baseMetricsClient.js +494 -0
  79. package/src/metrics/metricsClient.js +284 -0
  80. package/src/metrics/metricsDatabaseClient.js +236 -0
  81. package/src/metrics/metricsQueueRedisClient.js +352 -0
  82. package/src/metrics/metricsRedisClient.js +417 -0
  83. package/src/redisUtils.js +155 -0
  84. package/tsconfig.json +19 -0
  85. package/tsconfig.types.json +11 -0
@@ -0,0 +1,494 @@
1
+ const client = require('prom-client')
2
+ const https = require('https')
3
+ const http = require('http')
4
+ const { URL } = require('url')
5
+
6
+ /**
7
+ * BaseMetricsClient provides common functionality for all metrics clients.
8
+ * Handles registry setup, push to remote (VM-agent), default labels, and common operations.
9
+ * Always pushes registry to the configured URL (POST Prometheus text format + Basic auth). No Pushgateway.
10
+ */
11
+ class BaseMetricsClient {
12
+ /**
13
+ * @param {Object} config
14
+ * @param {string} [config.appName] Name of the application
15
+ * @param {string} [config.dynoId] Dyno/instance ID
16
+ * @param {string} [config.processType] Process type (web, worker, etc.)
17
+ * @param {boolean} [config.enabled] Enable metrics collection
18
+ * @param {boolean} [config.logValues] Log metrics values to console
19
+ * @param {string} [config.pushgatewayUrl] Push URL (VM-agent import endpoint, e.g. .../api/v1/import/prometheus). /metrics is for GET (scrape), not POST (push).
20
+ * @param {string} [config.pushgatewaySecret] Basic auth secret (Base64 of user:password)
21
+ * @param {number} [config.intervalSec] Interval in seconds for pushing metrics
22
+ * @param {boolean} [config.removeOldMetrics] Enable to clear metrics by service name
23
+ * @param {function} [config.startupValidation] Add to validate on start push.
24
+ * @param {boolean} [config.disablePushgateway] Disable pushing to Pushgateway (use HTTP scraping instead)
25
+ */
26
+ constructor(config = {}) {
27
+ this.appName = config.appName || process.env.BUILD_APP_NAME || 'unknown-app'
28
+ this.dynoId = config.dynoId || process.env.HOSTNAME || 'unknown-dyno'
29
+ this.processType =
30
+ config.processType ||
31
+ process.env.BUILD_DYNO_PROCESS_TYPE ||
32
+ 'undefined_build_dyno_type'
33
+ this.enabled = config.enabled ?? process.env.METRICS_ENABLED === 'true'
34
+ this.logValues =
35
+ config.logValues ?? process.env.METRICS_LOG_VALUES === 'true'
36
+ this.pushgatewayUrl =
37
+ config.pushgatewayUrl || process.env.METRICS_PUSHGATEWAY_URL || ''
38
+ this.authToken =
39
+ config.pushgatewaySecret || process.env.METRICS_PUSHGATEWAY_SECRET || ''
40
+ this.intervalSec =
41
+ config.intervalSec ||
42
+ parseInt(process.env.METRICS_INTERVAL_SEC || '', 10) ||
43
+ 15
44
+ this.startupValidation = config.startupValidation
45
+ this.disablePushgateway =
46
+ config.disablePushgateway ??
47
+ process.env.METRICS_DISABLE_PUSHGATEWAY === 'true'
48
+ this.removeOldMetrics =
49
+ config.removeOldMetrics ??
50
+ process.env.METRICS_REMOVE_OLD_METRICS === 'true'
51
+
52
+ this.prefixLogs = `[${this.processType}] [${this.appName}] [${this.dynoId}] [Monitoring]`
53
+
54
+ this._registry = new client.Registry()
55
+ client.collectDefaultMetrics({ register: this._registry })
56
+
57
+ this.defaultLabels = {
58
+ app: this.appName,
59
+ dyno_id: this.dynoId,
60
+ process_type: this.processType,
61
+ }
62
+
63
+ // Always push to configured URL (VM-agent). No Pushgateway.
64
+ this.gateway = null
65
+ this.gauges = {}
66
+ this.counters = {}
67
+ this.countersFunctions = {}
68
+
69
+ /** @type {Object<string, function(): number | Promise<number>>} */
70
+ this.gaugeUpdaters = {}
71
+
72
+ this._clearOldWorkers(config.removeOldMetrics)
73
+ this._setCleanupHandlers()
74
+
75
+ this.keepProcessAliveWhenDisabled = true
76
+ }
77
+
78
+ /**
79
+ * Create a gauge metric.
80
+ * @param {Object} options - Gauge configuration
81
+ * @param {string} options.name - Name of the gauge
82
+ * @param {string} options.help - Help text describing the gauge
83
+ * @param {function(): number|Promise<number>} [options.updateFn] - Optional function returning the gauge value
84
+ * @param {string[]} [options.labelNames] - Optional custom label names
85
+ * @returns {import('prom-client').Gauge} The created Prometheus gauge
86
+ */
87
+ createGauge = ({
88
+ name,
89
+ help,
90
+ updateFn,
91
+ labelNames = Object.keys(this.defaultLabels),
92
+ }) => {
93
+ if (this.gauges[name]) return this.gauges[name]
94
+
95
+ const g = new client.Gauge({
96
+ name,
97
+ help,
98
+ labelNames,
99
+ registers: [this._registry],
100
+ })
101
+ this.gauges[name] = g
102
+
103
+ if (updateFn && typeof updateFn === 'function') {
104
+ this.gaugeUpdaters[name] = updateFn
105
+ }
106
+
107
+ return g
108
+ }
109
+
110
+ /**
111
+ * Create a Prometheus Counter metric.
112
+ *
113
+ * @param {Object} params - Counter configuration
114
+ * @param {string} params.name - Metric name
115
+ * @param {string} params.help - Metric description
116
+ * @param {string[]} [params.labelNames] - Optional list of label names. Defaults to this.defaultLabels keys.
117
+ *
118
+ * @returns {(labels?: Object, incrementValue?: number) => void}
119
+ * A function to increment the counter.
120
+ * Usage: (labels?, incrementValue?)
121
+ */
122
+ createCounter({ name, help, labelNames = Object.keys(this.defaultLabels) }) {
123
+ if (this.counters[name]) return this.countersFunctions[name]
124
+
125
+ const c = new client.Counter({
126
+ name,
127
+ help,
128
+ labelNames,
129
+ registers: [this._registry],
130
+ })
131
+ this.counters[name] = c
132
+
133
+ this.countersFunctions = {
134
+ ...this.countersFunctions,
135
+ [name]: (data = {}, value = 1) => {
136
+ c.inc({ ...this.defaultLabels, ...data }, value)
137
+ },
138
+ }
139
+
140
+ return this.countersFunctions[name]
141
+ }
142
+
143
+ /**
144
+ * Clear all collected counters
145
+ */
146
+ clearAllCounters = () => {
147
+ if (this.metricsLogValues) {
148
+ console.log('Counters to clear: ', Object.keys(this.counters))
149
+ }
150
+ Object.values(this.counters).forEach(counter => counter.reset())
151
+ }
152
+
153
+ /**
154
+ * Push all gauges and counters to PushGateway and optionally log.
155
+ */
156
+ _pushMetrics = async () => {
157
+ try {
158
+ for (const [name, updateFn] of Object.entries(this.gaugeUpdaters)) {
159
+ try {
160
+ if (!updateFn) {
161
+ continue
162
+ }
163
+ const result = updateFn()
164
+ const val = result instanceof Promise ? await result : result
165
+ if (val !== undefined) this.gauges[name].set(this.defaultLabels, val)
166
+ } catch (err) {
167
+ console.error(
168
+ `${this.prefixLogs} Failed to update gauge ${name}:`,
169
+ err
170
+ )
171
+ }
172
+ }
173
+
174
+ if (!this.disablePushgateway) {
175
+ await this.gatewayPush()
176
+ }
177
+ // this.clearAllCounters() //TODO: or uncommit or delete (based on grafana expectation)
178
+
179
+ if (this.logValues) {
180
+ const metrics = await this._registry.getMetricsAsJSON()
181
+ console.log(
182
+ `${this.prefixLogs} Metrics:\n`,
183
+ JSON.stringify(metrics, null, 2)
184
+ )
185
+ }
186
+ } catch (err) {
187
+ console.error(`${this.prefixLogs} Failed to push metrics:`, err)
188
+ }
189
+ }
190
+
191
+ _startPush = (interval = this.intervalSec, customPushMetics = undefined) => {
192
+ if (!this.enabled) {
193
+ console.warn(`${this.prefixLogs} Metrics disabled`)
194
+ if (this.keepProcessAliveWhenDisabled && !this._idleInterval) {
195
+ this._idleInterval = setInterval(() => {}, 60 * 60 * 1000)
196
+ }
197
+ return
198
+ }
199
+
200
+ if (this._idleInterval) {
201
+ clearInterval(this._idleInterval)
202
+ this._idleInterval = null
203
+ }
204
+
205
+ if (this.startupValidation && !this.startupValidation()) {
206
+ return
207
+ }
208
+
209
+ const runPush = () => {
210
+ if (customPushMetics && typeof customPushMetics === 'function') {
211
+ return Promise.resolve(customPushMetics())
212
+ }
213
+ return this.pushMetrics()
214
+ }
215
+
216
+ if (customPushMetics && typeof customPushMetics === 'function') {
217
+ setInterval(() => customPushMetics(), interval * 1000)
218
+ } else {
219
+ setInterval(() => {
220
+ runPush().catch(err => {
221
+ console.error(`${this.prefixLogs} Failed to push metrics:`, err)
222
+ })
223
+ }, interval * 1000)
224
+ }
225
+
226
+ // First push immediately so metrics appear without waiting for the first interval
227
+ runPush().catch(err => {
228
+ console.error(`${this.prefixLogs} Failed to push metrics (initial):`, err)
229
+ })
230
+
231
+ let pushOrigin = 'none'
232
+ try {
233
+ if (this.pushgatewayUrl && this.pushgatewayUrl.trim()) {
234
+ pushOrigin = new URL(this.pushgatewayUrl.trim()).origin
235
+ }
236
+ } catch {
237
+ pushOrigin = 'invalid URL'
238
+ }
239
+ console.warn(
240
+ `${this.prefixLogs} Metrics collection started. (interval: ${this.intervalSec}s, push: ${pushOrigin})`
241
+ )
242
+ }
243
+
244
+ pushMetrics = async () => {
245
+ return this._pushMetrics()
246
+ }
247
+
248
+ /**
249
+ * Start periodic metrics collection and push.
250
+ *
251
+ * This method wraps the internal `_startPush` method.
252
+ * If a `customPushMetrics` function is provided, it will be executed
253
+ * at the given interval instead of the default `pushMetrics` behavior.
254
+ *
255
+ * @param {number} [interval=this.intervalSec] - Interval in seconds between pushes.
256
+ * @param {() => void | Promise<void>} [customPushMetrics] - Optional custom push function. If provided, Prometheus push is skipped.
257
+ */
258
+ startPush = (interval, customPushMetics = undefined) => {
259
+ this._startPush(interval, customPushMetics)
260
+ }
261
+
262
+ /**
263
+ * Cleanup metrics and exit process.
264
+ * @returns {Promise<void>}
265
+ */
266
+ cleanup = async () => {
267
+ if (this.enabled) {
268
+ await this.gatewayDelete()
269
+ }
270
+ process.exit(0)
271
+ }
272
+
273
+ /**
274
+ * Remove old/stale dyno/instance metrics from PushGateway.
275
+ *
276
+ * Compares existing PushGateway metrics for this job and deletes any instances
277
+ * that do not match the current dynoId.
278
+ *
279
+ * @param {boolean} removeOldMetrics If true, performs cleanup; otherwise does nothing
280
+ * @returns {Promise<void>}
281
+ * @private
282
+ */
283
+ _clearOldWorkers = async removeOldMetrics => {
284
+ // No Pushgateway; VM-agent does not support per-instance delete. Skip.
285
+ }
286
+
287
+ /**
288
+ * On shutdown: optionally delete this instance's metrics from VictoriaMetrics (by app, dyno_id, process_type).
289
+ * @returns {Promise<void>}
290
+ */
291
+ gatewayDelete = async () => {
292
+ if (
293
+ this.removeOldMetrics &&
294
+ this.pushgatewayUrl &&
295
+ this.pushgatewayUrl.trim()
296
+ ) {
297
+ await this._deleteFromVMByLabels().catch(err => {
298
+ console.warn(
299
+ `${this.prefixLogs} Deletion from VM on shutdown failed:`,
300
+ err.message
301
+ )
302
+ })
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Call VictoriaMetrics delete_series API to remove all series matching this instance's labels (app, dyno_id, process_type).
308
+ * @private
309
+ */
310
+ _deleteFromVMByLabels = () => {
311
+ const esc = s => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
312
+ const selector = `{app="${esc(this.appName)}",dyno_id="${esc(
313
+ this.dynoId
314
+ )}",process_type="${esc(this.processType)}"}`
315
+ let origin
316
+ try {
317
+ const u = new URL((this.pushgatewayUrl || '').trim())
318
+ origin = u.origin
319
+ } catch {
320
+ return Promise.reject(new Error('Invalid push URL'))
321
+ }
322
+ const path = `/api/v1/admin/tsdb/delete_series?match[]=${encodeURIComponent(
323
+ selector
324
+ )}`
325
+ return new Promise((resolve, reject) => {
326
+ const u = new URL(origin)
327
+ const req = (u.protocol === 'https:' ? https : http).request(
328
+ {
329
+ hostname: u.hostname,
330
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
331
+ path,
332
+ method: 'POST',
333
+ headers: {
334
+ 'Content-Length': '0',
335
+ Authorization: this.authToken
336
+ ? `Basic ${this.authToken}`
337
+ : undefined,
338
+ },
339
+ agent:
340
+ u.protocol === 'https:'
341
+ ? new https.Agent({ keepAlive: false })
342
+ : undefined,
343
+ },
344
+ res => {
345
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve()
346
+ else {
347
+ let data = ''
348
+ res.on('data', chunk => {
349
+ data += chunk
350
+ })
351
+ res.on('end', () =>
352
+ reject(new Error(`Delete failed: ${res.statusCode} ${data}`))
353
+ )
354
+ }
355
+ }
356
+ )
357
+ req.on('error', reject)
358
+ req.end()
359
+ })
360
+ }
361
+
362
+ /**
363
+ * Push registry to configured URL (VM-agent). POST Prometheus text format + Basic auth.
364
+ *
365
+ * @param {object} [params] Unused; kept for API compatibility.
366
+ * @returns {Promise<void>}
367
+ */
368
+ gatewayPush = async (params = {}) => {
369
+ if (this.disablePushgateway) {
370
+ console.warn(
371
+ `${this.prefixLogs} Metrics push skipped: METRICS_DISABLE_PUSHGATEWAY is set`
372
+ )
373
+ return Promise.resolve()
374
+ }
375
+ if (!this.pushgatewayUrl || !this.pushgatewayUrl.trim()) {
376
+ console.warn(
377
+ `${this.prefixLogs} Metrics push skipped: METRICS_PUSHGATEWAY_URL is not set`
378
+ )
379
+ return Promise.resolve()
380
+ }
381
+ return this._pushToVMAgent()
382
+ }
383
+
384
+ /**
385
+ * POST registry (Prometheus text format) to VM-agent. VM-agent accepts push at /api/v1/import/prometheus; /metrics is GET (scrape) only.
386
+ * @private
387
+ */
388
+ _pushToVMAgent = () => {
389
+ let pushUrl = (this.pushgatewayUrl || '').trim()
390
+ try {
391
+ const u = new URL(pushUrl)
392
+ if (!u.pathname || u.pathname === '/' || u.pathname === '/metrics') {
393
+ pushUrl = `${u.origin}/api/v1/import/prometheus${u.search}`
394
+ }
395
+ } catch {
396
+ // leave pushUrl as-is
397
+ }
398
+ return new Promise((resolve, reject) => {
399
+ const u = new URL(pushUrl)
400
+ const req = (u.protocol === 'https:' ? https : http).request(
401
+ {
402
+ hostname: u.hostname,
403
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
404
+ path: u.pathname + u.search,
405
+ method: 'POST',
406
+ headers: {
407
+ 'Content-Type': client.register.contentType,
408
+ Authorization: this.authToken
409
+ ? `Basic ${this.authToken}`
410
+ : undefined,
411
+ },
412
+ agent:
413
+ u.protocol === 'https:'
414
+ ? new https.Agent({ keepAlive: true })
415
+ : undefined,
416
+ },
417
+ res => {
418
+ if (res.statusCode >= 200 && res.statusCode < 300) {
419
+ resolve()
420
+ } else {
421
+ let data = ''
422
+ res.on('data', chunk => {
423
+ data += chunk
424
+ })
425
+ res.on('end', () =>
426
+ reject(new Error(`Push failed: ${res.statusCode} ${data}`))
427
+ )
428
+ }
429
+ }
430
+ )
431
+ req.on('error', reject)
432
+ this._registry
433
+ .metrics()
434
+ .then(metrics => {
435
+ req.setHeader('Content-Length', Buffer.byteLength(metrics, 'utf8'))
436
+ req.end(metrics, 'utf8')
437
+ })
438
+ .catch(reject)
439
+ })
440
+ }
441
+
442
+ /**
443
+ * Merge the default metric labels (`app`, `dyno_id`, `process_type`)
444
+ * with custom label names.
445
+ *
446
+ * @param {string[]} labels Additional label names
447
+ * @returns {string[]} Combined label names
448
+ */
449
+ withDefaultLabels = (labels = []) => {
450
+ return [...Object.keys(this.defaultLabels), ...labels]
451
+ }
452
+
453
+ getDefaultLabels = (labels = []) => {
454
+ return this.defaultLabels
455
+ }
456
+
457
+ _setCleanupHandlers = () => {
458
+ process.on('SIGINT', this.cleanup)
459
+ process.on('SIGTERM', this.cleanup)
460
+ }
461
+
462
+ // GETTERS
463
+
464
+ get metricsEnabled() {
465
+ return this.enabled
466
+ }
467
+
468
+ get metricsLogValues() {
469
+ return this.logValues
470
+ }
471
+
472
+ get registry() {
473
+ return this._registry
474
+ }
475
+
476
+ async getMetricsAsString() {
477
+ return this._registry.metrics()
478
+ }
479
+
480
+ metricsMiddleware() {
481
+ return async (req, res) => {
482
+ try {
483
+ const metrics = await this.getMetricsAsString()
484
+ res.set('Content-Type', client.register.contentType)
485
+ res.end(metrics)
486
+ } catch (err) {
487
+ console.error(`${this.prefixLogs} Failed to get metrics:`, err)
488
+ res.status(500).end('Failed to collect metrics')
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ module.exports = { BaseMetricsClient }