@adalo/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,378 @@
1
+ const client = require('prom-client')
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const https = require('https')
5
+
6
+ /**
7
+ * MetricsClient handles Prometheus metrics collection and push.
8
+ * Supports gauges, counters, default metrics, and custom metrics.
9
+ */
10
+ class MetricsClient {
11
+ /**
12
+ * @param {Object} config
13
+ * @param {string} [config.appName] Name of the application
14
+ * @param {string} [config.dynoId] Dyno/instance ID
15
+ * @param {string} [config.processType] Process type (web, worker, etc.)
16
+ * @param {boolean} [config.enabled] Enable metrics collection
17
+ * @param {boolean} [config.logValues] Log metrics values to console
18
+ * @param {string} [config.pushgatewayUrl] PushGateway URL
19
+ * @param {string} [config.pushgatewayUser] PushGateway username
20
+ * @param {string} [config.pushgatewayPassword] PushGateway password
21
+ * @param {number} [config.intervalSec] Interval in seconds for pushing metrics
22
+ */
23
+ constructor(config = {}) {
24
+ this.appName =
25
+ config.appName || process.env.METRICS_APP_NAME || 'unknown-app'
26
+ this.dynoId = config.dynoId || process.env.HOSTNAME || 'unknown-dyno'
27
+ this.processType =
28
+ config.processType ||
29
+ process.env.BUILD_DYNO_PROCESS_TYPE ||
30
+ 'undefined_build_dyno_type'
31
+ this.enabled = config.enabled ?? process.env.METRICS_ENABLED === 'true'
32
+ this.logValues =
33
+ config.logValues ?? process.env.METRICS_LOG_VALUES === 'true'
34
+ this.pushgatewayUrl =
35
+ config.pushgatewayUrl || process.env.METRICS_PUSHGATEWAY_URL || ''
36
+ this.pushgatewayUser =
37
+ config.pushgatewayUser || process.env.METRICS_PUSHGATEWAY_USER || ''
38
+ this.pushgatewayPassword =
39
+ config.pushgatewayPassword ||
40
+ process.env.METRICS_PUSHGATEWAY_PASSWORD ||
41
+ ''
42
+ this.intervalSec =
43
+ config.intervalSec ||
44
+ parseInt(process.env.METRICS_INTERVAL_SEC || '', 10) ||
45
+ 15
46
+
47
+ this.prefixLogs = `[${this.processType}] [${this.appName}] [${this.dynoId}] [Monitoring]`
48
+
49
+ this.registry = new client.Registry()
50
+ client.collectDefaultMetrics({ register: this.registry })
51
+
52
+ this.defaultLabels = {
53
+ app: this.appName,
54
+ dyno_id: this.dynoId,
55
+ process_type: this.processType,
56
+ }
57
+
58
+ const authToken = Buffer.from(
59
+ `${this.pushgatewayUser}:${this.pushgatewayPassword}`
60
+ ).toString('base64')
61
+ this.gateway = new client.Pushgateway(
62
+ this.pushgatewayUrl,
63
+ {
64
+ headers: { Authorization: `Basic ${authToken}` },
65
+ agent: new https.Agent({ keepAlive: true }),
66
+ },
67
+ this.registry
68
+ )
69
+
70
+ this.gauges = {}
71
+ this.counters = {}
72
+
73
+ /** @type {Object<string, function(): number | Promise<number>>} */
74
+ this.gaugeUpdaters = {}
75
+ /** @type {Object<string, function(data?: object, value?: number): void>} */
76
+ this.counterUpdaters = {}
77
+
78
+ this._lastUsageMicros = 0
79
+ this._lastCheckTime = Date.now()
80
+
81
+ this._initDefaultMetrics()
82
+ }
83
+
84
+ /**
85
+ * Register default gauges and counters.
86
+ * @private
87
+ */
88
+ _initDefaultMetrics = () => {
89
+ this.createGauge(
90
+ 'app_process_cpu_usage_percent',
91
+ 'Current CPU usage of the Node.js process in percent',
92
+ this.getCpuUsagePercent
93
+ )
94
+ this.createGauge(
95
+ 'app_available_cpu_count',
96
+ 'How many CPU cores are available to this process',
97
+ this.getAvailableCPUs
98
+ )
99
+ this.createGauge(
100
+ 'app_container_memory_usage_bytes',
101
+ 'Current container RAM usage from cgroup',
102
+ this.getContainerMemoryUsage
103
+ )
104
+ this.createGauge(
105
+ 'app_event_loop_lag_ms',
106
+ 'Estimated event loop lag in milliseconds',
107
+ this.measureLag
108
+ )
109
+ this.createGauge(
110
+ 'app_container_memory_limit_bytes',
111
+ 'Max RAM available to container from cgroup (memory.max)',
112
+ this.getContainerMemoryLimit
113
+ )
114
+ this.createGauge(
115
+ 'app_uptime_seconds',
116
+ 'How long the process has been running',
117
+ process.uptime
118
+ )
119
+
120
+ this.createCounter(
121
+ 'app_http_requests_total',
122
+ 'Total number of HTTP requests handled by this process',
123
+ [
124
+ ...Object.keys(this.defaultLabels),
125
+ 'method',
126
+ 'route',
127
+ 'appId',
128
+ 'databaseId',
129
+ 'duration',
130
+ 'requestSize',
131
+ 'status_code',
132
+ ]
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Create a gauge metric.
138
+ * @param {string} name
139
+ * @param {string} help
140
+ * @param {function(): number|Promise<number>} [updateFn] Optional function returning value
141
+ * @param {string[]} [labelNames]
142
+ * @returns {client.Gauge}
143
+ */
144
+ createGauge = (
145
+ name,
146
+ help,
147
+ updateFn,
148
+ labelNames = Object.keys(this.defaultLabels)
149
+ ) => {
150
+ if (this.gauges[name]) return this.gauges[name]
151
+
152
+ const g = new client.Gauge({
153
+ name,
154
+ help,
155
+ labelNames,
156
+ registers: [this.registry],
157
+ })
158
+ this.gauges[name] = g
159
+
160
+ if (typeof updateFn === 'function') this.gaugeUpdaters[name] = updateFn
161
+
162
+ return g
163
+ }
164
+
165
+ /**
166
+ * Create a counter metric.
167
+ * Returns a trigger function to increment the counter manually.
168
+ * @param {string} name
169
+ * @param {string} help
170
+ * @param {string[]} [labelNames]
171
+ * @returns {function(data?: object, value?: number): void} triggerFn
172
+ */
173
+ createCounter(name, help, labelNames = Object.keys(this.defaultLabels)) {
174
+ if (this.counters[name]) return this.counters[name].triggerFn
175
+
176
+ const c = new client.Counter({
177
+ name,
178
+ help,
179
+ labelNames,
180
+ registers: [this.registry],
181
+ })
182
+ this.counters[name] = c
183
+
184
+ const triggerFn = (data = {}, value = 1) => {
185
+ c.inc({ ...this.defaultLabels, ...data }, value)
186
+ }
187
+
188
+ this.counters[name].triggerFn = triggerFn
189
+ return triggerFn
190
+ }
191
+
192
+ /**
193
+ * Get CPU usage percent (cgroup-aware)
194
+ * @returns {number}
195
+ */
196
+ getCpuUsagePercent = () => {
197
+ try {
198
+ const stat = fs.readFileSync('/sys/fs/cgroup/cpu.stat', 'utf-8')
199
+ const match = stat.match(/usage_usec (\d+)/)
200
+ if (!match) return 0
201
+
202
+ const now = Date.now()
203
+ const currentUsage = parseInt(match[1], 10)
204
+
205
+ if (this._lastUsageMicros === 0) {
206
+ this._lastUsageMicros = currentUsage
207
+ this._lastCheckTime = now
208
+ return 0
209
+ }
210
+
211
+ const deltaUsage = currentUsage - this._lastUsageMicros
212
+ const deltaTime = now - this._lastCheckTime
213
+
214
+ this._lastUsageMicros = currentUsage
215
+ this._lastCheckTime = now
216
+
217
+ return (deltaUsage / (deltaTime * 1000)) * 100
218
+ } catch {
219
+ return 0
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Get available CPU cores.
225
+ * @returns {number}
226
+ */
227
+ getAvailableCPUs() {
228
+ try {
229
+ const cpuMaxPath = '/sys/fs/cgroup/cpu.max'
230
+ if (fs.existsSync(cpuMaxPath)) {
231
+ const [quotaStr, periodStr] = fs
232
+ .readFileSync(cpuMaxPath, 'utf8')
233
+ .trim()
234
+ .split(' ')
235
+ if (quotaStr === 'max') return os.cpus().length
236
+ return parseInt(quotaStr, 10) / parseInt(periodStr, 10)
237
+ }
238
+ return os.cpus().length
239
+ } catch {
240
+ return 1
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Get container memory usage in bytes.
246
+ * @returns {number}
247
+ */
248
+ getContainerMemoryUsage() {
249
+ try {
250
+ return parseInt(
251
+ fs.readFileSync('/sys/fs/cgroup/memory.current', 'utf-8').trim(),
252
+ 10
253
+ )
254
+ } catch {
255
+ return process.memoryUsage().rss
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Get container memory limit in bytes.
261
+ * @returns {number}
262
+ */
263
+ getContainerMemoryLimit() {
264
+ try {
265
+ const path = '/sys/fs/cgroup/memory.max'
266
+ if (fs.existsSync(path)) {
267
+ const val = fs.readFileSync(path, 'utf-8').trim()
268
+ if (val !== 'max') {
269
+ const parsed = parseInt(val, 10)
270
+ if (parsed && parsed < os.totalmem()) return parsed
271
+ }
272
+ }
273
+ return os.totalmem()
274
+ } catch {
275
+ return os.totalmem()
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Measure event loop lag in ms.
281
+ * @returns {Promise<number>}
282
+ */
283
+ measureLag() {
284
+ return new Promise(resolve => {
285
+ const start = Date.now()
286
+ setImmediate(() => resolve(Date.now() - start))
287
+ })
288
+ }
289
+
290
+ /**
291
+ * Express middleware to count HTTP requests.
292
+ * Increments the `app_http_requests_total` counter.
293
+ */
294
+ countHttpRequestMiddleware = (req, res, next) => {
295
+ const start = Date.now()
296
+ res.on('finish', () => {
297
+ const route = req.route?.path || req.path || 'unknown'
298
+ const appId =
299
+ req.params?.appId || req.body?.appId || req.query?.appId || ''
300
+ const databaseId =
301
+ req.params?.databaseId ||
302
+ req.body?.databaseId ||
303
+ req.query?.databaseId ||
304
+ ''
305
+
306
+ this.counters?.app_http_requests_total?.triggerFn({
307
+ method: req.method,
308
+ route,
309
+ status_code: res.statusCode,
310
+ appId,
311
+ databaseId,
312
+ duration: Date.now() - start,
313
+ requestSize: req.headers['content-length']
314
+ ? parseInt(req.headers['content-length'], 10)
315
+ : 0,
316
+ })
317
+ })
318
+
319
+ next()
320
+ }
321
+
322
+ /**
323
+ * Push all gauges and counters to PushGateway and optionally log.
324
+ */
325
+ pushMetrics = async () => {
326
+ try {
327
+ for (const [name, updateFn] of Object.entries(this.gaugeUpdaters)) {
328
+ try {
329
+ const result = updateFn()
330
+ const val = result instanceof Promise ? await result : result
331
+ if (val !== undefined) this.gauges[name].set(this.defaultLabels, val)
332
+ } catch (err) {
333
+ console.error(
334
+ `${this.prefixLogs} Failed to update gauge ${name}:`,
335
+ err
336
+ )
337
+ }
338
+ }
339
+
340
+ await this.gateway.push({
341
+ jobName: this.appName,
342
+ groupings: { process_type: this.processType, instance: this.dynoId },
343
+ })
344
+
345
+ Object.values(this.counters).forEach(counter => counter.reset())
346
+
347
+ if (this.logValues) {
348
+ const metrics = await this.registry.getMetricsAsJSON()
349
+ console.log(
350
+ `${this.prefixLogs} Metrics:\n`,
351
+ JSON.stringify(metrics, null, 2)
352
+ )
353
+ }
354
+ } catch (err) {
355
+ console.error(`${this.prefixLogs} Failed to push metrics:`, err)
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Start automatic periodic push of metrics.
361
+ * @param {number} [interval] Interval in seconds
362
+ */
363
+ startPush = (interval = this.intervalSec) => {
364
+ if (!this.enabled) {
365
+ console.warn(`${this.prefixLogs} Metrics disabled`)
366
+ }
367
+
368
+ setInterval(() => {
369
+ this.pushMetrics().catch(err => {
370
+ console.error(`${this.prefixLogs} Failed to push metrics:`, err)
371
+ })
372
+ }, interval * 1000)
373
+
374
+ console.warn(`${this.prefixLogs} Metrics collection started.`)
375
+ }
376
+ }
377
+
378
+ module.exports = { MetricsClient }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "allowSyntheticDefaultImports": true,
5
+ "esModuleInterop": true,
6
+ "isolatedModules": false,
7
+ "lib": ["ESNext"],
8
+ "module": "CommonJS",
9
+ "moduleResolution": "node",
10
+ "outDir": "./lib",
11
+ "strict": true,
12
+ "suppressImplicitAnyIndexErrors": true,
13
+ "target": "ESNext",
14
+ "resolveJsonModule": true,
15
+ "noEmit": true
16
+ },
17
+ "include": ["./src"],
18
+ "exclude": ["./node_modules"]
19
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "declarationMap": true,
6
+ "emitDeclarationOnly": true,
7
+ "noEmit": false,
8
+ "sourceMap": true
9
+ },
10
+ "exclude": ["**/__tests__/**", "**/*.test.js", "**/*.test.ts"]
11
+ }