[claude] API observability — structured logging + /api/metrics endpoint (#57) #87
@@ -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);
|
||||
|
||||
@@ -4,7 +4,19 @@ export interface LogContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = { 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<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
artifacts/api-server/src/lib/request-counters.ts
Normal file
37
artifacts/api-server/src/lib/request-counters.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** In-memory HTTP request counters for the /api/metrics endpoint. */
|
||||
|
||||
export interface RequestCountsSnapshot {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
errors_4xx: number;
|
||||
errors_5xx: number;
|
||||
}
|
||||
|
||||
class RequestCounters {
|
||||
private total = 0;
|
||||
private byStatus: Record<number, number> = {};
|
||||
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<string, number> = {};
|
||||
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();
|
||||
18
artifacts/api-server/src/middlewares/request-id.ts
Normal file
18
artifacts/api-server/src/middlewares/request-id.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user