import type { MonitoringView, ServiceHealthView, ServiceStatus } from "@batch-cooking/shared"; import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; /** How long each outbound probe (Postgres query, intent-service HTTP) is allowed to take before it counts as `down`. */ const PROBE_TIMEOUT_MS = 2000; /** * Heartbeat-age thresholds for the LLM worker. Its default cron is weekly * (`TECH_STEP_WORKER_CRON`, `0 3 * * 0`), and it also pings on boot/tick — * so no ping for **8 days** means it likely missed its last scheduled fire * (`degraded`), and none for **3 weeks** means it's almost certainly not * running at all (`down`). */ const WORKER_STALE_AFTER_MS = 8 * 24 * 60 * 60 * 1000; const WORKER_DOWN_AFTER_MS = 21 * 24 * 60 * 60 * 1000; const WORKER_KEY = "tech-step-llm-worker"; function nowIso(): string { return new Date().toISOString(); } function roundMs(value: number): number { return Math.round(value * 10) / 10; } function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } /** `12500` → `"il y a 12 s"`, `90000` → `"il y a 1 min"`, `172800000` → `"il y a 2 j"`. */ function formatAgo(ms: number): string { const s = Math.round(ms / 1000); if (s < 60) return `il y a ${s} s`; const m = Math.round(s / 60); if (m < 60) return `il y a ${m} min`; const h = Math.round(m / 60); if (h < 48) return `il y a ${h} h`; return `il y a ${Math.round(h / 24)} j`; } /** `process.uptime()` seconds → `"3 h 12 min"` / `"5 min"` / `"42 s"`. */ function formatUptime(seconds: number): string { const s = Math.floor(seconds); if (s < 60) return `${s} s`; const m = Math.floor(s / 60); if (m < 60) return `${m} min`; const h = Math.floor(m / 60); return `${h} h ${m % 60} min`; } /** `prisma.$queryRaw\`SELECT 1\`` with a bounded timeout — the DB connectivity probe. */ async function probePostgres(): Promise { const start = performance.now(); try { // `$queryRaw` doesn't take an AbortSignal — bound it with a race instead. await Promise.race([ prisma.$queryRaw`SELECT 1`, new Promise((_resolve, reject) => setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS), ), ]); return { key: "postgres", status: "up", latencyMs: roundMs(performance.now() - start), detail: null, checkedAt: nowIso(), }; } catch (err) { return { key: "postgres", status: "down", latencyMs: null, detail: errMessage(err), checkedAt: nowIso(), }; } } /** The API itself — trivially "up" (it's answering), reported with its process uptime/memory. */ function probeApi(): ServiceHealthView { const mem = process.memoryUsage(); return { key: "api", status: "up", latencyMs: 0, detail: `uptime ${formatUptime(process.uptime())} · RSS ${Math.round(mem.rss / 1_000_000)} Mo`, checkedAt: nowIso(), }; } /** `GET {INTENT_SERVICE_BASE_URL}/health` — no secret needed on that route (see the service's `routes/health.py`). */ async function probeIntentService(): Promise { const start = performance.now(); try { const res = await fetch(`${env.INTENT_SERVICE_BASE_URL}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); const latencyMs = roundMs(performance.now() - start); return { key: "intent-service", status: res.ok ? "up" : "degraded", latencyMs, detail: `HTTP ${res.status}`, checkedAt: nowIso(), }; } catch (err) { return { key: "intent-service", status: "down", latencyMs: null, detail: errMessage(err), checkedAt: nowIso(), }; } } /** Reads the LLM worker's stored `WorkerHeartbeat` (it has no HTTP surface to probe directly) and grades it by age + last job outcome. */ async function probeWorker(): Promise { const heartbeat = await prisma.workerHeartbeat.findUnique({ where: { workerKey: WORKER_KEY } }); if (!heartbeat) { return { key: WORKER_KEY, status: "unknown", latencyMs: null, detail: "aucun battement reçu", checkedAt: nowIso(), lastRunAt: null, lastResult: null, }; } const ageMs = Date.now() - heartbeat.lastSeenAt.getTime(); const lastResult = (heartbeat.lastResult ?? null) as ServiceHealthView["lastResult"]; let status: ServiceStatus = "up"; if (ageMs > WORKER_DOWN_AFTER_MS) status = "down"; else if (ageMs > WORKER_STALE_AFTER_MS || lastResult?.ok === false) status = "degraded"; return { key: WORKER_KEY, status, latencyMs: null, detail: `dernier battement ${formatAgo(ageMs)}`, checkedAt: nowIso(), lastRunAt: heartbeat.lastRunAt?.toISOString() ?? null, lastResult, }; } /** * Actively probes every dependency the admin monitoring board watches — * Postgres, the API itself, `tech-step-intent-service` (`/health`), and the * LLM worker (via its stored heartbeat). Each probe is independent and * bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the * others or the endpoint. */ export async function getMonitoring(): Promise { try { const [postgres, intentService, worker] = await Promise.all([ probePostgres(), probeIntentService(), probeWorker(), ]); return { generatedAt: nowIso(), services: [postgres, probeApi(), intentService, worker], }; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } }