From 0511f7d2ee81f00780b4c4eab332569ef33e99c4 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 19:30:09 +0200 Subject: [PATCH] Monitoring: /health (site, API, database) for an uptime monitor; errors logged as one JSON line each by the API and the site; the API answers errors without internal details Co-Authored-By: Claude Opus 5.5 --- api/src/server.ts | 19 +++++++++++++++++++ web/src/middleware.ts | 12 ++++++++++-- web/src/pages/health.ts | 20 ++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 web/src/pages/health.ts diff --git a/api/src/server.ts b/api/src/server.ts index b5fcb65c..376191aa 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -24,6 +24,25 @@ import { tagRoutes, pageTags } from './tags.ts'; import { ebookRoutes } from './ebooks.ts'; const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); + +// Errors: readers get a short message, never internal details; the log gets the whole error (one JSON line each, +// with the method and address), which is what the server's error report reads (divan-deploy scripts/errors.sh). +app.setErrorHandler((err: any, req, reply) => { + const status = err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 500; + if (status === 500) req.log.error({ err, method: req.method, url: req.url }, 'request failed'); + reply.code(status).send({ error: status === 500 ? 'سرور میں خرابی۔ تھوڑی دیر بعد کوشش کریں۔' : err.message }); +}); + +// for the site's /health (uptime monitoring): the API answers and the database too +app.get('/api/health', async (req, reply) => { + try { + await pool.query('SELECT 1'); + return { ok: true, db: 'ok' }; + } catch (err) { + req.log.error({ err }, 'health: database unreachable'); + return reply.code(503).send({ ok: false, db: 'down' }); + } +}); const PAGE_SIZE = 20; app.get('/health', async () => { diff --git a/web/src/middleware.ts b/web/src/middleware.ts index d0d91be4..a24b0bad 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -1,6 +1,6 @@ // The signed-in reader (or null) for every page, from the session cookie; and the reader's numerals: pages are // written with Eastern Arabic (Urdu) digits, and a reader who chose Western digits (cookie divan-digits=latn, set in -// the display settings) gets them in the page as sent, so nothing flashes +// the display settings) gets them in the page as sent, so nothing flashes. Errors are logged as one JSON line each. import { defineMiddleware } from 'astro:middleware'; import { auth, COOKIE } from './lib/auth'; @@ -12,7 +12,15 @@ export const onRequest = defineMiddleware(async (ctx, next) => { if (r.ok) ctx.locals.user = r.data.user; else if (r.status === 401) ctx.cookies.delete(COOKIE, { path: '/' }); // expired or signed out elsewhere } - const res = await next(); + let res: Response; + try { + res = await next(); + } catch (err: any) { + // one JSON line per error (the server's error report reads these); the reader sees the Urdu error page (500.astro) + console.error(JSON.stringify({ level: 'error', time: new Date().toISOString(), method: ctx.request.method, path: ctx.url.pathname, + msg: String(err?.message ?? err), stack: String(err?.stack ?? '').split('\n').slice(0, 8).join('\n') })); + throw err; + } if (ctx.cookies.get('divan-digits')?.value !== 'latn' || !res.headers.get('content-type')?.includes('text/html')) return res; // text only: scripts and styles are left alone (the page's own scripts look for Eastern digits) const html = (await res.text()).split(/(|)/) diff --git a/web/src/pages/health.ts b/web/src/pages/health.ts new file mode 100644 index 00000000..1b3bb5d0 --- /dev/null +++ b/web/src/pages/health.ts @@ -0,0 +1,20 @@ +// /health for an uptime monitor (divan-deploy README): 200 when the site, the API and the database all answer, +// 503 otherwise, with which part is down. Never cached. +import type { APIRoute } from 'astro'; + +const API = process.env.API_URL ?? 'http://127.0.0.1:4100'; + +export const GET: APIRoute = async () => { + const started = Date.now(); + let api = 'down', db = 'down'; + try { + const r = await fetch(`${API}/api/health`, { signal: AbortSignal.timeout(3000) }); + api = 'ok'; + db = (await r.json().catch(() => ({}))).db ?? 'down'; + } catch {} + const ok = api === 'ok' && db === 'ok'; + if (!ok) console.error(JSON.stringify({ level: 'error', time: new Date().toISOString(), msg: 'health check failed', api, db })); + return new Response(JSON.stringify({ ok, site: 'ok', api, db, ms: Date.now() - started }), { + status: ok ? 200 : 503, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, + }); +};