diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index 82f2a4b..b29723c 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -4,6 +4,7 @@ import path from "path"; import { fileURLToPath } from "url"; import router from "./routes/index.js"; import adminRelayPanelRouter from "./routes/admin-relay-panel.js"; +import { requestIdMiddleware } from "./middlewares/request-id.js"; import { responseTimeMiddleware } from "./middlewares/response-time.js"; const app: Express = express(); @@ -50,6 +51,7 @@ app.use( app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.use(requestIdMiddleware); app.use(responseTimeMiddleware); app.use("/api", router); diff --git a/artifacts/api-server/src/lib/logger.ts b/artifacts/api-server/src/lib/logger.ts index da53042..e155fab 100644 --- a/artifacts/api-server/src/lib/logger.ts +++ b/artifacts/api-server/src/lib/logger.ts @@ -4,7 +4,19 @@ export interface LogContext { [key: string]: unknown; } +const LEVEL_ORDER: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + +function resolveMinLevel(): LogLevel { + const env = (process.env["LOG_LEVEL"] ?? "").toLowerCase(); + if (env === "debug" || env === "info" || env === "warn" || env === "error") return env; + return "debug"; +} + +const minLevel: number = LEVEL_ORDER[resolveMinLevel()]; + function emit(level: LogLevel, component: string, message: string, ctx?: LogContext): void { + if (LEVEL_ORDER[level] < minLevel) return; + const line: Record = { timestamp: new Date().toISOString(), level, diff --git a/artifacts/api-server/src/lib/metrics.ts b/artifacts/api-server/src/lib/metrics.ts index 2fd2dfc..5ee3e9c 100644 --- a/artifacts/api-server/src/lib/metrics.ts +++ b/artifacts/api-server/src/lib/metrics.ts @@ -1,6 +1,7 @@ import { db, jobs, invoices } from "@workspace/db"; import { sql } from "drizzle-orm"; import { latencyHistogram, type BucketStats } from "./histogram.js"; +import { requestCounters, type RequestCountsSnapshot } from "./request-counters.js"; export interface JobStateCounts { awaiting_eval: number; @@ -12,6 +13,7 @@ export interface JobStateCounts { export interface MetricsSnapshot { uptime_s: number; + http: RequestCountsSnapshot; jobs: { total: number; by_state: JobStateCounts; @@ -94,6 +96,7 @@ export class MetricsService { return { uptime_s: Math.floor((Date.now() - START_TIME) / 1000), + http: requestCounters.snapshot(), jobs: { total: jobsTotal, by_state: byState, diff --git a/artifacts/api-server/src/lib/request-counters.ts b/artifacts/api-server/src/lib/request-counters.ts new file mode 100644 index 0000000..3db2656 --- /dev/null +++ b/artifacts/api-server/src/lib/request-counters.ts @@ -0,0 +1,37 @@ +/** In-memory HTTP request counters for the /api/metrics endpoint. */ + +export interface RequestCountsSnapshot { + total: number; + by_status: Record; + errors_4xx: number; + errors_5xx: number; +} + +class RequestCounters { + private total = 0; + private byStatus: Record = {}; + private errors4xx = 0; + private errors5xx = 0; + + record(statusCode: number): void { + this.total++; + this.byStatus[statusCode] = (this.byStatus[statusCode] ?? 0) + 1; + if (statusCode >= 400 && statusCode < 500) this.errors4xx++; + else if (statusCode >= 500) this.errors5xx++; + } + + snapshot(): RequestCountsSnapshot { + const byStatus: Record = {}; + for (const [code, count] of Object.entries(this.byStatus)) { + byStatus[code] = count; + } + return { + total: this.total, + by_status: byStatus, + errors_4xx: this.errors4xx, + errors_5xx: this.errors5xx, + }; + } +} + +export const requestCounters = new RequestCounters(); diff --git a/artifacts/api-server/src/middlewares/request-id.ts b/artifacts/api-server/src/middlewares/request-id.ts new file mode 100644 index 0000000..3bcb9ac --- /dev/null +++ b/artifacts/api-server/src/middlewares/request-id.ts @@ -0,0 +1,18 @@ +import crypto from "crypto"; +import type { Request, Response, NextFunction } from "express"; + +const HEADER = "X-Request-Id"; + +/** + * Assigns a unique request ID to every incoming request. + * If the client (or a reverse proxy) already sent X-Request-Id, reuse it; + * otherwise generate a short random hex string. + * The ID is stored on `res.locals.requestId` for downstream middleware/routes + * and echoed back via the X-Request-Id response header. + */ +export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void { + const id = (req.headers[HEADER.toLowerCase()] as string | undefined) ?? crypto.randomUUID(); + res.locals["requestId"] = id; + res.setHeader(HEADER, id); + next(); +} diff --git a/artifacts/api-server/src/middlewares/response-time.ts b/artifacts/api-server/src/middlewares/response-time.ts index 7475dbb..0d76102 100644 --- a/artifacts/api-server/src/middlewares/response-time.ts +++ b/artifacts/api-server/src/middlewares/response-time.ts @@ -1,6 +1,7 @@ import type { Request, Response, NextFunction } from "express"; import { makeLogger } from "../lib/logger.js"; import { latencyHistogram } from "../lib/histogram.js"; +import { requestCounters } from "../lib/request-counters.js"; const logger = makeLogger("http"); @@ -13,8 +14,10 @@ export function responseTimeMiddleware(req: Request, res: Response, next: NextFu const routeKey = `${req.method} ${route ?? req.path}`; latencyHistogram.record(routeKey, durationMs); + requestCounters.record(res.statusCode); logger.info("request", { + request_id: res.locals["requestId"] ?? null, method: req.method, path: req.path, route: route ?? null,