@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/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @adalo/metrics
2
+ Utility library for Prometheus metrics and HTTP request counting in Node.js applications.
3
+
4
+ ## Testing and staging (for contributors)
5
+
6
+ - **Run tests**: `yarn test` (or `yarn test:watch`). CI runs tests on every pull request.
7
+ - **Test locally in a consuming app** (without publishing):
8
+ - From this repo: `yarn link`
9
+ - In the app repo (e.g. backend): `yarn link @adalo/metrics`
10
+ - Or use a file dependency: `"@adalo/metrics": "file:../path/to/metrics-js"`
11
+ - **Test on staging before merging to main**:
12
+ - Push your changes to the **`staging`** branch (create it if needed). The **Deploy Staging** workflow will build and publish to npm with the dist-tag **`staging`** (version like `0.0.0-staging.123`).
13
+ - In the app that uses this library (e.g. backend), point the dependency to the staging build:
14
+ - In `package.json`: `"@adalo/metrics": "staging"` (resolves to latest `@adalo/metrics@staging`), or pin a specific version: `"@adalo/metrics": "0.0.0-staging.123"`.
15
+ - Deploy that app to staging and verify metrics/behavior. When satisfied, merge **metrics-js** into **main** and release a normal version; then update the app back to `"@adalo/metrics": "^x.y.z"` or `"latest"`.
16
+
17
+ ## Constructor
18
+ ```ts
19
+ new MetricsClient({
20
+ appName, // defaults: process.env.BUILD_APP_NAME || 'unknown-app'
21
+ dynoId, // defaults: process.env.HOSTNAME || 'unknown-dyno'
22
+ processType, // defaults: process.env.BUILD_DYNO_PROCESS_TYPE || 'undefined_build_dyno_type'
23
+ enabled, // defaults: process.env.METRICS_ENABLED === 'true'
24
+ logValues, // defaults: process.env.METRICS_LOG_VALUES === 'true'
25
+ pushgatewayUrl, // defaults: process.env.METRICS_PUSHGATEWAY_URL || ''
26
+ pushgatewaySecret, // defaults: process.env.METRICS_PUSHGATEWAY_SECRET || '' (Base64 of user:password)
27
+ intervalSec, // defaults: process.env.METRICS_INTERVAL_SEC || 15
28
+ })
29
+ ```
30
+ ## Example Usage
31
+ ```ts
32
+ import { MetricsClient } from '@adalo/metrics-js'
33
+
34
+ const metrics = new MetricsClient({ appName: 'my-app', enabled: true })
35
+
36
+ // Express middleware
37
+ app.use(metrics.trackHttpRequestMiddleware)
38
+
39
+
40
+ // Start metrics push
41
+ metrics.startPush()
42
+ ```
43
+
44
+
45
+ ## Custom usage
46
+ ```ts
47
+ // Create custom gauges and counters
48
+
49
+ const metrics = new MetricsClient()
50
+ const testGauge = metricsClient.createGauge({
51
+ name: 'app_test_usage_percent',
52
+ help: 'Current test usage of the Node.js process in percent',
53
+ labelNames: metricsClient.withDefaultLabels(['test_result']),
54
+ })
55
+
56
+ // Push metrics manually
57
+ metricsClient.gatewayPush({ groupings: { process_type: 'web' } })
58
+
59
+ // Automatically push metrics at intervals (staging: 60s; prod: 900 or 1800 for 15/30 min)
60
+ metricsClient.startPush(15) // interval in seconds
61
+ ```
62
+
63
+ ## HealthCheckClient
64
+
65
+ Provides a health check endpoint for external monitoring services like BetterStack.
66
+ Validates database and Redis connections with rate limiting to prevent excessive load.
67
+
68
+ ```typescript
69
+ import { HealthCheckClient } from '@adalo/metrics'
70
+ import Redis from 'ioredis'
71
+
72
+ const redisClient = new Redis()
73
+ const healthCheck = new HealthCheckClient({
74
+ databaseUrl: process.env.DATABASE_URL,
75
+ databaseName: 'main',
76
+ additionalDatabaseUrls: {
77
+ cluster_1: process.env.CLUSTER_1_URL,
78
+ cluster_2: process.env.CLUSTER_2_URL,
79
+ },
80
+ redisClient,
81
+ })
82
+
83
+ // Register on Express app
84
+ healthCheck.registerHealthEndpoint(app, '/betterstack-health')
85
+ ```
86
+
87
+ Response format (BetterStack compatible):
88
+ ```json
89
+ {
90
+ "status": "healthy",
91
+ "timestamp": "2026-01-20T12:00:00.000Z",
92
+ "cached": false,
93
+ "components": {
94
+ "database": {
95
+ "status": "healthy",
96
+ "clusters": {
97
+ "main": { "status": "healthy", "connectMs": 5.2 },
98
+ "cluster_1": { "status": "healthy", "connectMs": 8.1 },
99
+ "cluster_2": { "status": "healthy", "connectMs": 6.4 }
100
+ }
101
+ },
102
+ "redis": { "status": "healthy" }
103
+ }
104
+ }
105
+ ```
106
+
107
+ Status codes:
108
+ - `200` - healthy or degraded
109
+ - `503` - unhealthy (at least one component failed)
110
+
111
+ Environment variables:
112
+ | Variable | Description | Default |
113
+ |----------|-------------|---------|
114
+ | `DATABASE_URL` | Main PostgreSQL connection URL | - |
115
+
116
+ ## Tips
117
+ secret env was created as
118
+ ```js
119
+ Buffer.from(`${username}:${password}`).toString('base64')
120
+ ```
@@ -0,0 +1,138 @@
1
+ const http = require('http')
2
+ const https = require('https')
3
+ const { RedisMetricsClient } = require('../src/metrics/metricsRedisClient')
4
+ const { IOREDIS } = require('../src/redisUtils')
5
+
6
+ jest.mock('../src/redisUtils', () => ({
7
+ ...jest.requireActual('../src/redisUtils'),
8
+ getRedisClientType: jest.fn(),
9
+ IOREDIS: 'IOREDIS',
10
+ REDIS_V3: 'REDIS_V3',
11
+ REDIS_V4: 'REDIS_V4',
12
+ }))
13
+
14
+ const { getRedisClientType } = require('../src/redisUtils')
15
+
16
+ describe('RedisMetricsClient', () => {
17
+ const originalEnv = process.env
18
+ let mockRedisClient
19
+ let mockHttpRequest
20
+ let mockHttpsRequest
21
+
22
+ beforeEach(() => {
23
+ jest.clearAllMocks()
24
+ process.env = { ...originalEnv }
25
+ process.exit = jest.fn()
26
+
27
+ mockRedisClient = {
28
+ publish: jest.fn().mockResolvedValue(1),
29
+ sendCommand: jest.fn().mockResolvedValue(1),
30
+ send_command: jest.fn((cmd, args, cb) => (cb ? cb(null, 1) : undefined)),
31
+ disconnect: jest.fn(),
32
+ quit: jest.fn().mockResolvedValue(undefined),
33
+ }
34
+
35
+ const createMockRequest = (cb) => {
36
+ const req = {
37
+ setHeader: jest.fn(),
38
+ end: jest.fn(),
39
+ on: jest.fn(),
40
+ }
41
+ setImmediate(() => {
42
+ const res = { statusCode: 200, on: jest.fn() }
43
+ if (cb) cb(res)
44
+ })
45
+ return req
46
+ }
47
+ mockHttpRequest = jest.spyOn(http, 'request').mockImplementation((opts, cb) => {
48
+ const req = createMockRequest((res) => {
49
+ if (typeof cb === 'function') cb(res)
50
+ })
51
+ return req
52
+ })
53
+ mockHttpsRequest = jest.spyOn(https, 'request').mockImplementation((opts, cb) => {
54
+ const req = createMockRequest((res) => {
55
+ if (typeof cb === 'function') cb(res)
56
+ })
57
+ return req
58
+ })
59
+ })
60
+
61
+ afterEach(() => {
62
+ process.env = originalEnv
63
+ mockHttpRequest.mockRestore()
64
+ mockHttpsRequest.mockRestore()
65
+ })
66
+
67
+ describe('constructor', () => {
68
+ it('throws when redisClient is missing', () => {
69
+ expect(() => new RedisMetricsClient({})).toThrow('RedisMetricsClient requires redisClient')
70
+ expect(() => new RedisMetricsClient()).toThrow('RedisMetricsClient requires redisClient')
71
+ expect(() => new RedisMetricsClient({ redisClient: null })).toThrow('RedisMetricsClient requires redisClient')
72
+ })
73
+
74
+ it('accepts redisClient and passes other options via metricsConfig', () => {
75
+ getRedisClientType.mockReturnValue(IOREDIS)
76
+ const client = new RedisMetricsClient({
77
+ redisClient: mockRedisClient,
78
+ appName: 'my-app',
79
+ processType: 'web',
80
+ pushgatewayUrl: 'http://vm:8428/api/v1/import/prometheus',
81
+ })
82
+ expect(client.redisClient).toBe(mockRedisClient)
83
+ expect(client.appName).toBe('my-app')
84
+ expect(client.processType).toBe('web')
85
+ expect(client.pushgatewayUrl).toBe('http://vm:8428/api/v1/import/prometheus')
86
+ })
87
+
88
+ it('uses Redis label names without dyno_id (app, process_type only for Redis metrics)', () => {
89
+ getRedisClientType.mockReturnValue(IOREDIS)
90
+ const client = new RedisMetricsClient({
91
+ redisClient: mockRedisClient,
92
+ appName: 'my-app',
93
+ processType: 'queue-metrics',
94
+ })
95
+ expect(client._redisLabelNames).toEqual(['app', 'process_type'])
96
+ expect(client.redisConnectionsGauge.labelNames).toContain('app')
97
+ expect(client.redisConnectionsGauge.labelNames).toContain('process_type')
98
+ expect(client.redisConnectionsGauge.labelNames).not.toContain('dyno_id')
99
+ })
100
+ })
101
+
102
+ describe('gateway push (mocked)', () => {
103
+ it('calls http.request when pushgatewayUrl is set', async () => {
104
+ getRedisClientType.mockReturnValue(IOREDIS)
105
+ const client = new RedisMetricsClient({
106
+ redisClient: mockRedisClient,
107
+ pushgatewayUrl: 'http://localhost:8428/api/v1/import/prometheus',
108
+ enabled: true,
109
+ cleanupExitsProcess: false,
110
+ })
111
+ await client.gatewayPush()
112
+ expect(mockHttpRequest).toHaveBeenCalled()
113
+ expect(mockHttpRequest.mock.calls[0][0].method).toBe('POST')
114
+ expect(mockHttpRequest.mock.calls[0][0].path).toContain('import/prometheus')
115
+ })
116
+ })
117
+
118
+ describe('gateway delete (mocked)', () => {
119
+ it('calls http.request for delete_series when removeOldMetrics and pushgatewayUrl set', async () => {
120
+ getRedisClientType.mockReturnValue(IOREDIS)
121
+ const client = new RedisMetricsClient({
122
+ redisClient: mockRedisClient,
123
+ pushgatewayUrl: 'http://localhost:8428/api/v1/import/prometheus',
124
+ removeOldMetrics: true,
125
+ enabled: true,
126
+ appName: 'test-app',
127
+ dynoId: 'dyno-1',
128
+ processType: 'web',
129
+ cleanupExitsProcess: false,
130
+ })
131
+ await client.gatewayDelete()
132
+ expect(mockHttpRequest).toHaveBeenCalled()
133
+ const call = mockHttpRequest.mock.calls[0][0]
134
+ expect(call.path).toContain('delete_series')
135
+ expect(call.path).toContain('match')
136
+ })
137
+ })
138
+ })
@@ -0,0 +1,20 @@
1
+ const include = ['./src']
2
+ const ignore = ['./lib']
3
+
4
+ // This env var is set when running with jest
5
+ if (process.env.NODE_ENV !== 'test') {
6
+ ignore.push('**/*.test.js', '**/*.test.ts', '**/__tests__/*')
7
+ }
8
+
9
+ const presets = [
10
+ [
11
+ '@babel/preset-env',
12
+ {
13
+ targets: { node: '22' },
14
+ useBuiltIns: 'usage',
15
+ },
16
+ ],
17
+ '@babel/preset-typescript',
18
+ ]
19
+
20
+ module.exports = { include, ignore, presets }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @param {string} url
3
+ * @returns {string} postgres | mysql
4
+ */
5
+ export function getDatabaseType(url: string): string;
6
+ /**
7
+ * @param {string} url
8
+ * @param {string} [type]
9
+ * @returns {{ host: string, port: number, user: string, password: string, database: string } | null}
10
+ */
11
+ export function parseConnectionUrl(url: string, type?: string | undefined): {
12
+ host: string;
13
+ port: number;
14
+ user: string;
15
+ password: string;
16
+ database: string;
17
+ } | null;
18
+ /**
19
+ * @param {string} env
20
+ * @param {string} url
21
+ * @param {number} connectionTimeoutMs
22
+ * @returns {{ pool: any, type: string }}
23
+ */
24
+ export function createDatabasePool(env: string, url: string, connectionTimeoutMs: number): {
25
+ pool: any;
26
+ type: string;
27
+ };
28
+ /**
29
+ * @param {any} pool
30
+ * @param {string} type
31
+ * @returns {Promise<{ connectMs: number }>}
32
+ */
33
+ export function runHealthCheck(pool: any, type: string): Promise<{
34
+ connectMs: number;
35
+ }>;
36
+ /**
37
+ * @param {any} pool
38
+ * @returns {Promise<void>}
39
+ */
40
+ export function closePool(pool: any): Promise<void>;
41
+ export const DB_TYPE_POSTGRES: "postgres";
42
+ export const DB_TYPE_MYSQL: "mysql";
43
+ //# sourceMappingURL=databaseChecker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"databaseChecker.d.ts","sourceRoot":"","sources":["../../src/health/databaseChecker.js"],"names":[],"mappings":"AAqBA;;;GAGG;AACH,qCAHW,MAAM,GACJ,MAAM,CAYlB;AAED;;;;GAIG;AACH,wCAJW,MAAM,8BAEJ;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAiBnG;AAED;;;;;GAKG;AACH,wCALW,MAAM,OACN,MAAM,uBACN,MAAM;UACI,GAAG;UAAQ,MAAM;EA8BrC;AAED;;;;GAIG;AACH,qCAJW,GAAG,QACH,MAAM;eACiB,MAAM;GA8DvC;AAED;;;GAGG;AACH,gCAHW,GAAG,GACD,QAAQ,IAAI,CAAC,CAQzB;AAzKD,0CAAmC;AACnC,oCAA6B"}
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+
3
+ const {
4
+ Pool
5
+ } = require('pg');
6
+ const mysql = require('mysql2/promise');
7
+ const DB_TYPE_POSTGRES = 'postgres';
8
+ const DB_TYPE_MYSQL = 'mysql';
9
+
10
+ /**
11
+ * @returns {bigint}
12
+ */
13
+ function nowNs() {
14
+ return process.hrtime.bigint();
15
+ }
16
+
17
+ /**
18
+ * @param {bigint} deltaNs
19
+ * @returns {number}
20
+ */
21
+ function nsToMs(deltaNs) {
22
+ return Number(deltaNs) / 1_000_000;
23
+ }
24
+
25
+ /**
26
+ * @param {string} url
27
+ * @returns {string} postgres | mysql
28
+ */
29
+ function getDatabaseType(url) {
30
+ if (!url || typeof url !== 'string') return DB_TYPE_POSTGRES;
31
+ const lower = url.toLowerCase();
32
+ if (lower.startsWith('mysql://') || lower.startsWith('mysql2://')) {
33
+ return DB_TYPE_MYSQL;
34
+ }
35
+ if (lower.startsWith('mariadb://')) {
36
+ return DB_TYPE_MYSQL;
37
+ }
38
+ return DB_TYPE_POSTGRES;
39
+ }
40
+
41
+ /**
42
+ * @param {string} url
43
+ * @param {string} [type]
44
+ * @returns {{ host: string, port: number, user: string, password: string, database: string } | null}
45
+ */
46
+ function parseConnectionUrl(url, type) {
47
+ try {
48
+ const parsed = new URL(url);
49
+ const defaultPort = type === DB_TYPE_MYSQL ? 3306 : 5432;
50
+ const defaultDb = type === DB_TYPE_MYSQL ? 'mysql' : 'postgres';
51
+ return {
52
+ host: parsed.hostname || 'localhost',
53
+ port: parseInt(parsed.port || String(defaultPort), 10),
54
+ user: parsed.username || '',
55
+ password: parsed.password || '',
56
+ database: (parsed.pathname || '/').replace(/^\//, '') || defaultDb
57
+ };
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {string} env
65
+ * @param {string} url
66
+ * @param {number} connectionTimeoutMs
67
+ * @returns {{ pool: any, type: string }}
68
+ */
69
+ function createDatabasePool(env, url, connectionTimeoutMs) {
70
+ const type = getDatabaseType(url);
71
+ if (type === DB_TYPE_MYSQL) {
72
+ const config = parseConnectionUrl(url, DB_TYPE_MYSQL);
73
+ if (!config) {
74
+ throw new Error(`Invalid MySQL URL for ${env}`);
75
+ }
76
+ const pool = mysql.createPool({
77
+ host: config.host,
78
+ port: config.port,
79
+ user: config.user,
80
+ password: config.password,
81
+ database: config.database,
82
+ connectionLimit: 1,
83
+ connectTimeout: connectionTimeoutMs,
84
+ waitForConnections: false
85
+ });
86
+ return {
87
+ pool,
88
+ type: DB_TYPE_MYSQL
89
+ };
90
+ }
91
+ const pool = new Pool({
92
+ connectionString: url,
93
+ max: 1,
94
+ idleTimeoutMillis: 30000,
95
+ connectionTimeoutMillis: connectionTimeoutMs
96
+ });
97
+ return {
98
+ pool,
99
+ type: DB_TYPE_POSTGRES
100
+ };
101
+ }
102
+
103
+ /**
104
+ * @param {any} pool
105
+ * @param {string} type
106
+ * @returns {Promise<{ connectMs: number }>}
107
+ */
108
+ async function runHealthCheck(pool, type) {
109
+ if (type === DB_TYPE_MYSQL) {
110
+ let connectMs = 0.0;
111
+
112
+ /** @type {any} */
113
+ let conn;
114
+ try {
115
+ const startConnect = nowNs();
116
+ conn = await pool.getConnection();
117
+ connectMs = nsToMs(nowNs() - startConnect);
118
+ await conn.query('SELECT 1');
119
+ return {
120
+ connectMs
121
+ };
122
+ } catch (err) {
123
+ err.healthCheckTimings = {
124
+ connectMs
125
+ };
126
+ throw err;
127
+ } finally {
128
+ if (conn) {
129
+ try {
130
+ // Destroy the connection so next check measures a real connect.
131
+ // mysql2 pooled connections support destroy() to remove it from the pool.
132
+ if (typeof conn.destroy === 'function') conn.destroy();else if (typeof conn.end === 'function') await conn.end();else if (typeof conn.release === 'function') conn.release();
133
+ } catch {
134
+ // ignore
135
+ }
136
+ }
137
+ }
138
+ }
139
+ let connectMs = 0.0;
140
+
141
+ /** @type {import('pg').PoolClient | null} */
142
+ let client = null;
143
+ try {
144
+ const startConnect = nowNs();
145
+ client = await pool.connect();
146
+ connectMs = nsToMs(nowNs() - startConnect);
147
+ await client.query('SELECT 1');
148
+ return {
149
+ connectMs
150
+ };
151
+ } catch (err) {
152
+ err.healthCheckTimings = {
153
+ connectMs
154
+ };
155
+ throw err;
156
+ } finally {
157
+ if (client) {
158
+ try {
159
+ // Force the pool to drop the client so next check measures a real connect.
160
+ // In node-postgres, passing a truthy value removes the client from the pool.
161
+ client.release(true);
162
+ } catch {
163
+ // ignore
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * @param {any} pool
171
+ * @returns {Promise<void>}
172
+ */
173
+ async function closePool(pool) {
174
+ try {
175
+ await pool.end();
176
+ } catch {
177
+ // ignore
178
+ }
179
+ }
180
+ module.exports = {
181
+ getDatabaseType,
182
+ parseConnectionUrl,
183
+ createDatabasePool,
184
+ runHealthCheck,
185
+ closePool,
186
+ DB_TYPE_POSTGRES,
187
+ DB_TYPE_MYSQL
188
+ };
189
+ //# sourceMappingURL=databaseChecker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"databaseChecker.js","names":["Pool","require","mysql","DB_TYPE_POSTGRES","DB_TYPE_MYSQL","nowNs","process","hrtime","bigint","nsToMs","deltaNs","Number","getDatabaseType","url","lower","toLowerCase","startsWith","parseConnectionUrl","type","parsed","URL","defaultPort","defaultDb","host","hostname","port","parseInt","String","user","username","password","database","pathname","replace","createDatabasePool","env","connectionTimeoutMs","config","Error","pool","createPool","connectionLimit","connectTimeout","waitForConnections","connectionString","max","idleTimeoutMillis","connectionTimeoutMillis","runHealthCheck","connectMs","conn","startConnect","getConnection","query","err","healthCheckTimings","destroy","end","release","client","connect","closePool","module","exports"],"sources":["../../src/health/databaseChecker.js"],"sourcesContent":["const { Pool } = require('pg')\nconst mysql = require('mysql2/promise')\n\nconst DB_TYPE_POSTGRES = 'postgres'\nconst DB_TYPE_MYSQL = 'mysql'\n\n/**\n * @returns {bigint}\n */\nfunction nowNs() {\n return process.hrtime.bigint()\n}\n\n/**\n * @param {bigint} deltaNs\n * @returns {number}\n */\nfunction nsToMs(deltaNs) {\n return Number(deltaNs) / 1_000_000\n}\n\n/**\n * @param {string} url\n * @returns {string} postgres | mysql\n */\nfunction getDatabaseType(url) {\n if (!url || typeof url !== 'string') return DB_TYPE_POSTGRES\n const lower = url.toLowerCase()\n if (lower.startsWith('mysql://') || lower.startsWith('mysql2://')) {\n return DB_TYPE_MYSQL\n }\n if (lower.startsWith('mariadb://')) {\n return DB_TYPE_MYSQL\n }\n return DB_TYPE_POSTGRES\n}\n\n/**\n * @param {string} url\n * @param {string} [type]\n * @returns {{ host: string, port: number, user: string, password: string, database: string } | null}\n */\nfunction parseConnectionUrl(url, type) {\n try {\n const parsed = new URL(url)\n const defaultPort = type === DB_TYPE_MYSQL ? 3306 : 5432\n const defaultDb = type === DB_TYPE_MYSQL ? 'mysql' : 'postgres'\n return {\n host: parsed.hostname || 'localhost',\n port: parseInt(parsed.port || String(defaultPort), 10),\n user: parsed.username || '',\n password: parsed.password || '',\n database: (parsed.pathname || '/').replace(/^\\//, '') || defaultDb,\n }\n } catch {\n return null\n }\n}\n\n/**\n * @param {string} env\n * @param {string} url\n * @param {number} connectionTimeoutMs\n * @returns {{ pool: any, type: string }}\n */\nfunction createDatabasePool(env, url, connectionTimeoutMs) {\n const type = getDatabaseType(url)\n\n if (type === DB_TYPE_MYSQL) {\n const config = parseConnectionUrl(url, DB_TYPE_MYSQL)\n if (!config) {\n throw new Error(`Invalid MySQL URL for ${env}`)\n }\n const pool = mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectionLimit: 1,\n connectTimeout: connectionTimeoutMs,\n waitForConnections: false,\n })\n return { pool, type: DB_TYPE_MYSQL }\n }\n\n const pool = new Pool({\n connectionString: url,\n max: 1,\n idleTimeoutMillis: 30000,\n connectionTimeoutMillis: connectionTimeoutMs,\n })\n return { pool, type: DB_TYPE_POSTGRES }\n}\n\n/**\n * @param {any} pool\n * @param {string} type\n * @returns {Promise<{ connectMs: number }>}\n */\nasync function runHealthCheck(pool, type) {\n if (type === DB_TYPE_MYSQL) {\n let connectMs = 0.0\n\n /** @type {any} */\n let conn\n try {\n const startConnect = nowNs()\n conn = await pool.getConnection()\n connectMs = nsToMs(nowNs() - startConnect)\n\n await conn.query('SELECT 1')\n return { connectMs }\n } catch (err) {\n err.healthCheckTimings = {\n connectMs,\n }\n throw err\n } finally {\n if (conn) {\n try {\n // Destroy the connection so next check measures a real connect.\n // mysql2 pooled connections support destroy() to remove it from the pool.\n if (typeof conn.destroy === 'function') conn.destroy()\n else if (typeof conn.end === 'function') await conn.end()\n else if (typeof conn.release === 'function') conn.release()\n } catch {\n // ignore\n }\n }\n }\n }\n\n let connectMs = 0.0\n\n /** @type {import('pg').PoolClient | null} */\n let client = null\n try {\n const startConnect = nowNs()\n client = await pool.connect()\n connectMs = nsToMs(nowNs() - startConnect)\n\n await client.query('SELECT 1')\n return { connectMs }\n } catch (err) {\n err.healthCheckTimings = {\n connectMs,\n }\n throw err\n } finally {\n if (client) {\n try {\n // Force the pool to drop the client so next check measures a real connect.\n // In node-postgres, passing a truthy value removes the client from the pool.\n client.release(true)\n } catch {\n // ignore\n }\n }\n }\n}\n\n/**\n * @param {any} pool\n * @returns {Promise<void>}\n */\nasync function closePool(pool) {\n try {\n await pool.end()\n } catch {\n // ignore\n }\n}\n\nmodule.exports = {\n getDatabaseType,\n parseConnectionUrl,\n createDatabasePool,\n runHealthCheck,\n closePool,\n DB_TYPE_POSTGRES,\n DB_TYPE_MYSQL,\n}\n"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * HealthCheckCache provides a shared cache layer for health check results.
3
+ * It uses Redis if available for cross-process sharing, with graceful fallback
4
+ * to in-memory cache if Redis is not configured or unavailable.
5
+ */
6
+ export class HealthCheckCache {
7
+ /**
8
+ * @param {Object} options
9
+ * @param {any} [options.redisClient]
10
+ * @param {string} [options.cacheKey] - Redis key (e.g. 'health:database:status')
11
+ * @param {string} [options.appName] - Used when cacheKey not set: healthcheck:${appName}
12
+ * @param {number} [options.staleThresholdMs]
13
+ */
14
+ constructor(options?: {
15
+ redisClient?: any;
16
+ cacheKey?: string | undefined;
17
+ appName?: string | undefined;
18
+ staleThresholdMs?: number | undefined;
19
+ });
20
+ redisClient: any;
21
+ appName: string;
22
+ staleThresholdMs: number;
23
+ cacheKey: string;
24
+ /** In-memory fallback cache */
25
+ _memoryCache: any;
26
+ _memoryCacheTimestamp: any;
27
+ _redisClientType: string | undefined;
28
+ _redisAvailable: boolean;
29
+ /**
30
+ * Checks if Redis is available and working.
31
+ * @returns {Promise<boolean>}
32
+ * @private
33
+ */
34
+ private _checkRedisAvailable;
35
+ /**
36
+ * Gets cached health check result from Redis (if available) or in-memory cache.
37
+ * Throws error if Redis is configured but read fails (so caller can return proper error format).
38
+ * @returns {Promise<Object | null>} Cached result or null
39
+ * @throws {Error} If Redis is configured but read fails
40
+ */
41
+ get(): Promise<Object | null>;
42
+ /**
43
+ * Sets cached health check result in Redis (if available) and in-memory.
44
+ * @param {Object} result - Health check result to cache
45
+ * @returns {Promise<void>}
46
+ */
47
+ set(result: Object): Promise<void>;
48
+ /**
49
+ * Clears the cache (both Redis and in-memory).
50
+ * @returns {Promise<void>}
51
+ */
52
+ clear(): Promise<void>;
53
+ /**
54
+ * Checks if Redis is configured and available.
55
+ * @returns {boolean}
56
+ */
57
+ isRedisAvailable(): boolean;
58
+ }
59
+ //# sourceMappingURL=healthCheckCache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"healthCheckCache.d.ts","sourceRoot":"","sources":["../../src/health/healthCheckCache.js"],"names":[],"mappings":"AAOA;;;;GAIG;AACH;IACE;;;;;;OAMG;IACH;QALyB,WAAW,GAAzB,GAAG;QACc,QAAQ;QACR,OAAO;QACP,gBAAgB;OAsB3C;IAnBC,iBAA8C;IAC9C,gBACgE;IAChE,yBAA2D;IAC3D,iBAAiE;IAEjE,+BAA+B;IAC/B,kBAAwB;IACxB,2BAAiC;IAG/B,qCAA4D;IAC5D,yBAA2B;IAS/B;;;;OAIG;IACH,6BAgCC;IAED;;;;;OAKG;IACH,OAHa,QAAQ,MAAM,GAAG,IAAI,CAAC,CAgDlC;IAED;;;;OAIG;IACH,YAHW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAsCzB;IAED;;;OAGG;IACH,SAFa,QAAQ,IAAI,CAAC,CAyBzB;IAED;;;OAGG;IACH,oBAFa,OAAO,CAInB;CACF"}