Merge pull request #171 from trails-cool/fix/overview-error-rate-and-journal-metrics

Fix overview error rate panel, add request metrics to journal
This commit is contained in:
Ullrich Schäfer 2026-04-05 15:23:07 +01:00 committed by GitHub
commit cde1ddc6f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 101 additions and 6 deletions

View file

@ -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"]

View file

@ -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;

View file

@ -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 ."
},

96
apps/journal/server.ts Normal file
View file

@ -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<string, string> = {
".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<void> {
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<void> {
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");
});

View file

@ -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

View file

@ -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 %"
}
],