u
This commit is contained in:
181
src/services/healthMonitorService.js
Normal file
181
src/services/healthMonitorService.js
Normal file
@@ -0,0 +1,181 @@
|
||||
// #region Imports & Configuration
|
||||
import http from 'node:http';
|
||||
import Redis from 'ioredis';
|
||||
import env from '../config/env.js';
|
||||
import { createLogger } from '../util/logger.js';
|
||||
|
||||
const healthLog = createLogger('health-monitor');
|
||||
|
||||
let scheduledTimer = null;
|
||||
let scheduledInterval = null;
|
||||
let isServiceRunning = false;
|
||||
let redisClient = null;
|
||||
// #endregion
|
||||
|
||||
// #region Redis Client Management
|
||||
// MARK: resolves or creates the lazy Redis client
|
||||
export function getRedisClient() {
|
||||
if (!redisClient) {
|
||||
const opts = env.redis.url
|
||||
? env.redis.url
|
||||
: {
|
||||
host: env.redis.host,
|
||||
port: env.redis.port,
|
||||
password: env.redis.password || undefined,
|
||||
db: env.redis.db || 0,
|
||||
lazyConnect: true,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy(times) {
|
||||
return Math.min(times * 1000, 30000);
|
||||
},
|
||||
};
|
||||
|
||||
redisClient = new Redis(opts);
|
||||
redisClient.on('error', (err) => {
|
||||
healthLog.warn(`Redis connection error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region TCP Health API Selftest
|
||||
// MARK: executes an HTTP GET /health call over TCP loopback and measures response time
|
||||
export function performHealthSelftest() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = performance.now();
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: env.port,
|
||||
path: '/health',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Dashback-HealthMonitor/1.0',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ success: true, durationMs, statusCode: res.statusCode, body: data });
|
||||
} else {
|
||||
resolve({ success: false, durationMs, statusCode: res.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', (err) => {
|
||||
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
||||
reject({ error: err, durationMs });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('TCP connection or request timed out'));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// MARK: runs one selftest cycle and writes duration to Redis
|
||||
async function executeHealthCheckCycle() {
|
||||
try {
|
||||
const result = await performHealthSelftest();
|
||||
if (result.success) {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
await redis.set(env.redis.key, String(result.durationMs));
|
||||
healthLog.debug(
|
||||
`Health selftest passed in ${result.durationMs}ms; recorded in redis key "${env.redis.key}"`
|
||||
);
|
||||
} catch (redisErr) {
|
||||
healthLog.warn(`Failed to write latency to Redis: ${redisErr.message}`);
|
||||
}
|
||||
} else {
|
||||
healthLog.warn(
|
||||
`Health selftest returned HTTP ${result.statusCode} in ${result.durationMs}ms`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err?.error?.message || err?.message || String(err);
|
||||
healthLog.warn(`Health selftest failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Service Lifecycle
|
||||
// MARK: starts the periodic health check selftest service
|
||||
export function startHealthMonitorService() {
|
||||
if (isServiceRunning) return;
|
||||
if (env.healthMonitor?.enabled === false) {
|
||||
healthLog.info('Health monitor service is disabled by configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
isServiceRunning = true;
|
||||
const intervalMs = env.healthMonitor?.intervalMs || 60_000;
|
||||
|
||||
// Run initial selftest after 1s warmup
|
||||
scheduledTimer = setTimeout(() => {
|
||||
scheduledTimer = null;
|
||||
executeHealthCheckCycle().catch((err) => {
|
||||
healthLog.error(`Initial health selftest error: ${err.message}`);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Schedule recurring selftest every minute
|
||||
scheduledInterval = setInterval(() => {
|
||||
executeHealthCheckCycle().catch((err) => {
|
||||
healthLog.error(`Recurring health selftest error: ${err.message}`);
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
healthLog.info(`Health monitor service started (selftest every ${Math.round(intervalMs / 1000)}s via TCP).`);
|
||||
}
|
||||
|
||||
// MARK: stops the health check service and closes Redis client
|
||||
export async function stopHealthMonitorService() {
|
||||
isServiceRunning = false;
|
||||
if (scheduledTimer) {
|
||||
clearTimeout(scheduledTimer);
|
||||
scheduledTimer = null;
|
||||
}
|
||||
if (scheduledInterval) {
|
||||
clearInterval(scheduledInterval);
|
||||
scheduledInterval = null;
|
||||
}
|
||||
|
||||
if (redisClient) {
|
||||
try {
|
||||
await redisClient.quit();
|
||||
} catch {
|
||||
redisClient.disconnect();
|
||||
} finally {
|
||||
redisClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
healthLog.info('Health monitor service stopped.');
|
||||
}
|
||||
|
||||
// MARK: returns health monitor status
|
||||
export function getHealthMonitorStatus() {
|
||||
return {
|
||||
enabled: env.healthMonitor?.enabled ?? true,
|
||||
isRunning: isServiceRunning,
|
||||
intervalMs: env.healthMonitor?.intervalMs || 60_000,
|
||||
redisKey: env.redis.key,
|
||||
redisHost: env.redis.host,
|
||||
redisPort: env.redis.port,
|
||||
};
|
||||
}
|
||||
// #endregion
|
||||
Reference in New Issue
Block a user