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`** * **`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`. * **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**: 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. * **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)**: * **Respuesta de Ejemplo (truncada)**:
```json ```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. Contiene la lógica de negocio más compleja en forma de scripts.
* **`health_checker.py`**: * **`health_checker.js`**:
* **Lenguaje**: Python 3. * **Lenguaje**: Node.js (requiere Node 18+ con `fetch` nativo).
* **Dependencias**: `requests`. * **Dependencias**: ninguna adicional (usa APIs nativas).
* **Lógica**: * **Lógica**:
1. Carga la lista de sitios desde `../data/sites.json`. 1. Carga la lista de sitios desde `../data/sites.json`.
2. Itera sobre cada servicio y realiza diferentes tipos de verificaciones: 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 path = require("path");
const fs = require("fs"); const fs = require("fs");
const { exec } = require("child_process"); const { exec } = require("child_process");
const { runHealthChecker } = require("./scripts/health_checker");
const app = express(); const app = express();
const port = process.env.PORT || 3001; const port = process.env.PORT || 3001;
@@ -17,28 +18,19 @@ app.use(express.static(rootDir, { index: "index.html" }));
/** /**
* @route GET /healthchecker * @route GET /healthchecker
* @description Ejecuta un script de Python para un chequeo de salud avanzado. * @description Ejecuta un chequeo de salud avanzado implementado en Node.js.
* Invoca el script `scripts/health_checker.py`, que monitorea múltiples servicios * Lee `data/sites.json`, consulta los servicios definidos y devuelve un reporte JSON.
* y devuelve un reporte detallado en formato JSON.
* @returns {object} Un objeto JSON con el estado de los servicios monitoreados. * @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) => { app.get("/healthchecker", async (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" });
}
try { try {
const healthData = JSON.parse(stdout); const healthData = await runHealthChecker();
res.status(200).json(healthData); res.status(200).json(healthData);
} catch (parseErr) { } catch (error) {
console.error(`Error al parsear la salida del chequeo de salud: ${parseErr}`); console.error("Error al ejecutar el health checker:", error);
res.status(500).json({ error: "La respuesta del chequeo de salud no es un JSON válido" }); res.status(500).json({ error: "No se pudo ejecutar el chequeo de salud" });
} }
});
}); });
/** /**