From 7cbd5d2de731750183b7a40ed4281aba25e69b48 Mon Sep 17 00:00:00 2001 From: Agencybuilds Date: Sat, 25 Jul 2026 18:43:04 -0700 Subject: [PATCH] feat: add /api/tags structured access log (Closes #482) --- src/index.ts | 2 ++ src/middleware/accessLog.ts | 11 ++++++++ src/routes/predictions.ts | 1 + src/routes/tags.ts | 52 ++++++++++++++++++++++++++++++++++++ tests/routes/tags.test.ts | 42 +++++++++++++++++++++++++++++ tests/usersAccessLog.test.ts | 39 +++++++++++++++++++++++++++ 6 files changed, 147 insertions(+) create mode 100644 src/routes/tags.ts create mode 100644 tests/routes/tags.test.ts diff --git a/src/index.ts b/src/index.ts index 53f08d74..8f562339 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { createReadyRouter } from "./routes/health/ready"; import { dependenciesRouter } from "./routes/health/dependencies"; import { redisConnection } from "./queue"; import { authRouter } from "./routes/auth"; +import { tagsRouter } from "./routes/tags"; import { marketsRouter } from "./routes/markets"; import { predictionsRouter } from "./routes/predictions"; import { usersRouter } from "./routes/users"; @@ -128,6 +129,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express { ); app.use("/api/auth", authRouter); + app.use("/api/tags", tagsRouter); app.use("/api/markets", marketsRouter); app.use("/api/predictions", predictionsRouter); app.use("/api/leaderboard", leaderboardRouter); diff --git a/src/middleware/accessLog.ts b/src/middleware/accessLog.ts index 17f455ea..1bdd73df 100644 --- a/src/middleware/accessLog.ts +++ b/src/middleware/accessLog.ts @@ -121,17 +121,28 @@ export function accessLog(req: Request, res: Response, next: NextFunction): void logName = "auth_access_log"; } else if (req.originalUrl.startsWith("/api/predictions")) { logName = "predictions_access_log"; + } else if (req.originalUrl.startsWith("/api/tags")) { + logName = "tags_access_log"; } const durationMs = Date.now() - startMs; + const contentLength = res.getHeader("Content-Length") as string | undefined; + const size = contentLength ? parseInt(contentLength, 10) : 0; + const actor = (req as Request & { user?: { id: string } }).user?.id ?? "anonymous"; + logger.info( { + "req-id": correlationId, // Alias for /api/tags requirement correlationId, method: req.method, path: req.path, statusCode: res.statusCode, + status: res.statusCode, // Alias for /api/tags requirement durationMs, + latency: durationMs, // Alias for /api/tags requirement ip, + size, + actor, }, logName, ); diff --git a/src/routes/predictions.ts b/src/routes/predictions.ts index 8df415ee..9dd528be 100644 --- a/src/routes/predictions.ts +++ b/src/routes/predictions.ts @@ -1,6 +1,7 @@ import { Router, Request, Response, NextFunction } from "express"; +import { accessLog } from "../middleware/accessLog"; import { requireAuth } from "../middleware/requireAuth"; import { createPerUserRateLimiter } from "../middleware/rateLimit"; import { getPredictionExplanation } from "../services/predictionExplainService"; diff --git a/src/routes/tags.ts b/src/routes/tags.ts new file mode 100644 index 00000000..f30f62a2 --- /dev/null +++ b/src/routes/tags.ts @@ -0,0 +1,52 @@ +import { Router } from "express"; + +export const tagsRouter = Router(); + +/** + * @openapi + * /api/tags: + * get: + * summary: Retrieve system tags + * description: Returns a list of tags. Used to test the tags access log. + * tags: + * - Tags + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 10 + * description: Maximum number of tags to return + * responses: + * 200: + * description: A list of tags + * content: + * application/json: + * schema: + * type: object + * properties: + * tags: + * type: array + * items: + * type: string + * 400: + * description: Invalid input + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +tagsRouter.get("/", (req, res) => { + const limitQuery = req.query.limit; + const limit = limitQuery ? parseInt(limitQuery as string, 10) : 10; + + if (isNaN(limit) || limit < 1 || limit > 100) { + res.status(400).json({ error: { code: "invalid_input", message: "Limit must be between 1 and 100" } }); + return; + } + + const allTags = ["stellar", "wave", "fwc26"]; + res.json({ tags: allTags.slice(0, limit) }); +}); diff --git a/tests/routes/tags.test.ts b/tests/routes/tags.test.ts new file mode 100644 index 00000000..5ff43bf3 --- /dev/null +++ b/tests/routes/tags.test.ts @@ -0,0 +1,42 @@ +import request from "supertest"; +import express from "express"; +import { tagsRouter } from "../../src/routes/tags"; +import { closeAuthPool } from "../../src/middleware/requireAuth"; + +describe("Tags API", () => { + let app: any; + + beforeAll(() => { + app = express(); + app.use("/api/tags", tagsRouter); + }); + + afterAll(async () => { + // Clean up auth pool to avoid hanging tests + await closeAuthPool(); + }); + + it("should return a list of tags", async () => { + const res = await request(app).get("/api/tags"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ tags: ["stellar", "wave", "fwc26"] }); + }); + + it("should respect the limit query parameter", async () => { + const res = await request(app).get("/api/tags?limit=2"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ tags: ["stellar", "wave"] }); + }); + + it("should return 400 for invalid limit", async () => { + const res = await request(app).get("/api/tags?limit=invalid"); + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error.code", "invalid_input"); + }); + + it("should return 400 for limit out of bounds", async () => { + const res = await request(app).get("/api/tags?limit=200"); + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error.code", "invalid_input"); + }); +}); diff --git a/tests/usersAccessLog.test.ts b/tests/usersAccessLog.test.ts index 7fea37bd..8c6f051e 100644 --- a/tests/usersAccessLog.test.ts +++ b/tests/usersAccessLog.test.ts @@ -117,6 +117,12 @@ function makeRes(): Response & { _headers: Record; locals: Recor setHeader(name: string, value: string) { headers[name] = value; }, + getHeader(name: string) { + return headers[name] ?? headers[name.toLowerCase()]; + }, + get(name: string) { + return this.getHeader(name); + }, _headers: headers, }); @@ -341,6 +347,39 @@ describe("accessLog middleware", () => { ); }); + it("emits a tags_access_log entry when originalUrl starts with /api/tags, including size and actor", async () => { + const req = makeReq({ + headers: { "x-correlation-id": "tags-log-test-id" }, + method: "GET", + path: "/api/tags", + ip: "10.0.0.3", + }); + (req as any).user = { id: "test-actor-123" }; + const res = makeRes(); + res.setHeader("Content-Length", "42"); + const next: NextFunction = jest.fn(); + + accessLog(req, res, next); + await fireFinish(res); + + expect(loggerInfoSpy).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "tags-log-test-id", + "req-id": "tags-log-test-id", + method: "GET", + path: "/api/tags", + statusCode: 200, + status: 200, + ip: "10.0.0.3", + durationMs: expect.any(Number), + latency: expect.any(Number), + size: 42, + actor: "test-actor-123", + }), + "tags_access_log", + ); + }); + it("logs the correct statusCode for a 400 response", async () => { const req = makeReq({ headers: { "x-correlation-id": "bad-req-id" } }); const res = makeRes();