Skip to content

Commit 316ff5f

Browse files
authored
Merge pull request #443 from Dayz-tech-co/feat/238-request-logger
feat(server): add request logging middleware
2 parents 4c551b7 + 099416f commit 316ff5f

File tree

4 files changed

+143
-3
lines changed

4 files changed

+143
-3
lines changed

server/src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { initDb } from "./db/index"
1515
import { createNonceStore } from "./db/nonce-store"
1616
import { errorHandler } from "./middleware/error.middleware"
1717
import { globalLimiter } from "./middleware/rate-limit.middleware"
18+
import { requestLogger } from "./middleware/request-logger.middleware"
1819
import { buildOpenApiSpec } from "./openapi"
1920
import { adminMilestonesRouter } from "./routes/admin-milestones.routes"
2021
import { adminRouter } from "./routes/admin.routes"
@@ -93,8 +94,11 @@ if (!jwtPrivateKey || !jwtPublicKey) {
9394
jwtPublicKey = ephemeral.publicKeyPem
9495
}
9596

96-
process.env.JWT_PRIVATE_KEY = jwtPrivateKey
97-
process.env.JWT_PUBLIC_KEY = jwtPublicKey
97+
if (!jwtPrivateKey || !jwtPublicKey) {
98+
throw new Error(
99+
"JWT_PRIVATE_KEY and JWT_PUBLIC_KEY must be configured to start the server",
100+
)
101+
}
98102

99103
const nonceStore = createNonceStore(env.REDIS_URL)
100104
const jwtService = createJwtService(jwtPrivateKey, jwtPublicKey)
@@ -105,7 +109,7 @@ const openApiSpec = buildOpenApiSpec()
105109
const openApiYaml = YAML.stringify(openApiSpec)
106110

107111
app.set("trust proxy", 1)
108-
app.use(morgan("dev"))
112+
app.use(requestLogger)
109113
app.use(
110114
cors({
111115
origin: (origin, callback) => {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { randomUUID } from "crypto"
2+
import type { NextFunction, Request, Response } from "express"
3+
4+
type LogPayload = {
5+
requestId: string
6+
method: string
7+
path: string
8+
statusCode: number
9+
durationMs: number
10+
}
11+
12+
type Logger = {
13+
info: (payload: LogPayload) => void
14+
}
15+
16+
type RequestLoggerOptions = {
17+
logger?: Logger
18+
enabled?: boolean
19+
}
20+
21+
const jsonLogger: Logger = {
22+
info(payload) {
23+
process.stdout.write(`${JSON.stringify(payload)}\n`)
24+
},
25+
}
26+
27+
export function createRequestLogger(
28+
options: RequestLoggerOptions = {},
29+
) {
30+
const enabled = options.enabled ?? process.env.NODE_ENV !== "test"
31+
const logger = options.logger ?? jsonLogger
32+
33+
return function requestLogger(
34+
req: Request,
35+
res: Response,
36+
next: NextFunction,
37+
) {
38+
const requestId = randomUUID()
39+
const startedAt = process.hrtime.bigint()
40+
41+
req.requestId = requestId
42+
res.setHeader("X-Request-Id", requestId)
43+
44+
res.on("finish", () => {
45+
if (!enabled) {
46+
return
47+
}
48+
49+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
50+
51+
logger.info({
52+
requestId,
53+
method: req.method,
54+
path: req.originalUrl || req.path,
55+
statusCode: res.statusCode,
56+
durationMs: Number(durationMs.toFixed(3)),
57+
})
58+
})
59+
60+
next()
61+
}
62+
}
63+
64+
export const requestLogger = createRequestLogger()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import express from "express"
2+
import request from "supertest"
3+
4+
import {
5+
createRequestLogger,
6+
} from "../middleware/request-logger.middleware"
7+
8+
describe("requestLogger middleware", () => {
9+
it("attaches a request id header to every response", async () => {
10+
const app = express()
11+
12+
app.use(createRequestLogger({ enabled: false }))
13+
app.get("/api/ping", (req, res) => {
14+
res.json({ requestId: req.requestId })
15+
})
16+
17+
const response = await request(app).get("/api/ping?source=test")
18+
19+
expect(response.status).toBe(200)
20+
expect(response.headers["x-request-id"]).toMatch(
21+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
22+
)
23+
expect(response.body.requestId).toBe(response.headers["x-request-id"])
24+
})
25+
26+
it("logs structured request data on response finish", async () => {
27+
const info = jest.fn()
28+
const app = express()
29+
30+
app.use(createRequestLogger({ enabled: true, logger: { info } }))
31+
app.get("/api/users", (_req, res) => {
32+
res.status(201).json({ ok: true })
33+
})
34+
35+
const response = await request(app).get("/api/users?page=1")
36+
37+
expect(response.status).toBe(201)
38+
expect(info).toHaveBeenCalledTimes(1)
39+
expect(info).toHaveBeenCalledWith(
40+
expect.objectContaining({
41+
requestId: response.headers["x-request-id"],
42+
method: "GET",
43+
path: "/api/users?page=1",
44+
statusCode: 201,
45+
durationMs: expect.any(Number),
46+
}),
47+
)
48+
})
49+
50+
it("stays silent in the test environment by default", async () => {
51+
const previousNodeEnv = process.env.NODE_ENV
52+
process.env.NODE_ENV = "test"
53+
54+
const info = jest.fn()
55+
const app = express()
56+
57+
app.use(createRequestLogger({ logger: { info } }))
58+
app.get("/api/quiet", (_req, res) => {
59+
res.sendStatus(204)
60+
})
61+
62+
const response = await request(app).get("/api/quiet")
63+
64+
expect(response.status).toBe(204)
65+
expect(response.headers["x-request-id"]).toBeTruthy()
66+
expect(info).not.toHaveBeenCalled()
67+
68+
process.env.NODE_ENV = previousNodeEnv
69+
})
70+
})

server/src/types/express.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
declare global {
22
namespace Express {
33
interface Request {
4+
/** Correlation ID attached to each request */
5+
requestId?: string
46
/** Stellar public key (G...) after JWT verification */
57
walletAddress?: string
68
}

0 commit comments

Comments
 (0)