Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"passport-local": "^1.0.0",
"passport-magic-link": "^2.1.1",
"passport-strategy": "^1.0.0",
"pino-http": "^11.0.0",
"prisma": "6.4.1",
"short-uuid": "^5.2.0",
"zod": "^4.0.14"
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { generateHandle } from "./utils/names";
import { codeRouter } from "./routes/code";
import { metricsRouter } from "./routes/metricsRoutes";
import { contentRouter } from "./routes/content.route";
import { initRequestLogger } from "./middleware/requestLogger";

// Type assertion to work around passport type declaration issues
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -394,6 +395,7 @@ app.use(

app.use(passport.initialize());
app.use(passport.session());
app.use("/api", initRequestLogger());

const port = process.env.PORT || 3000;

Expand Down
88 changes: 88 additions & 0 deletions apps/api/src/middleware/requestLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { NextFunction, Request, Response } from "express";
import pinoHttp from "pino-http";

type LoggableObject = Record<string, unknown>;
type RequestLoggerOptions = {
stream?: NodeJS.WritableStream;
};

/**
* Initialize the pino-http middleware for logging API requests.
*
* The middleware logs two events per API call
* 1) a start event as soon as a request enters the API (level: debug)
* 2) a completion event when the response ends (level: info)
*
* Each log entry includes only the metadata fields we want. No sensitive data.
*/
export function initRequestLogger(options: RequestLoggerOptions = {}) {
const httpLogger = pinoHttp({
customErrorMessage() {
return "API request completed";
Comment thread
cqnykamp marked this conversation as resolved.
Outdated
},
customErrorObject(req, res, _err, loggableObject) {
return getRequestEndObject(
req as Request,
res as Response,
loggableObject as LoggableObject,
);
},
customSuccessMessage() {
return "API request completed";
},
customSuccessObject(req, res, loggableObject) {
return getRequestEndObject(
req as Request,
res as Response,
loggableObject as LoggableObject,
);
},
level: "debug",
quietReqLogger: true,
stream: options.stream,
useLevel: "info",
});

return (req: Request, res: Response, next: NextFunction) => {
httpLogger(req, res);
req.log.debug(
{
...getRequestMetadata(req),
event: "request_start",
},
"API request started",
);
next();
};
}

/**
* Pulls the safe request fields that we want to include in every lifecycle log.
*/
function getRequestMetadata(req: Request) {
return {
anonymous: req.user?.isAnonymous ?? false,
authenticated: req.user !== undefined,
method: req.method,
path: req.originalUrl || req.url,
Comment thread
cqnykamp marked this conversation as resolved.
Outdated
};
}

function getDurationMs(loggableObject: LoggableObject) {
const responseTime = loggableObject.responseTime;

return typeof responseTime === "number" ? responseTime : undefined;
}

function getRequestEndObject(
req: Request,
res: Response,
loggableObject: LoggableObject,
) {
return {
...getRequestMetadata(req),
durationMs: getDurationMs(loggableObject),
event: "request_end",
statusCode: res.statusCode,
};
}
107 changes: 107 additions & 0 deletions apps/api/src/test/requestLogger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { EventEmitter } from "node:events";
import { Writable } from "node:stream";
import type { Request, Response } from "express";
import { describe, expect, test } from "vitest";
import { initRequestLogger } from "../middleware/requestLogger";

class MockResponse extends EventEmitter {
statusCode = 200;
writableEnded = false;
}

function createMockRequest(overrides: Partial<Request> = {}) {
return {
method: "GET",
originalUrl: "/api/health",
url: "/api/health",
...overrides,
} as Request;
}

function createLogCapture() {
const lines: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
lines.push(chunk.toString());
callback();
},
});

return { lines, stream };
}

function parseLogs(lines: string[]) {
return lines.map((line) => JSON.parse(line));
}

describe("initRequestLogger", () => {
test("logs request start at debug and request end at info", () => {
const { lines, stream } = createLogCapture();
const middleware = initRequestLogger({ stream });
const req = createMockRequest();
const mockResponse = new MockResponse();
const res = mockResponse as unknown as Response;

middleware(req, res, () => {});

mockResponse.statusCode = 204;
mockResponse.writableEnded = true;
mockResponse.emit("finish");

const [startLog, endLog] = parseLogs(lines);

expect(startLog).toMatchObject({
anonymous: false,
authenticated: false,
event: "request_start",
level: 20,
method: "GET",
msg: "API request started",
path: "/api/health",
});
expect(endLog).toMatchObject({
event: "request_end",
level: 30,
msg: "API request completed",
statusCode: 204,
});
expect(endLog.durationMs).toBeGreaterThanOrEqual(0);
});

test("includes safe authenticated request metadata in both lifecycle logs", () => {
const { lines, stream } = createLogCapture();
const middleware = initRequestLogger({ stream });
const req = createMockRequest({
method: "POST",
originalUrl: "/api/login/anonymous",
url: "/api/login/anonymous",
user: { isAnonymous: true } as Request["user"],
});
const mockResponse = new MockResponse();
const res = mockResponse as unknown as Response;

middleware(req, res, () => {});

mockResponse.statusCode = 200;
mockResponse.writableEnded = true;
mockResponse.emit("finish");

const [startLog, endLog] = parseLogs(lines);

expect(startLog).toMatchObject({
anonymous: true,
authenticated: true,
event: "request_start",
method: "POST",
path: "/api/login/anonymous",
});
expect(endLog).toMatchObject({
anonymous: true,
authenticated: true,
event: "request_end",
method: "POST",
path: "/api/login/anonymous",
statusCode: 200,
});
});
});
Loading
Loading