Fix crash handling and log noise in logging feature

Review of the logging work from the previous commit turned up a real
correctness issue: the uncaughtException/unhandledRejection handlers
logged the error but let the process keep running, which silently
disabled Node's default crash-on-fatal-error behavior and could leave
a zombie process serving broken requests instead of restarting. Both
processes now log and then exit(1); restart: unless-stopped is added
to both services so Docker actually brings them back up.

Also: harden the logger against JSON.stringify throwing on
non-serializable meta, log aborted (closed-before-finished) requests
in the API access log, and stop the Next.js proxy from logging the
Docker healthcheck's request to "/" every few seconds so real
navigation events aren't drowned out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-08-06 19:58:31 +02:00
parent ea3c02cd6b
commit fa96be2d42
6 changed files with 49 additions and 15 deletions

View file

@ -13,6 +13,7 @@ services:
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
logging:
driver: json-file
options:
@ -53,6 +54,7 @@ services:
NEXT_TELEMETRY_DISABLED: "1"
LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
logging:
driver: json-file
options:

View file

@ -58,6 +58,15 @@ mit Laufzeit und Speicherverbrauch nützlich, um Speicherlecks oder Hänger
einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche
`LOG_LEVEL=debug` setzen; das protokolliert zusätzlich den Start jeder
API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar.
Ein `close`-Ereignis ohne vorheriges `finish` wird als `request aborted before
response finished` (`warn`) geloggt und zeigt damit vom Client oder einem
vorgeschalteten Proxy abgebrochene Verbindungen.
Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet
den jeweiligen Prozess anschließend bewusst (`process.exit(1)`), statt in
einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
## Voraussetzungen für ein späteres Produktionssetup

View file

@ -7,11 +7,13 @@ export function registerNodeInstrumentation() {
logger.info("web server starting", { pid: process.pid });
process.on("uncaughtException", (error) => {
logger.error("uncaught exception", toErrorMeta(error));
logger.error("uncaught exception, exiting", toErrorMeta(error));
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logger.error("unhandled rejection", toErrorMeta(reason));
logger.error("unhandled rejection, exiting", toErrorMeta(reason));
process.exit(1);
});
setInterval(() => {

View file

@ -5,10 +5,14 @@ import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation");
export function proxy(request: NextRequest) {
logger.info("page request", {
method: request.method,
path: request.nextUrl.pathname,
});
// The Docker healthcheck hits "/" every few seconds with no User-Agent
// header; skip it so real navigation isn't drowned out in the logs.
if (request.headers.get("user-agent")) {
logger.info("page request", {
method: request.method,
path: request.nextUrl.pathname,
});
}
return NextResponse.next();
}

View file

@ -31,6 +31,15 @@ app.use((req, res, next) => {
else if (res.statusCode >= 400) logger.warn("request completed", meta);
else logger.info("request completed", meta);
});
res.on("close", () => {
if (!res.writableEnded) {
logger.warn("request aborted before response finished", {
method: req.method,
path: req.originalUrl,
durationMs: Date.now() - startedAt,
});
}
});
next();
});
@ -46,11 +55,13 @@ app.use("/api/project-devices", projectDeviceRouter);
app.use(errorMiddleware);
process.on("uncaughtException", (error) => {
logger.error("uncaught exception", toErrorMeta(error));
logger.error("uncaught exception, exiting", toErrorMeta(error));
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logger.error("unhandled rejection", toErrorMeta(reason));
logger.error("unhandled rejection, exiting", toErrorMeta(reason));
process.exit(1);
});
process.on("SIGTERM", () => logger.info("received SIGTERM"));

View file

@ -47,13 +47,19 @@ export function createLogger(
if (LEVEL_SEVERITY[level] > LEVEL_SEVERITY[threshold]) {
return;
}
const line = JSON.stringify({
timestamp: new Date().toISOString(),
level,
scope,
message,
...meta,
});
const timestamp = new Date().toISOString();
let line: string;
try {
line = JSON.stringify({ timestamp, level, scope, message, ...meta });
} catch {
line = JSON.stringify({
timestamp,
level,
scope,
message,
logError: "failed to serialize log metadata",
});
}
if (level === "error" || level === "warn") {
console.error(line);
} else {