Merge pull request 'Monitoring: /health for uptime checks, structured error logging, API errors without internal details' (#90) from feature/monitoring into main

Reviewed-on: #90
This commit is contained in:
anas 2026-10-09 19:49:08 +00:00
commit 415459d55f
3 changed files with 49 additions and 2 deletions

View File

@ -24,6 +24,25 @@ import { tagRoutes, pageTags } from './tags.ts';
import { ebookRoutes } from './ebooks.ts'; import { ebookRoutes } from './ebooks.ts';
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); 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; const PAGE_SIZE = 20;
app.get('/health', async () => { app.get('/health', async () => {

View File

@ -1,6 +1,6 @@
// The signed-in reader (or null) for every page, from the session cookie; and the reader's numerals: pages are // 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 // 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 { defineMiddleware } from 'astro:middleware';
import { auth, COOKIE } from './lib/auth'; 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; if (r.ok) ctx.locals.user = r.data.user;
else if (r.status === 401) ctx.cookies.delete(COOKIE, { path: '/' }); // expired or signed out elsewhere 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; 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) // text only: scripts and styles are left alone (the page's own scripts look for Eastern digits)
const html = (await res.text()).split(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>)/) const html = (await res.text()).split(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>)/)

20
web/src/pages/health.ts Normal file
View File

@ -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' },
});
};