From 7994ea79f46e2d55141f1b0eb14398677deac961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 5 Apr 2026 16:20:18 +0200 Subject: [PATCH] Fix overview error rate panel, add request metrics to journal Dashboard: switch error rate panel from app-level http_request_duration (never observed) to Caddy's caddy_http_response_duration_seconds_count. Journal: add custom server.ts (matching planner pattern) that observes http_request_duration_seconds on every request. Replaces react-router-serve with a custom Node HTTP server that handles /api/health, /api/metrics, static assets, and request timing. Removes redundant React Router routes for health and metrics. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/Dockerfile | 3 +- apps/journal/app/routes.ts | 2 - apps/journal/package.json | 2 +- apps/journal/server.ts | 96 +++++++++++++++++++ infrastructure/docker-compose.yml | 2 +- .../grafana/dashboards/overview.json | 2 +- 6 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 apps/journal/server.ts diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 51fcd14..53c6cdb 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -28,6 +28,7 @@ RUN addgroup --system app && adduser --system --ingroup app app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules COPY --from=build /app/apps/journal/build ./apps/journal/build +COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json COPY --from=build /app/packages ./packages @@ -35,4 +36,4 @@ WORKDIR /app/apps/journal USER app EXPOSE 3000 ENV PORT=3000 -CMD ["npx", "react-router-serve", "./build/server/index.js"] +CMD ["node", "--experimental-strip-types", "server.ts"] diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index a3a1804..60e9952 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -30,6 +30,4 @@ export default [ route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"), route("privacy", "routes/privacy.tsx"), - route("api/health", "routes/api.health.ts"), - route("api/metrics", "routes/api.metrics.ts"), ] satisfies RouteConfig; diff --git a/apps/journal/package.json b/apps/journal/package.json index 012e06e..40afbfb 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "react-router dev", "build": "react-router build", - "start": "react-router-serve ./build/server/index.js", + "start": "node --experimental-strip-types server.ts", "typecheck": "react-router typegen && tsc", "lint": "eslint ." }, diff --git a/apps/journal/server.ts b/apps/journal/server.ts new file mode 100644 index 0000000..3385112 --- /dev/null +++ b/apps/journal/server.ts @@ -0,0 +1,96 @@ +import { createRequestListener } from "@react-router/node"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createReadStream, statSync } from "node:fs"; +import { join, extname, resolve } from "node:path"; +import { logger } from "./app/lib/logger.server.ts"; +import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts"; + +const port = Number(process.env.PORT ?? 3000); +const CLIENT_DIR = resolve(import.meta.dirname, "build", "client"); + +const MIME: Record = { + ".js": "application/javascript", + ".css": "text/css", + ".html": "text/html", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +function serveStatic(req: IncomingMessage, res: ServerResponse): boolean { + if (req.method !== "GET" && req.method !== "HEAD") return false; + + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); + const filePath = resolve(join(CLIENT_DIR, url.pathname)); + + if (!filePath.startsWith(CLIENT_DIR)) return false; + + try { + if (!statSync(filePath).isFile()) return false; + } catch { + return false; + } + + res.setHeader("Content-Type", MIME[extname(filePath)] ?? "application/octet-stream"); + if (url.pathname.startsWith("/assets/")) { + res.setHeader("Cache-Control", "public, immutable, max-age=31536000"); + } + createReadStream(filePath).pipe(res); + return true; +} + +const listener = createRequestListener({ + build: () => import("./build/server/index.js" as string) as never, +}); + +async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promise { + const metrics = await registry.metrics(); + res.writeHead(200, { "Content-Type": registry.contentType }); + res.end(metrics); +} + +const version = process.env.SENTRY_RELEASE ?? "dev"; + +async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise { + try { + const { createDb } = await import("@trails-cool/db"); + const { sql } = await import("drizzle-orm"); + const db = createDb(); + await db.execute(sql`SELECT 1`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", version, db: "connected" })); + } catch { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" })); + } +} + +const server = createServer((req, res) => { + const url = req.url ?? "/"; + const start = Date.now(); + + if (!url.startsWith("/assets/") && url !== "/api/health" && url !== "/api/metrics") { + res.on("finish", () => { + const duration = Date.now() - start; + logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request"); + httpRequestDuration.observe( + { method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) }, + duration / 1000, + ); + }); + } + + if (url === "/api/health") { handleHealth(req, res); return; } + if (url === "/api/metrics") { handleMetrics(req, res); return; } + if (!serveStatic(req, res)) { + listener(req, res); + } +}); + +server.listen(port, () => { + logger.info({ port }, "Journal server listening"); +}); diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 64858da..3f63dc5 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -35,7 +35,7 @@ services: WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-} WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-} healthcheck: - test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"] + test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""] interval: 15s timeout: 5s retries: 3 diff --git a/infrastructure/grafana/dashboards/overview.json b/infrastructure/grafana/dashboards/overview.json index dd7df4a..250833c 100644 --- a/infrastructure/grafana/dashboards/overview.json +++ b/infrastructure/grafana/dashboards/overview.json @@ -52,7 +52,7 @@ }, "targets": [ { - "expr": "sum(rate(http_request_duration_seconds_count{status=~\"5..\"}[5m])) / sum(rate(http_request_duration_seconds_count[5m])) * 100", + "expr": "sum(rate(caddy_http_response_duration_seconds_count{code=~\"5..\"}[5m])) / sum(rate(caddy_http_response_duration_seconds_count[5m])) * 100", "legendFormat": "Error %" } ],