@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
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@adalo/metrics",
3
+ "version": "0.0.0-staging.1",
4
+ "description": "Reusable metrics utilities for Node.js and Laravel apps",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "scripts": {
8
+ "build": "babel src --out-dir lib --source-maps --extensions .js,.ts && yarn build:types",
9
+ "build:types": "tsc --project ./tsconfig.types.json",
10
+ "watch": "yarn build -- --watch",
11
+ "start": "yarn watch",
12
+ "test": "jest",
13
+ "test:watch": "yarn test --watch",
14
+ "test:all": "yarn format:check && yarn test",
15
+ "types:check": "tsc --project ./tsconfig.json",
16
+ "prettier": "prettier --write 'src/**'",
17
+ "prettier:check": "prettier --check 'src/**'",
18
+ "lint": "eslint --fix src --ext .js,.ts",
19
+ "lint:check": "eslint src --ext .js,.ts",
20
+ "format": "yarn prettier && yarn lint",
21
+ "format:check": "yarn prettier:check && yarn lint:check"
22
+ },
23
+ "author": "@AdaloHQ",
24
+ "license": "ISC",
25
+ "dependencies": {
26
+ "bee-queue": "^1.2.2",
27
+ "dotenv": "^8.2.0",
28
+ "mysql2": "^3.11.0",
29
+ "pg": "^8.16.3",
30
+ "prom-client": "^15.1.3",
31
+ "@types/pg": "^8.15.6"
32
+ },
33
+ "devDependencies": {
34
+ "@babel/cli": "^7.24.0",
35
+ "@babel/core": "^7.24.0",
36
+ "@babel/preset-env": "^7.24.0",
37
+ "@babel/preset-typescript": "^7.24.0",
38
+ "@types/ioredis": "^5.0.0",
39
+ "@types/jest": "28.1.6",
40
+ "@types/node": "18.0.4",
41
+ "@types/redis": "2",
42
+ "@typescript-eslint/eslint-plugin": "5.30.6",
43
+ "@typescript-eslint/parser": "5.30.6",
44
+ "babel-jest": "28.1.3",
45
+ "eslint": "~8.4.1",
46
+ "eslint-config-airbnb-base": "~15.0.0",
47
+ "eslint-config-airbnb-typescript": "17.0.0",
48
+ "eslint-config-prettier": "~8.3.0",
49
+ "eslint-import-resolver-typescript": "2.7.1",
50
+ "eslint-plugin-import": "~2.25.3",
51
+ "jest": "28.1.3",
52
+ "prettier": "2.5.1",
53
+ "typescript": "4.7.4"
54
+ },
55
+ "resolutions": {
56
+ "@types/istanbul-lib-report": "^3.0.0"
57
+ },
58
+ "jest": {
59
+ "testMatch": [
60
+ "**/__tests__/**/*.[jt]s?(x)"
61
+ ]
62
+ },
63
+ "engines": {
64
+ "node": ">=13.0.0"
65
+ }
66
+ }
@@ -0,0 +1,43 @@
1
+ # This is utils for manage metrics
2
+
3
+
4
+ ## Type envs to handling require metrics
5
+ ```js
6
+ BUILD_APP_NAME=
7
+ HOSTNAME=
8
+ BUILD_DYNO_PROCESS_TYPE=
9
+ METRICS_ENABLED=
10
+ METRICS_LOG_VALUES=
11
+ METRICS_PUSHGATEWAY_URL=
12
+ METRICS_PUSHGATEWAY_SECRET=
13
+ METRICS_INTERVAL_SEC=
14
+
15
+ ```
16
+
17
+
18
+ ## Clear metriks for service
19
+ ```
20
+ node scripts/clearMetrics.js
21
+ ```
22
+
23
+ ## Publish metrics to VM-agent (example)
24
+
25
+ Publishes built-in gauges (CPU, memory, uptime, etc.) and an example custom gauge to VM-agent.
26
+
27
+ 1. Set env (or `.env`):
28
+ - `METRICS_PUSHGATEWAY_URL` – VM-agent import URL, e.g. `https://vm-agent.staging.infradalogs.adalo.com/api/v1/import/prometheus`
29
+ - `METRICS_PUSHGATEWAY_SECRET` – Base64 of `user:password` (same as VM-agent BASIC_USER / BASIC_PASSWORD)
30
+ - `METRICS_ENABLED=true`
31
+ - `METRICS_INTERVAL_SEC=15` (optional, default 15)
32
+
33
+ 2. Generate secret:
34
+ ```bash
35
+ echo -n 'user:password' | base64
36
+ ```
37
+
38
+ 3. Run:
39
+ ```bash
40
+ node scripts/publish-metrics-example.js
41
+ ```
42
+
43
+ Metrics appear in VM-agent (and VictoriaMetrics) under names like `app_process_cpu_usage_percent`, `app_uptime_seconds`, `app_example_custom_total`, etc.
@@ -0,0 +1,6 @@
1
+ const { BaseMetricsClient } = require('../src')
2
+ require('dotenv').config()
3
+
4
+ new BaseMetricsClient({
5
+ removeOldMetrics: true,
6
+ })
@@ -0,0 +1,183 @@
1
+ const { Pool } = require('pg')
2
+ const mysql = require('mysql2/promise')
3
+
4
+ const DB_TYPE_POSTGRES = 'postgres'
5
+ const DB_TYPE_MYSQL = 'mysql'
6
+
7
+ /**
8
+ * @returns {bigint}
9
+ */
10
+ function nowNs() {
11
+ return process.hrtime.bigint()
12
+ }
13
+
14
+ /**
15
+ * @param {bigint} deltaNs
16
+ * @returns {number}
17
+ */
18
+ function nsToMs(deltaNs) {
19
+ return Number(deltaNs) / 1_000_000
20
+ }
21
+
22
+ /**
23
+ * @param {string} url
24
+ * @returns {string} postgres | mysql
25
+ */
26
+ function getDatabaseType(url) {
27
+ if (!url || typeof url !== 'string') return DB_TYPE_POSTGRES
28
+ const lower = url.toLowerCase()
29
+ if (lower.startsWith('mysql://') || lower.startsWith('mysql2://')) {
30
+ return DB_TYPE_MYSQL
31
+ }
32
+ if (lower.startsWith('mariadb://')) {
33
+ return DB_TYPE_MYSQL
34
+ }
35
+ return DB_TYPE_POSTGRES
36
+ }
37
+
38
+ /**
39
+ * @param {string} url
40
+ * @param {string} [type]
41
+ * @returns {{ host: string, port: number, user: string, password: string, database: string } | null}
42
+ */
43
+ function parseConnectionUrl(url, type) {
44
+ try {
45
+ const parsed = new URL(url)
46
+ const defaultPort = type === DB_TYPE_MYSQL ? 3306 : 5432
47
+ const defaultDb = type === DB_TYPE_MYSQL ? 'mysql' : 'postgres'
48
+ return {
49
+ host: parsed.hostname || 'localhost',
50
+ port: parseInt(parsed.port || String(defaultPort), 10),
51
+ user: parsed.username || '',
52
+ password: parsed.password || '',
53
+ database: (parsed.pathname || '/').replace(/^\//, '') || defaultDb,
54
+ }
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {string} env
62
+ * @param {string} url
63
+ * @param {number} connectionTimeoutMs
64
+ * @returns {{ pool: any, type: string }}
65
+ */
66
+ function createDatabasePool(env, url, connectionTimeoutMs) {
67
+ const type = getDatabaseType(url)
68
+
69
+ if (type === DB_TYPE_MYSQL) {
70
+ const config = parseConnectionUrl(url, DB_TYPE_MYSQL)
71
+ if (!config) {
72
+ throw new Error(`Invalid MySQL URL for ${env}`)
73
+ }
74
+ const pool = mysql.createPool({
75
+ host: config.host,
76
+ port: config.port,
77
+ user: config.user,
78
+ password: config.password,
79
+ database: config.database,
80
+ connectionLimit: 1,
81
+ connectTimeout: connectionTimeoutMs,
82
+ waitForConnections: false,
83
+ })
84
+ return { pool, type: DB_TYPE_MYSQL }
85
+ }
86
+
87
+ const pool = new Pool({
88
+ connectionString: url,
89
+ max: 1,
90
+ idleTimeoutMillis: 30000,
91
+ connectionTimeoutMillis: connectionTimeoutMs,
92
+ })
93
+ return { pool, type: DB_TYPE_POSTGRES }
94
+ }
95
+
96
+ /**
97
+ * @param {any} pool
98
+ * @param {string} type
99
+ * @returns {Promise<{ connectMs: number }>}
100
+ */
101
+ async function runHealthCheck(pool, type) {
102
+ if (type === DB_TYPE_MYSQL) {
103
+ let connectMs = 0.0
104
+
105
+ /** @type {any} */
106
+ let conn
107
+ try {
108
+ const startConnect = nowNs()
109
+ conn = await pool.getConnection()
110
+ connectMs = nsToMs(nowNs() - startConnect)
111
+
112
+ await conn.query('SELECT 1')
113
+ return { connectMs }
114
+ } catch (err) {
115
+ err.healthCheckTimings = {
116
+ connectMs,
117
+ }
118
+ throw err
119
+ } finally {
120
+ if (conn) {
121
+ try {
122
+ // Destroy the connection so next check measures a real connect.
123
+ // mysql2 pooled connections support destroy() to remove it from the pool.
124
+ if (typeof conn.destroy === 'function') conn.destroy()
125
+ else if (typeof conn.end === 'function') await conn.end()
126
+ else if (typeof conn.release === 'function') conn.release()
127
+ } catch {
128
+ // ignore
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ let connectMs = 0.0
135
+
136
+ /** @type {import('pg').PoolClient | null} */
137
+ let client = null
138
+ try {
139
+ const startConnect = nowNs()
140
+ client = await pool.connect()
141
+ connectMs = nsToMs(nowNs() - startConnect)
142
+
143
+ await client.query('SELECT 1')
144
+ return { connectMs }
145
+ } catch (err) {
146
+ err.healthCheckTimings = {
147
+ connectMs,
148
+ }
149
+ throw err
150
+ } finally {
151
+ if (client) {
152
+ try {
153
+ // Force the pool to drop the client so next check measures a real connect.
154
+ // In node-postgres, passing a truthy value removes the client from the pool.
155
+ client.release(true)
156
+ } catch {
157
+ // ignore
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * @param {any} pool
165
+ * @returns {Promise<void>}
166
+ */
167
+ async function closePool(pool) {
168
+ try {
169
+ await pool.end()
170
+ } catch {
171
+ // ignore
172
+ }
173
+ }
174
+
175
+ module.exports = {
176
+ getDatabaseType,
177
+ parseConnectionUrl,
178
+ createDatabasePool,
179
+ runHealthCheck,
180
+ closePool,
181
+ DB_TYPE_POSTGRES,
182
+ DB_TYPE_MYSQL,
183
+ }
@@ -0,0 +1,216 @@
1
+ const {
2
+ getRedisClientType,
3
+ REDIS_V4,
4
+ IOREDIS,
5
+ REDIS_V3,
6
+ } = require('../redisUtils')
7
+
8
+ /**
9
+ * HealthCheckCache provides a shared cache layer for health check results.
10
+ * It uses Redis if available for cross-process sharing, with graceful fallback
11
+ * to in-memory cache if Redis is not configured or unavailable.
12
+ */
13
+ class HealthCheckCache {
14
+ /**
15
+ * @param {Object} options
16
+ * @param {any} [options.redisClient]
17
+ * @param {string} [options.cacheKey] - Redis key (e.g. 'health:database:status')
18
+ * @param {string} [options.appName] - Used when cacheKey not set: healthcheck:${appName}
19
+ * @param {number} [options.staleThresholdMs]
20
+ */
21
+ constructor(options = {}) {
22
+ this.redisClient = options.redisClient || null
23
+ this.appName =
24
+ options.appName || process.env.BUILD_APP_NAME || 'unknown-app'
25
+ this.staleThresholdMs = options.staleThresholdMs ?? 180_000
26
+ this.cacheKey = options.cacheKey || `healthcheck:${this.appName}`
27
+
28
+ /** In-memory fallback cache */
29
+ this._memoryCache = null
30
+ this._memoryCacheTimestamp = null
31
+
32
+ if (this.redisClient) {
33
+ this._redisClientType = getRedisClientType(this.redisClient)
34
+ this._redisAvailable = true
35
+ } else {
36
+ this._redisAvailable = false
37
+ console.warn(
38
+ `[HealthCheckCache] Redis not configured for ${this.appName}, using in-memory cache only (not shared across processes)`
39
+ )
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Checks if Redis is available and working.
45
+ * @returns {Promise<boolean>}
46
+ * @private
47
+ */
48
+ async _checkRedisAvailable() {
49
+ if (!this.redisClient || !this._redisAvailable) {
50
+ return false
51
+ }
52
+
53
+ try {
54
+ let pong
55
+ if (this._redisClientType === REDIS_V3) {
56
+ pong = await new Promise((resolve, reject) => {
57
+ this.redisClient.ping((err, result) => {
58
+ if (err) reject(err)
59
+ else resolve(result)
60
+ })
61
+ })
62
+ } else if (
63
+ this._redisClientType === REDIS_V4 ||
64
+ this._redisClientType === IOREDIS
65
+ ) {
66
+ pong = await this.redisClient.ping()
67
+ } else {
68
+ return false
69
+ }
70
+ return pong === 'PONG'
71
+ } catch (err) {
72
+ if (this._redisAvailable) {
73
+ console.warn(
74
+ `[HealthCheckCache] Redis became unavailable: ${err.message}`
75
+ )
76
+ this._redisAvailable = false
77
+ }
78
+ return false
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Gets cached health check result from Redis (if available) or in-memory cache.
84
+ * Throws error if Redis is configured but read fails (so caller can return proper error format).
85
+ * @returns {Promise<Object | null>} Cached result or null
86
+ * @throws {Error} If Redis is configured but read fails
87
+ */
88
+ async get() {
89
+ if (this.redisClient) {
90
+ try {
91
+ let cachedStr
92
+ if (this._redisClientType === REDIS_V3) {
93
+ cachedStr = await new Promise((resolve, reject) => {
94
+ this.redisClient.get(this.cacheKey, (err, result) => {
95
+ if (err) reject(err)
96
+ else resolve(result)
97
+ })
98
+ })
99
+ } else if (
100
+ this._redisClientType === REDIS_V4 ||
101
+ this._redisClientType === IOREDIS
102
+ ) {
103
+ cachedStr = await this.redisClient.get(this.cacheKey)
104
+ }
105
+
106
+ if (cachedStr) {
107
+ try {
108
+ const cached = JSON.parse(cachedStr)
109
+ if (cached.result && cached.timestamp) {
110
+ this._memoryCache = cached.result
111
+ this._memoryCacheTimestamp = cached.timestamp
112
+ return cached.result
113
+ }
114
+ } catch (parseErr) {
115
+ console.warn(
116
+ `[HealthCheckCache] Failed to parse Redis cache:`,
117
+ parseErr.message
118
+ )
119
+ }
120
+ }
121
+ return null
122
+ } catch (redisErr) {
123
+ this._redisAvailable = false
124
+ throw new Error(`Redis cache read failed: ${redisErr.message}`)
125
+ }
126
+ }
127
+
128
+ if (this._memoryCache && this._memoryCacheTimestamp) {
129
+ return this._memoryCache
130
+ }
131
+
132
+ return null
133
+ }
134
+
135
+ /**
136
+ * Sets cached health check result in Redis (if available) and in-memory.
137
+ * @param {Object} result - Health check result to cache
138
+ * @returns {Promise<void>}
139
+ */
140
+ async set(result) {
141
+ const cacheData = {
142
+ result,
143
+ timestamp: Date.now(),
144
+ }
145
+
146
+ this._memoryCache = result
147
+ this._memoryCacheTimestamp = cacheData.timestamp
148
+
149
+ if (await this._checkRedisAvailable()) {
150
+ try {
151
+ const cacheStr = JSON.stringify(cacheData)
152
+ const ttlSeconds = Math.ceil(this.staleThresholdMs / 1000) + 60
153
+
154
+ if (this._redisClientType === REDIS_V3) {
155
+ await new Promise((resolve, reject) => {
156
+ this.redisClient.setex(this.cacheKey, ttlSeconds, cacheStr, err => {
157
+ if (err) reject(err)
158
+ else resolve()
159
+ })
160
+ })
161
+ } else if (this._redisClientType === REDIS_V4) {
162
+ await this.redisClient.set(this.cacheKey, cacheStr, {
163
+ EX: ttlSeconds,
164
+ })
165
+ } else if (this._redisClientType === IOREDIS) {
166
+ await this.redisClient.setex(this.cacheKey, ttlSeconds, cacheStr)
167
+ }
168
+ } catch (redisErr) {
169
+ console.warn(
170
+ `[HealthCheckCache] Redis write failed (in-memory cache updated):`,
171
+ redisErr.message
172
+ )
173
+ this._redisAvailable = false
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Clears the cache (both Redis and in-memory).
180
+ * @returns {Promise<void>}
181
+ */
182
+ async clear() {
183
+ this._memoryCache = null
184
+ this._memoryCacheTimestamp = null
185
+
186
+ if (await this._checkRedisAvailable()) {
187
+ try {
188
+ if (this._redisClientType === REDIS_V3) {
189
+ await new Promise((resolve, reject) => {
190
+ this.redisClient.del(this.cacheKey, err => {
191
+ if (err) reject(err)
192
+ else resolve()
193
+ })
194
+ })
195
+ } else if (
196
+ this._redisClientType === REDIS_V4 ||
197
+ this._redisClientType === IOREDIS
198
+ ) {
199
+ await this.redisClient.del(this.cacheKey)
200
+ }
201
+ } catch (redisErr) {
202
+ console.warn(`[HealthCheckCache] Redis clear failed:`, redisErr.message)
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Checks if Redis is configured and available.
209
+ * @returns {boolean}
210
+ */
211
+ isRedisAvailable() {
212
+ return this._redisAvailable && this.redisClient !== null
213
+ }
214
+ }
215
+
216
+ module.exports = { HealthCheckCache }