[claude] API observability — structured logging + /api/metrics endpoint (#57) #87
@@ -4,6 +4,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import router from "./routes/index.js";
|
import router from "./routes/index.js";
|
||||||
import adminRelayPanelRouter from "./routes/admin-relay-panel.js";
|
import adminRelayPanelRouter from "./routes/admin-relay-panel.js";
|
||||||
|
import { requestIdMiddleware } from "./middlewares/request-id.js";
|
||||||
import { responseTimeMiddleware } from "./middlewares/response-time.js";
|
import { responseTimeMiddleware } from "./middlewares/response-time.js";
|
||||||
|
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
@@ -50,6 +51,7 @@ app.use(
|
|||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(requestIdMiddleware);
|
||||||
app.use(responseTimeMiddleware);
|
app.use(responseTimeMiddleware);
|
||||||
|
|
||||||
app.use("/api", router);
|
app.use("/api", router);
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ export interface LogContext {
|
|||||||
[key: string]: unknown;
|
[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 {
|
function emit(level: LogLevel, component: string, message: string, ctx?: LogContext): void {
|
||||||
|
if (LEVEL_ORDER[level] < minLevel) return;
|
||||||
|
|
||||||
const line: Record<string, unknown> = {
|
const line: Record<string, unknown> = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
level,
|
level,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db, jobs, invoices } from "@workspace/db";
|
import { db, jobs, invoices } from "@workspace/db";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { latencyHistogram, type BucketStats } from "./histogram.js";
|
import { latencyHistogram, type BucketStats } from "./histogram.js";
|
||||||
|
import { requestCounters, type RequestCountsSnapshot } from "./request-counters.js";
|
||||||
|
|
||||||
export interface JobStateCounts {
|
export interface JobStateCounts {
|
||||||
awaiting_eval: number;
|
awaiting_eval: number;
|
||||||
@@ -12,6 +13,7 @@ export interface JobStateCounts {
|
|||||||
|
|
||||||
export interface MetricsSnapshot {
|
export interface MetricsSnapshot {
|
||||||
uptime_s: number;
|
uptime_s: number;
|
||||||
|
http: RequestCountsSnapshot;
|
||||||
jobs: {
|
jobs: {
|
||||||
total: number;
|
total: number;
|
||||||
by_state: JobStateCounts;
|
by_state: JobStateCounts;
|
||||||
@@ -94,6 +96,7 @@ export class MetricsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
uptime_s: Math.floor((Date.now() - START_TIME) / 1000),
|
uptime_s: Math.floor((Date.now() - START_TIME) / 1000),
|
||||||
|
http: requestCounters.snapshot(),
|
||||||
jobs: {
|
jobs: {
|
||||||
total: jobsTotal,
|
total: jobsTotal,
|
||||||
by_state: byState,
|
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 type { Request, Response, NextFunction } from "express";
|
||||||
import { makeLogger } from "../lib/logger.js";
|
import { makeLogger } from "../lib/logger.js";
|
||||||
import { latencyHistogram } from "../lib/histogram.js";
|
import { latencyHistogram } from "../lib/histogram.js";
|
||||||
|
import { requestCounters } from "../lib/request-counters.js";
|
||||||
|
|
||||||
const logger = makeLogger("http");
|
const logger = makeLogger("http");
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ export function responseTimeMiddleware(req: Request, res: Response, next: NextFu
|
|||||||
const routeKey = `${req.method} ${route ?? req.path}`;
|
const routeKey = `${req.method} ${route ?? req.path}`;
|
||||||
|
|
||||||
latencyHistogram.record(routeKey, durationMs);
|
latencyHistogram.record(routeKey, durationMs);
|
||||||
|
requestCounters.record(res.statusCode);
|
||||||
|
|
||||||
logger.info("request", {
|
logger.info("request", {
|
||||||
|
request_id: res.locals["requestId"] ?? null,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.path,
|
path: req.path,
|
||||||
route: route ?? null,
|
route: route ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user