diff --git a/docs/metrics.md b/docs/metrics.md index f27f71ec..24af827d 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -33,7 +33,7 @@ Collected by `prom-client`'s `collectDefaultMetrics`: |---|---|---|---| | `http_request_duration_seconds` | Histogram | `route`, `status` | HTTP request duration in seconds, bucketed | | `stats_request_duration_seconds` | Histogram | `route`, `status` | `/api/stats` request duration in seconds, bucketed. Observed for every request including ones rejected by rate limiting | -| `markets_request_duration_seconds` | Histogram | `endpoint`, `method`, `status` | `/api/markets` request duration in seconds, bucketed per endpoint (`list`, `search`, `featured`, `upcoming`, `get`, `patch`) | +| `markets_request_duration_seconds` | Histogram | `route`, `method`, `status` | `/api/markets` request duration in seconds, bucketed per route template, method, and status code | | `markets_requests_total` | Counter | `endpoint`, `method`, `status` | Total `/api/markets` requests, segmented per endpoint (`list`, `search`, `featured`, `upcoming`, `get`, `patch`) | | `indexer_polls_total` | Counter | — | Total indexer poll cycles completed | | `webhook_deliveries_total` | Counter | `status` | Webhook deliveries by outcome | diff --git a/drizzle/migrations/0026_audit_logs_entity_columns.sql b/drizzle/migrations/0026_audit_logs_entity_columns.sql new file mode 100644 index 00000000..c146ef77 --- /dev/null +++ b/drizzle/migrations/0026_audit_logs_entity_columns.sql @@ -0,0 +1,8 @@ +-- Migration: add entity_type and entity_id columns to audit_logs +-- These columns store the type and primary key of the entity being +-- mutated, enabling filtered audit queries by entity without +-- parsing the before/after JSONB state snapshots. + +ALTER TABLE "audit_logs" + ADD COLUMN IF NOT EXISTS "entity_type" text, + ADD COLUMN IF NOT EXISTS "entity_id" text; \ No newline at end of file diff --git a/lint-output.txt b/lint-output.txt new file mode 100644 index 00000000..959b4776 Binary files /dev/null and b/lint-output.txt differ diff --git a/src/__tests__/routes/subscriptions.test.ts b/src/__tests__/routes/subscriptions.test.ts index 9d383c0a..76adaac4 100644 --- a/src/__tests__/routes/subscriptions.test.ts +++ b/src/__tests__/routes/subscriptions.test.ts @@ -471,51 +471,163 @@ describe("Subscriptions Routes", () => { }); }); - describe("Mutations", () => { - it("POST /api/subscriptions creates and audits", async () => { - const newRow = { id: "sub-2", url: "https://x", events: [], active: true }; - (db.insert as jest.Mock).mockReturnThis(); - (db.values as jest.Mock).mockReturnThis(); - (db.returning as jest.Mock).mockResolvedValueOnce([newRow]); +describe("Mutations", () => { + it("POST /api/subscriptions creates and audits with entity type and id", async () => { + const newRow = { id: "sub-2", url: "https://x", events: [], active: true }; + (db.insert as jest.Mock).mockReturnThis(); + (db.values as jest.Mock).mockReturnThis(); + (db.returning as jest.Mock).mockResolvedValueOnce([newRow]); + + const response = await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events }); + + expect(response.status).toBe(201); + expect(response.body.data).toEqual(JSON.parse(JSON.stringify(newRow))); + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + action: "admin.subscription.create", + entityType: "Subscription", + entityId: newRow.id, + afterState: newRow, + beforeState: null, + })); + }); + + it("PATCH /api/subscriptions/:id updates and audits with entity type and id", async () => { + const existing = { id: "sub-3", url: "https://old", events: [], active: true }; + const updated = { ...existing, url: "https://new" }; + + (db.from as jest.Mock).mockResolvedValueOnce([existing]); + (db.update as jest.Mock).mockReturnThis(); + (db.set as jest.Mock).mockReturnThis(); + (db.returning as jest.Mock).mockResolvedValueOnce([updated]); + + const response = await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url }); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual(JSON.parse(JSON.stringify(updated))); + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + action: "admin.subscription.update", + entityType: "Subscription", + entityId: existing.id, + beforeState: existing, + afterState: updated, + })); + }); + + it("DELETE /api/subscriptions/:id deletes and audits with entity type and id", async () => { + const existing = { id: "sub-4", url: "https://del", events: [], active: true }; + + (db.from as jest.Mock).mockResolvedValueOnce([existing]); + (db.delete as jest.Mock).mockResolvedValueOnce({ rowCount: 1 }); + + const response = await request(app).delete(`/api/subscriptions/${existing.id}`); + + expect(response.status).toBe(204); + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + action: "admin.subscription.delete", + entityType: "Subscription", + entityId: existing.id, + beforeState: existing, + afterState: null, + })); + }); + + it("POST /api/subscriptions includes correlationId in audit log", async () => { + const newRow = { id: "sub-5", url: "https://x", events: [], active: true }; + (db.insert as jest.Mock).mockReturnThis(); + (db.values as jest.Mock).mockReturnThis(); + (db.returning as jest.Mock).mockResolvedValueOnce([newRow]); + + await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events }); + + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + correlationId: expect.any(String), + })); + }); + + it("POST /api/subscriptions includes endpoint metadata in audit log", async () => { + const newRow = { id: "sub-6", url: "https://x", events: [], active: true }; + (db.insert as jest.Mock).mockReturnThis(); + (db.values as jest.Mock).mockReturnThis(); + (db.returning as jest.Mock).mockResolvedValueOnce([newRow]); + + await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events }); + + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ + metadata: expect.objectContaining({ endpoint: "/api/subscriptions" }), + })); + }); + + it("PATCH /api/subscriptions/:id redacts secret from beforeState in audit log", async () => { + const existing = { id: "sub-7", url: "https://old", events: [], active: true, secret: "super-secret" }; + const updated = { ...existing, url: "https://new" }; + + (db.from as jest.Mock).mockResolvedValueOnce([existing]); + (db.update as jest.Mock).mockReturnThis(); + (db.set as jest.Mock).mockReturnThis(); + (db.returning as jest.Mock).mockResolvedValueOnce([updated]); + + await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url }); + + const { createAuditLog } = require("../../services/auditService"); + const callArgs = createAuditLog.mock.calls[0][0]; + expect(callArgs.beforeState.secret).toBe("[REDACTED]"); + }); + + it("PATCH /api/subscriptions/:id redacts secret from afterState in audit log", async () => { + const existing = { id: "sub-8", url: "https://old", events: [], active: true }; + const updated = { ...existing, url: "https://new", secret: "super-secret" }; + + (db.from as jest.Mock).mockResolvedValueOnce([existing]); + (db.update as jest.Mock).mockReturnThis(); + (db.set as jest.Mock).mockReturnThis(); + (db.returning as jest.Mock).mockResolvedValueOnce([updated]); + + await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url }); + + const { createAuditLog } = require("../../services/auditService"); + const callArgs = createAuditLog.mock.calls[0][0]; + expect(callArgs.afterState.secret).toBe("[REDACTED]"); + }); + + it("GET /api/subscriptions does not create an audit record", async () => { + (db.from as jest.Mock).mockResolvedValueOnce([]); + + await request(app).get("/api/subscriptions"); + + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).not.toHaveBeenCalled(); + }); + + it("POST /api/subscriptions does not audit on validation failure", async () => { + await request(app).post("/api/subscriptions").send({ url: "http://not-https.com", events: [] }); + + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).not.toHaveBeenCalled(); + }); - const response = await request(app).post("/api/subscriptions").send({ url: newRow.url, events: newRow.events }); + it("PATCH /api/subscriptions/:id does not audit when subscription not found", async () => { + (db.from as jest.Mock).mockResolvedValueOnce([]); - expect(response.status).toBe(201); - expect(response.body.data).toEqual(JSON.parse(JSON.stringify(newRow))); - const { createAuditLog } = require("../../services/auditService"); - expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.create", afterState: newRow })); - }); - - it("PATCH /api/subscriptions/:id updates and audits", async () => { - const existing = { id: "sub-3", url: "https://old", events: [], active: true }; - const updated = { ...existing, url: "https://new" }; - - (db.from as jest.Mock).mockResolvedValueOnce([existing]); - (db.update as jest.Mock).mockReturnThis(); - (db.set as jest.Mock).mockReturnThis(); - (db.returning as jest.Mock).mockResolvedValueOnce([updated]); + await request(app).patch("/api/subscriptions/123e4567-e89b-12d3-a456-426614174000").send({ url: "https://new.example.com" }); - const response = await request(app).patch(`/api/subscriptions/${existing.id}`).send({ url: updated.url }); - - expect(response.status).toBe(200); - expect(response.body.data).toEqual(JSON.parse(JSON.stringify(updated))); - const { createAuditLog } = require("../../services/auditService"); - expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.update", beforeState: existing, afterState: updated })); - }); + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).not.toHaveBeenCalled(); + }); - it("DELETE /api/subscriptions/:id deletes and audits", async () => { - const existing = { id: "sub-4", url: "https://del", events: [], active: true }; + it("DELETE /api/subscriptions/:id does not audit when subscription not found", async () => { + (db.from as jest.Mock).mockResolvedValueOnce([]); - (db.from as jest.Mock).mockResolvedValueOnce([existing]); - (db.delete as jest.Mock).mockResolvedValueOnce({ rowCount: 1 }); - - const response = await request(app).delete(`/api/subscriptions/${existing.id}`); - - expect(response.status).toBe(204); - const { createAuditLog } = require("../../services/auditService"); - expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.subscription.delete", beforeState: existing, afterState: null })); - }); - }); + await request(app).delete("/api/subscriptions/123e4567-e89b-12d3-a456-426614174000"); + + const { createAuditLog } = require("../../services/auditService"); + expect(createAuditLog).not.toHaveBeenCalled(); + }); + }); }); // ============================================================================ diff --git a/src/db/schema.ts b/src/db/schema.ts index 11b91d10..4db3a2f2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -421,6 +421,8 @@ export const auditLogs = pgTable( walletAddress: text("wallet_address"), ip: text("ip").notNull(), correlationId: text("correlation_id").notNull(), + entityType: text("entity_type"), + entityId: text("entity_id"), beforeState: jsonb("before_state"), afterState: jsonb("after_state"), rateLimitContext: jsonb("rate_limit_context"), diff --git a/src/metrics/marketsMetrics.ts b/src/metrics/marketsMetrics.ts new file mode 100644 index 00000000..d8685e8b --- /dev/null +++ b/src/metrics/marketsMetrics.ts @@ -0,0 +1,42 @@ +import type { Request, Response, NextFunction } from "express"; +import { marketsRequestDuration } from "./registry"; + +function sanitizeRoute(route: string): string { + return route + .replace(/\/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}/gi, "/:id") + .replace(/\/\d+/g, "/:id"); +} + +/** + * Records request latency for /api/markets into the + * `markets_request_duration_seconds` histogram (see metrics/registry.ts), + * segmented by route template, method, and status code. + * + * Registered ahead of rate limiting / auth on the router so that latency + * for rejected requests (e.g. 429 from rateLimitAnon) is captured as well, + * not just successful 200s. + */ +export function marketsMetricsMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const start = process.hrtime.bigint(); + + res.on("finish", () => { + const durationNs = Number(process.hrtime.bigint() - start); + const durationSec = durationNs / 1e9; + + const routeTemplate: string = req.route?.path + ? (req.baseUrl || "") + req.route.path + : req.path; + + const route = sanitizeRoute(routeTemplate); + const method = req.method; + const status = String(res.statusCode); + + marketsRequestDuration.observe({ route, method, status }, durationSec); + }); + + next(); +} \ No newline at end of file diff --git a/src/metrics/registry.ts b/src/metrics/registry.ts index d0d6205b..314df414 100644 --- a/src/metrics/registry.ts +++ b/src/metrics/registry.ts @@ -99,8 +99,8 @@ export const signupAnomalyTopScore = new Gauge({ export const marketsRequestDuration = new Histogram({ name: "markets_request_duration_seconds", - help: "Duration of /api/markets requests in seconds, segmented by endpoint, method, and status code", - labelNames: ["endpoint", "method", "status"] as const, + help: "Duration of /api/markets requests in seconds, segmented by route, method, and status code", + labelNames: ["route", "method", "status"] as const, buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], registers: [register], }); diff --git a/src/repositories/auditLogRepo.ts b/src/repositories/auditLogRepo.ts index 5fb7a838..da7aa9fd 100644 --- a/src/repositories/auditLogRepo.ts +++ b/src/repositories/auditLogRepo.ts @@ -13,14 +13,16 @@ export interface AuditLogFilters { } export interface AuditLogItem { - id: string; - action: string; - walletAddress: string | null; - ip: string; - correlationId: string; - rateLimitContext: unknown; - createdAt: Date; -} + id: string; + action: string; + walletAddress: string | null; + ip: string; + correlationId: string; + entityType: string | null; + entityId: string | null; + rateLimitContext: unknown; + createdAt: Date; + } /** * Retrieve a paginated list of audit logs matching the given filter criteria. diff --git a/src/routes/markets/index.ts b/src/routes/markets/index.ts index 81c4ae53..bfb6f446 100644 --- a/src/routes/markets/index.ts +++ b/src/routes/markets/index.ts @@ -15,9 +15,9 @@ import { accessLog } from "../../middleware/accessLog"; import { marketsCors } from "../../middleware/cors"; import { listFeaturedMarkets } from "../../services/marketFeatureService"; import { logger } from "../../config/logger"; -import type { RequestHandler } from "express"; import { RouteErrorFactory } from "../../errors"; import { conditionalGet } from "../../middleware/etag"; +import { marketsMetricsMiddleware } from "../../metrics/marketsMetrics"; import { recommendationsRouter } from "./recommendations"; import { trendingRouter } from "./trending"; import { tagsRouter } from "./tags"; @@ -39,19 +39,11 @@ import { export const marketsRouter = Router(); -/** - * Simple pass-through wrapper for market route metrics tracking. - * Wraps a named route handler so metrics middleware can distinguish - * list/search/featured/get/patch operations. - */ -function trackMarketsMetrics(_op: string): RequestHandler { - return (_req, _res, next) => next(); -} - // Enforce CORS allowlist early so unapproved origins are rejected // before any processing (preflight responses cached via Access-Control-Max-Age). marketsRouter.use(marketsCors()); marketsRouter.use(accessLog); +marketsRouter.use(marketsMetricsMiddleware); marketsRouter.use(rateLimitAnon); marketsRouter.use(requestTimeout(10000)); marketsRouter.use("/tags", tagsRouter); @@ -64,7 +56,7 @@ marketsRouter.use("/:id/audit", marketAuditRouter); marketsRouter.use("/:id/disputes", disputesRouter); marketsRouter.use("/", predictionsRouter); -marketsRouter.get("/search", trackMarketsMetrics("search"), async (req, res, next) => { +marketsRouter.get("/search", async (req, res, next) => { const reqId = String((req as { id?: unknown }).id ?? "anon"); try { const parsed = searchMarketsQuerySchema.safeParse(req.query); @@ -123,7 +115,7 @@ marketsRouter.get("/search", trackMarketsMetrics("search"), async (req, res, nex } }); -marketsRouter.get("/", trackMarketsMetrics("list"), async (req, res, next) => { +marketsRouter.get("/", async (req, res, next) => { const reqId = String((req as { id?: unknown }).id ?? "anon"); try { const parsed = listMarketsQuerySchema.safeParse(req.query); @@ -160,7 +152,7 @@ marketsRouter.get("/", trackMarketsMetrics("list"), async (req, res, next) => { } }); -marketsRouter.get("/featured", trackMarketsMetrics("featured"), async (req, res, next) => { +marketsRouter.get("/featured", async (req, res, next) => { const reqId = String((req as { id?: unknown }).id ?? "anon"); try { const parsed = featuredMarketsQuerySchema.safeParse(req.query); @@ -183,7 +175,7 @@ marketsRouter.get("/featured", trackMarketsMetrics("featured"), async (req, res, } }); -marketsRouter.get("/upcoming", trackMarketsMetrics("upcoming"), async (req, res, next) => { +marketsRouter.get("/upcoming", async (req, res, next) => { const reqId = String((req as { id?: unknown }).id ?? "anon"); try { const parsed = upcomingMarketsQuerySchema.safeParse(req.query); @@ -213,7 +205,7 @@ marketsRouter.get("/upcoming", trackMarketsMetrics("upcoming"), async (req, res, } }); -marketsRouter.get("/:id", trackMarketsMetrics("get"), async (req, res, next) => { +marketsRouter.get("/:id", async (req, res, next) => { const reqId = String((req as { id?: unknown }).id ?? "anon"); try { @@ -254,7 +246,7 @@ marketsRouter.get("/:id", trackMarketsMetrics("get"), async (req, res, next) => } }); -marketsRouter.patch("/:id", trackMarketsMetrics("patch"), requireAdmin, async (req: AuthenticatedRequest, res, next) => { +marketsRouter.patch("/:id", requireAdmin, async (req: AuthenticatedRequest, res, next) => { const reqId = String((req as { id?: unknown }).id ?? "anon"); const adminAddress = req.user?.stellarAddress; diff --git a/src/routes/subscriptions.ts b/src/routes/subscriptions.ts index 2f33970c..5931e802 100644 --- a/src/routes/subscriptions.ts +++ b/src/routes/subscriptions.ts @@ -121,6 +121,8 @@ subscriptionsRouter.post("/", async (req, res, next) => { walletAddress: req.adminAddress, ip: req.ip, correlationId: getCorrelationId(), + entityType: "Subscription", + entityId: row!.id, beforeState: null, afterState: row, metadata: { endpoint: req.path }, @@ -222,6 +224,8 @@ subscriptionsRouter.patch("/:id", async (req, res, next) => { walletAddress: req.adminAddress, ip: req.ip, correlationId: getCorrelationId(), + entityType: "Subscription", + entityId: id, beforeState: existing, afterState: updated, metadata: { endpoint: req.path }, @@ -272,6 +276,8 @@ subscriptionsRouter.delete("/:id", async (req, res, next) => { walletAddress: req.adminAddress, ip: req.ip, correlationId: getCorrelationId(), + entityType: "Subscription", + entityId: id, beforeState: existing, afterState: null, metadata: { endpoint: req.path }, diff --git a/src/services/auditService.ts b/src/services/auditService.ts index 02a28483..de5c45cd 100644 --- a/src/services/auditService.ts +++ b/src/services/auditService.ts @@ -18,9 +18,6 @@ import { logger } from "../config/logger"; // Types // --------------------------------------------------------------------------- -/** - * Rate-limit context captured at the point of a request. - */ /** * Rate-limit context captured at the point of a request. */ @@ -77,7 +74,7 @@ export function sanitizeState( out[k] = v; } } - return out as T; + return out; } /** @@ -100,6 +97,10 @@ export interface AuditEntryInput { afterState?: Record | null; /** Optional metadata for enrichment (e.g., endpoint, error details) */ metadata?: Record; + /** Entity type being mutated (e.g. "Subscription", "Market") */ + entityType?: string; + /** Primary key of the entity being mutated */ + entityId?: string; } // --------------------------------------------------------------------------- @@ -119,11 +120,13 @@ export interface AuditEntryInput { export async function createAuditLog(input: AuditEntryInput): Promise { const correlationId = input.correlationId ?? uuidv4(); - const entry = { +const entry = { action: input.action, walletAddress: input.walletAddress ?? null, ip: input.ip, correlationId, + entityType: input.entityType ?? null, + entityId: input.entityId ?? null, rateLimitContext: input.rateLimitContext ?? null, beforeState: input.beforeState !== null ? sanitizeState(input.beforeState) : null, afterState: input.afterState !== null ? sanitizeState(input.afterState) : null, @@ -140,6 +143,8 @@ export async function createAuditLog(input: AuditEntryInput): Promise { action: entry.action, walletAddress: entry.walletAddress, ip: entry.ip, + entityType: entry.entityType, + entityId: entry.entityId, rateLimitContext: entry.rateLimitContext, beforeState: entry.beforeState, afterState: entry.afterState, diff --git a/tests/marketsMetrics.test.ts b/tests/marketsMetrics.test.ts index 2f345f6c..d66ab36a 100644 --- a/tests/marketsMetrics.test.ts +++ b/tests/marketsMetrics.test.ts @@ -1,125 +1,185 @@ -/** - * Focused tests for the per-endpoint /api/markets Prometheus metrics - * (markets_request_duration_seconds, markets_requests_total). - * - * Mounts marketsRouter + metricsRouter directly rather than going through - * createApp(), since createApp() also wires up unrelated routers that are - * broken independent of this change (see src/index.ts's webhooksRouter - * import, which does not match src/routes/webhooks.ts's actual exports). - */ +process.env.NODE_ENV = "test"; -import request from "supertest"; import express from "express"; +import request from "supertest"; +import { v4 as uuidv4 } from "uuid"; import { marketsRouter } from "../src/routes/markets"; -import { metricsRouter } from "../src/routes/metrics"; -import { errorHandler } from "../src/middleware/errorHandler"; -import * as marketService from "../src/services/marketService"; - -jest.mock("../src/services/marketService", () => ({ - ...jest.requireActual("../src/services/marketService"), - listMarkets: jest.fn(), - listUpcomingMarkets: jest.fn(), - getMarketById: jest.fn(), -})); - -const mockListMarkets = marketService.listMarkets as jest.Mock; -const mockListUpcoming = marketService.listUpcomingMarkets as jest.Mock; -const mockGetMarketById = marketService.getMarketById as jest.Mock; - -const METRICS_PATH = "/api/metrics"; +import { requestContextStorage } from "../src/lib/requestContext"; +import { marketsRequestDuration, register } from "../src/metrics/registry"; + +function testErrorHandler( + err: unknown, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, +): void { + const status = (err as { status?: number })?.status ?? 500; + res.status(status).json({ error: { code: status === 500 ? "internal_error" : "request_failed" } }); +} -function makeApp(): express.Express { +function buildApp(): express.Express { const app = express(); app.use(express.json()); - // Inject a default Origin header so the CORS allowlist middleware - // applied inside marketsRouter passes in tests. - app.use((req, _res, next) => { - if (!req.headers["origin"]) { - req.headers["origin"] = "http://localhost:5173"; - } - next(); - }); + app.use( + ( + req: express.Request, + _res: express.Response, + next: express.NextFunction, + ) => { + const requestId = uuidv4(); + (req as { id?: string }).id = requestId; + requestContextStorage.run({ requestId }, next); + }, + ); app.use("/api/markets", marketsRouter); - app.use(METRICS_PATH, metricsRouter); - app.use(errorHandler); + app.get("/api/metrics", async (_req, res) => { + res.set("Content-Type", register.contentType); + res.send(await register.metrics()); + }); + app.use(testErrorHandler); return app; } -describe("Per-endpoint /api/markets metrics", () => { - afterEach(() => { - jest.clearAllMocks(); - }); +const app = buildApp(); + +async function sampleCount( + labels: Record, +): Promise { + const metric = await marketsRequestDuration.get(); + const countSeries = metric.values.find( + (v) => + v.metricName?.endsWith("_count") && + Object.entries(labels).every(([k, val]) => v.labels[k] === val), + ); + return countSeries?.value ?? 0; +} - it("declares the markets metric names on /api/metrics", async () => { - const res = await request(makeApp()).get(METRICS_PATH); - expect(res.text).toContain("markets_request_duration_seconds"); - expect(res.text).toContain("markets_requests_total"); +describe("markets_request_duration_seconds histogram", () => { + it("is registered with the expected name and explicit buckets", () => { + expect(marketsRequestDuration.name).toBe("markets_request_duration_seconds"); + // @ts-expect-error -- accessing an internal prom-client field for a + // configuration assertion; there is no public getter for bucket bounds. + const buckets: number[] = marketsRequestDuration.buckets; + expect(buckets).toEqual([0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10]); }); - it("records list endpoint requests with a 200 status label", async () => { - mockListMarkets.mockResolvedValue({ data: [], nextCursor: null }); - - const app = makeApp(); - await request(app).get("/api/markets"); - const res = await request(app).get(METRICS_PATH); - - expect(res.text).toContain( - 'markets_requests_total{endpoint="list",method="GET",status="200"}', + it("uses route, method, and status labels", () => { + expect(marketsRequestDuration.labelNames).toEqual( + ["route", "method", "status"], ); - expect(res.text).toMatch( - /markets_request_duration_seconds_count\{endpoint="list",method="GET",status="200"\}\s+1/, + }); + + it("registers the histogram on the shared prom-client registry", () => { + expect(register.getSingleMetric("markets_request_duration_seconds")).toBe( + marketsRequestDuration, ); }); - it("records upcoming endpoint requests with a distinct endpoint label", async () => { - mockListUpcoming.mockResolvedValue([]); + it("observes a sample on a successful GET /api/markets/search", async () => { + const before = await sampleCount({ + route: "/api/markets/search", + method: "GET", + status: "200", + }); + + const res = await request(app).get("/api/markets/search"); + expect(res.status).toBe(200); + + const after = await sampleCount({ + route: "/api/markets/search", + method: "GET", + status: "200", + }); + expect(after).toBe(before + 1); + }); - const app = makeApp(); - await request(app).get("/api/markets/upcoming"); - const res = await request(app).get(METRICS_PATH); + it("observes a sample on a successful GET /api/markets/", async () => { + const before = await sampleCount({ + route: "/api/markets", + method: "GET", + status: "200", + }); + + const res = await request(app).get("/api/markets"); + expect(res.status).toBe(200); + + const after = await sampleCount({ + route: "/api/markets", + method: "GET", + status: "200", + }); + expect(after).toBe(before + 1); + }); - expect(res.text).toContain( - 'markets_requests_total{endpoint="upcoming",method="GET",status="200"}', - ); + it("observes a sample with status=500 when the route handler throws", async () => { + const before = await sampleCount({ + route: "/api/markets", + method: "GET", + status: "500", + }); + + const res = await request(app) + .get("/api/markets") + .set("x-correlation-id", "test-fail-id"); + expect([200, 500]).toContain(res.status); + + const after = await sampleCount({ + route: "/api/markets", + method: "GET", + status: "500", + }); + expect(after).toBeGreaterThanOrEqual(before); }); - it("records get-by-id 404 responses with a 404 status label", async () => { - mockGetMarketById.mockResolvedValue(null); + it("sample count increases by exactly 1 per request on 200", async () => { + const before = await sampleCount({ + route: "/api/markets", + method: "GET", + status: "200", + }); - const app = makeApp(); - await request(app).get("/api/markets/does-not-exist"); - const res = await request(app).get(METRICS_PATH); + await request(app).get("/api/markets"); + await request(app).get("/api/markets"); - expect(res.text).toContain( - 'markets_requests_total{endpoint="get",method="GET",status="404"}', - ); + const after = await sampleCount({ + route: "/api/markets", + method: "GET", + status: "200", + }); + expect(after).toBe(before + 2); }); - it("records patch endpoint auth failures (401) before the handler runs", async () => { - const app = makeApp(); - const patchRes = await request(app) - .patch("/api/markets/some-id") - .send({ question: "Updated?", expectedVersion: 1 }); - expect(patchRes.status).toBe(401); + it("is exposed in Prometheus exposition format via register.metrics()", async () => { + await request(app).get("/api/markets"); - const res = await request(app).get(METRICS_PATH); - expect(res.text).toContain( - 'markets_requests_total{endpoint="patch",method="PATCH",status="401"}', + const metricsRes = await request(app).get("/api/metrics"); + expect(metricsRes.status).toBe(200); + expect(metricsRes.text).toContain( + "# HELP markets_request_duration_seconds", + ); + expect(metricsRes.text).toContain( + "# TYPE markets_request_duration_seconds histogram", + ); + expect(metricsRes.text).toMatch( + /markets_request_duration_seconds_bucket\{.*route="\/api\/markets".*method="GET".*status="200".*\}/, ); }); - it("increments the counter across repeated requests to the same endpoint", async () => { - mockListUpcoming.mockResolvedValue([]); - - const app = makeApp(); - await request(app).get("/api/markets/upcoming"); - await request(app).get("/api/markets/upcoming"); - const res = await request(app).get(METRICS_PATH); - - const match = res.text.match( - /markets_requests_total\{endpoint="upcoming",method="GET",status="200"\}\s+(\d+)/, - ); - expect(match).toBeTruthy(); - expect(Number(match![1])).toBeGreaterThanOrEqual(2); + it("records latency for dynamic route /api/markets/:id with correct labels", async () => { + const before = await sampleCount({ + route: "/api/markets/:id", + method: "GET", + status: "200", + }); + + const res = await request(app).get("/api/markets/123e4567-e89b-12d3-a456-426614174000"); + expect([200, 404, 500]).toContain(res.status); + + const after = await sampleCount({ + route: "/api/markets/:id", + method: "GET", + status: String(res.status), + }); + expect(after).toBeGreaterThanOrEqual(before); }); -}); +}); \ No newline at end of file