Add configurable structured logging
Adds a leveled JSON logger (error/warn/info/verbose/debug, controlled via LOG_LEVEL) wired into the Express API (access log, error middleware, crash handlers, memory heartbeat) and the Next.js server (page-request proxy, instrumentation crash handlers, heartbeat). LOG_LEVEL is exposed through compose.yaml, and both services now rotate their Docker logs (json-file, 20m x 10 files) instead of growing unbounded. Intended to capture long-running diagnostic data for the intermittent 502s seen on the Docker host. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f26c000007
commit
ea3c02cd6b
12 changed files with 317 additions and 6 deletions
25
src/instrumentation-node.ts
Normal file
25
src/instrumentation-node.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createLogger, toErrorMeta } from "./shared/logging/logger";
|
||||
|
||||
export function registerNodeInstrumentation() {
|
||||
const logger = createLogger("web");
|
||||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||
|
||||
logger.info("web server starting", { pid: process.pid });
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("uncaught exception", toErrorMeta(error));
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error("unhandled rejection", toErrorMeta(reason));
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const memory = process.memoryUsage();
|
||||
logger.verbose("heartbeat", {
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
rssMb: Math.round(memory.rss / 1024 / 1024),
|
||||
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
|
||||
});
|
||||
}, heartbeatIntervalMs).unref();
|
||||
}
|
||||
8
src/instrumentation.ts
Normal file
8
src/instrumentation.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { registerNodeInstrumentation } = await import("./instrumentation-node");
|
||||
registerNodeInstrumentation();
|
||||
}
|
||||
17
src/proxy.ts
Normal file
17
src/proxy.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
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,
|
||||
});
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
||||
};
|
||||
|
|
@ -4,12 +4,36 @@ import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
|||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||
import { projectRouter } from "./routes/project.routes.js";
|
||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||
|
||||
const logger = createLogger("api");
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === "/health") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logger.debug("request started", { method: req.method, path: req.originalUrl });
|
||||
res.on("finish", () => {
|
||||
const meta = {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
if (res.statusCode >= 500) logger.error("request completed", meta);
|
||||
else if (res.statusCode >= 400) logger.warn("request completed", meta);
|
||||
else logger.info("request completed", meta);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
|
@ -21,6 +45,26 @@ app.use("/api/project-devices", projectDeviceRouter);
|
|||
|
||||
app.use(errorMiddleware);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}`);
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("uncaught exception", toErrorMeta(error));
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error("unhandled rejection", toErrorMeta(reason));
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => logger.info("received SIGTERM"));
|
||||
process.on("SIGINT", () => logger.info("received SIGINT"));
|
||||
|
||||
setInterval(() => {
|
||||
const memory = process.memoryUsage();
|
||||
logger.verbose("heartbeat", {
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
rssMb: Math.round(memory.rss / 1024 / 1024),
|
||||
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
|
||||
});
|
||||
}, heartbeatIntervalMs).unref();
|
||||
|
||||
app.listen(port, () => {
|
||||
logger.info("server started", { port });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import type { NextFunction, Request, Response } from "express";
|
||||
import { createLogger, toErrorMeta } from "../../shared/logging/logger.js";
|
||||
|
||||
const logger = createLogger("api");
|
||||
|
||||
export function errorMiddleware(
|
||||
error: unknown,
|
||||
_req: Request,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) {
|
||||
console.error(error);
|
||||
logger.error("request handler threw", {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
...toErrorMeta(error),
|
||||
});
|
||||
res.status(500).json({ error: "Internal Server Error" });
|
||||
}
|
||||
|
||||
|
|
|
|||
71
src/shared/logging/logger.ts
Normal file
71
src/shared/logging/logger.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export type LogLevel = "error" | "warn" | "info" | "verbose" | "debug";
|
||||
|
||||
const LEVEL_SEVERITY: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
verbose: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
const DEFAULT_LEVEL: LogLevel = "info";
|
||||
|
||||
export function resolveLogLevel(raw: string | undefined): LogLevel {
|
||||
const candidate = raw?.trim().toLowerCase();
|
||||
if (candidate && candidate in LEVEL_SEVERITY) {
|
||||
return candidate as LogLevel;
|
||||
}
|
||||
return DEFAULT_LEVEL;
|
||||
}
|
||||
|
||||
export function toErrorMeta(error: unknown): Record<string, unknown> {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
return { error: typeof error === "string" ? error : JSON.stringify(error) };
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
error(message: string, meta?: Record<string, unknown>): void;
|
||||
warn(message: string, meta?: Record<string, unknown>): void;
|
||||
info(message: string, meta?: Record<string, unknown>): void;
|
||||
verbose(message: string, meta?: Record<string, unknown>): void;
|
||||
debug(message: string, meta?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export function createLogger(
|
||||
scope: string,
|
||||
options?: { level?: LogLevel }
|
||||
): Logger {
|
||||
const threshold = options?.level ?? resolveLogLevel(process.env.LOG_LEVEL);
|
||||
|
||||
const write = (level: LogLevel, message: string, meta?: Record<string, unknown>) => {
|
||||
if (LEVEL_SEVERITY[level] > LEVEL_SEVERITY[threshold]) {
|
||||
return;
|
||||
}
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
scope,
|
||||
message,
|
||||
...meta,
|
||||
});
|
||||
if (level === "error" || level === "warn") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
error: (message, meta) => write("error", message, meta),
|
||||
warn: (message, meta) => write("warn", message, meta),
|
||||
info: (message, meta) => write("info", message, meta),
|
||||
verbose: (message, meta) => write("verbose", message, meta),
|
||||
debug: (message, meta) => write("debug", message, meta),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue