[claude] API observability — structured logging + /api/metrics endpoint (#57) #87

Merged
claude merged 1 commits from claude/issue-57 into main 2026-03-23 20:10:41 +00:00
6 changed files with 75 additions and 0 deletions

View File

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

View File

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

View File

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

View 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();

View 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();
}

View File

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