feat: reimplement health checker from Python to Node.js.

This commit is contained in:
Marco Gallegos
2025-12-17 20:29:20 -06:00
parent 419b8a283b
commit 949a7c22cc
3 changed files with 237 additions and 25 deletions

View File

@@ -74,8 +74,8 @@ Es el núcleo de la aplicación. Configura un servidor Express que gestiona toda
```
* **`GET /healthchecker`**
* **Descripción**: Ejecuta un script de monitoreo en Python (`scripts/health_checker.py`) y devuelve un reporte detallado del estado de los servicios definidos en `data/sites.json`.
* **Lógica**: Utiliza `child_process.exec` de Node.js para invocar el script de Python. Captura la salida JSON del script y la sirve como respuesta. Es ideal para tableros de monitoreo o webhooks.
* **Descripción**: Ejecuta un monitor de salud escrito en Node.js (`scripts/health_checker.js`) y devuelve un reporte detallado del estado de los servicios definidos en `data/sites.json`.
* **Lógica**: Importa el módulo de chequeo desde `server.js`, corre todas las verificaciones (HTTP simples, endpoints dedicados y statuspage.io) y retorna el JSON resultante, ideal para tableros o webhooks.
* **Respuesta de Ejemplo (truncada)**:
```json
{
@@ -114,9 +114,9 @@ Este directorio centraliza todos los datos que la aplicación necesita para func
Contiene la lógica de negocio más compleja en forma de scripts.
* **`health_checker.py`**:
* **Lenguaje**: Python 3.
* **Dependencias**: `requests`.
* **`health_checker.js`**:
* **Lenguaje**: Node.js (requiere Node 18+ con `fetch` nativo).
* **Dependencias**: ninguna adicional (usa APIs nativas).
* **Lógica**:
1. Carga la lista de sitios desde `../data/sites.json`.
2. Itera sobre cada servicio y realiza diferentes tipos de verificaciones:

220
scripts/health_checker.js Normal file
View File

@@ -0,0 +1,220 @@
const fs = require("fs/promises");
const path = require("path");
const STATUSPAGE_SERVICES = new Set(["openai", "canva", "cloudflare"]);
const DEFAULT_TIMEOUT_MS = 10_000;
const getWebhookUrls = () => {
const value = process.env.WEBHOOK_URLS || "";
return value
.split(",")
.map((url) => url.trim())
.filter(Boolean);
};
const fetchWithTimeout = async (url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
return response;
} finally {
clearTimeout(id);
}
};
const checkUrl = async (url) => {
try {
const response = await fetchWithTimeout(url, {
headers: { "User-Agent": "HealthCheckMonitor/1.0" },
});
return response.status;
} catch {
return 0;
}
};
const checkVpsHealthEndpoint = async (url) => {
try {
const response = await fetchWithTimeout(url);
if (response.status !== 200) {
return `🔴 Caído (Endpoint status: ${response.status})`;
}
const data = await response.json();
const alive = data?.checks?.vps_ping?.alive;
if (alive) {
return "🟢 OK (VPS Reachable)";
}
return "🔴 Caído (VPS reporta 'alive': false)";
} catch (error) {
return `🔴 Error Conexión (${error.message})`;
}
};
const checkFormbricksHealth = async (url) => {
try {
const response = await fetchWithTimeout(url, {}, 8_000);
if (response.status !== 200) {
return `🔴 Caído (Código: ${response.status})`;
}
const data = await response.json().catch(() => null);
if (data?.status === "ok") {
return "🟢 OK (API Health: ok)";
}
if (data?.status) {
return `🟡 Advertencia (${data.status})`;
}
return "🟡 Advertencia (No JSON)";
} catch {
return "🔴 Caído (Error red)";
}
};
const getStatusPageStatus = async (baseUrl) => {
const url = `${baseUrl.replace(/\/$/, "")}/api/v2/summary.json`;
try {
const response = await fetchWithTimeout(url, {}, 8_000);
if (response.status !== 200) {
return `🔴 Caído (${response.status})`;
}
const data = await response.json();
const indicator = data?.status?.indicator;
const description = data?.status?.description;
if (indicator === "none") {
return `🟢 OK (${description})`;
}
return `🟡 Advertencia (${description})`;
} catch {
return "🔴 Error verificación";
}
};
const getGeminiStatus = async (displayUrl) => {
const incidentsUrl = "https://status.cloud.google.com/incidents.json";
try {
const response = await fetchWithTimeout(incidentsUrl, {}, 8_000);
if (response.status === 200) {
const incidents = await response.json();
const active = incidents.filter((incident) => {
if (incident.end) return false;
const serviceName = (incident.service_name || "").toLowerCase();
return (
serviceName.includes("gemini") ||
serviceName.includes("vertex") ||
serviceName.includes("generative")
);
});
if (active.length === 0) {
return "🟢 OK (Sin incidentes en Google AI)";
}
return `🟡 Advertencia (${active.length} incidentes activos)`;
}
const backupStatus = humanState(await checkUrl(displayUrl));
return backupStatus;
} catch {
return "🔴 Error de conexión";
}
};
const humanState = (code) => {
if (code === 200) return `🟢 OK (${code})`;
if ([301, 302, 307, 308].includes(code)) return `🟢 OK (Redirección ${code})`;
if ([401, 403, 404].includes(code)) return `🟡 Advertencia (${code})`;
return `🔴 Caído (${code})`;
};
const buildSection = async (dictionary) => {
const output = {};
for (const [name, urlOrIp] of Object.entries(dictionary)) {
let statusMessage;
if (name === "vps_soul23") {
statusMessage = await checkVpsHealthEndpoint(urlOrIp);
output[`${name}_status`] = statusMessage;
output[`${name}_state`] = statusMessage;
} else if (STATUSPAGE_SERVICES.has(name)) {
statusMessage = await getStatusPageStatus(urlOrIp);
output[`${name}_status`] = statusMessage;
output[`${name}_state`] = statusMessage;
} else if (name === "google_gemini") {
statusMessage = await getGeminiStatus(urlOrIp);
output[`${name}_status`] = statusMessage;
output[`${name}_state`] = statusMessage;
} else if (name === "formbricks") {
statusMessage = await checkFormbricksHealth(urlOrIp);
output[`${name}_status`] = statusMessage;
output[`${name}_state`] = statusMessage;
} else {
const statusCode = await checkUrl(urlOrIp);
output[`${name}_status`] = statusCode;
output[`${name}_state`] = humanState(statusCode);
}
output[`${name}_url`] = urlOrIp;
}
return output;
};
const postWebhooks = async (payload) => {
const urls = getWebhookUrls();
if (!urls.length) return;
await Promise.all(
urls.map(async (url) => {
try {
await fetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
10_000
);
} catch {
// Silenciar errores individuales de webhook para no romper el resto del proceso
}
})
);
};
const runHealthChecker = async () => {
const start = Date.now();
const sitesPath = path.join(__dirname, "..", "data", "sites.json");
const raw = await fs.readFile(sitesPath, "utf8");
const sites = JSON.parse(raw);
const result = {
timestamp: new Date().toISOString(),
internos: await buildSection(sites.internos || {}),
empresa: await buildSection(sites.sitios_empresa || {}),
externos: await buildSection(sites.externos || {}),
};
const duration = (Date.now() - start) / 1000;
result.execution_time_seconds = Number(duration.toFixed(2));
await postWebhooks(result);
return result;
};
module.exports = { runHealthChecker };
if (require.main === module) {
runHealthChecker()
.then((data) => {
console.log(JSON.stringify(data, null, 4));
})
.catch((error) => {
console.error(
JSON.stringify(
{ error: "Health checker failed", details: error.message },
null,
4
)
);
process.exit(1);
});
}

View File

@@ -2,6 +2,7 @@ const express = require("express");
const path = require("path");
const fs = require("fs");
const { exec } = require("child_process");
const { runHealthChecker } = require("./scripts/health_checker");
const app = express();
const port = process.env.PORT || 3001;
@@ -17,29 +18,20 @@ app.use(express.static(rootDir, { index: "index.html" }));
/**
* @route GET /healthchecker
* @description Ejecuta un script de Python para un chequeo de salud avanzado.
* Invoca el script `scripts/health_checker.py`, que monitorea múltiples servicios
* y devuelve un reporte detallado en formato JSON.
* @description Ejecuta un chequeo de salud avanzado implementado en Node.js.
* Lee `data/sites.json`, consulta los servicios definidos y devuelve un reporte JSON.
* @returns {object} Un objeto JSON con el estado de los servicios monitoreados.
* @throws 500 - Si el script de Python falla o su salida no es un JSON válido.
* @throws 500 - Si ocurre algún error durante la ejecución del chequeo.
*/
app.get("/healthchecker", (req, res) => {
const pythonScriptPath = path.join(rootDir, "scripts", "health_checker.py");
exec(`python3 ${pythonScriptPath}`, (error, stdout, stderr) => {
if (error) {
console.error(`Error al ejecutar health_checker.py: ${stderr}`);
return res.status(500).json({ error: "No se pudo ejecutar el chequeo de salud" });
}
app.get("/healthchecker", async (req, res) => {
try {
const healthData = JSON.parse(stdout);
const healthData = await runHealthChecker();
res.status(200).json(healthData);
} catch (parseErr) {
console.error(`Error al parsear la salida del chequeo de salud: ${parseErr}`);
res.status(500).json({ error: "La respuesta del chequeo de salud no es un JSON válido" });
} catch (error) {
console.error("Error al ejecutar el health checker:", error);
res.status(500).json({ error: "No se pudo ejecutar el chequeo de salud" });
}
});
});
/**
* @route GET /telegram