Skip to content

Commit de84566

Browse files
authored
Merge pull request #323 from shoaib050326/codex/issue-165-request-logging
backend: add request logging interceptor for method/url/status/duration
2 parents 88d0563 + d67c683 commit de84566

3 files changed

Lines changed: 126 additions & 0 deletions

File tree

backend/src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import multipart from "@fastify/multipart";
77
import { AppModule } from "./app.module";
88
import { configureSecurity } from "./bootstrap";
99
import { MAX_UPLOAD_BYTES } from "./config/upload.config";
10+
import { RequestLoggingInterceptor } from "./middleware/request-logging.interceptor";
1011

1112
async function bootstrap() {
1213
const app = await NestFactory.create<NestFastifyApplication>(
@@ -24,6 +25,8 @@ async function bootstrap() {
2425
},
2526
});
2627

28+
app.useGlobalInterceptors(new RequestLoggingInterceptor());
29+
2730
await app.listen(process.env.PORT ?? 3001, "0.0.0.0");
2831
}
2932
bootstrap();
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
CallHandler,
3+
ExecutionContext,
4+
Logger,
5+
} from '@nestjs/common';
6+
import { lastValueFrom, of } from 'rxjs';
7+
import { RequestLoggingInterceptor } from './request-logging.interceptor';
8+
9+
function createExecutionContext(
10+
request: Record<string, unknown>,
11+
response: Record<string, unknown>,
12+
): ExecutionContext {
13+
return {
14+
switchToHttp: () => ({
15+
getRequest: () => request,
16+
getResponse: () => response,
17+
}),
18+
} as ExecutionContext;
19+
}
20+
21+
describe('RequestLoggingInterceptor', () => {
22+
afterEach(() => {
23+
jest.restoreAllMocks();
24+
});
25+
26+
it('logs method, url, status code, and duration', async () => {
27+
const interceptor = new RequestLoggingInterceptor();
28+
const context = createExecutionContext(
29+
{ method: 'GET', url: '/health' },
30+
{ statusCode: 200 },
31+
);
32+
const next: CallHandler = { handle: () => of('ok') };
33+
34+
jest.spyOn(Date, 'now').mockReturnValueOnce(100).mockReturnValueOnce(115);
35+
const logSpy = jest
36+
.spyOn(Logger.prototype, 'log')
37+
.mockImplementation(() => undefined);
38+
39+
await expect(lastValueFrom(interceptor.intercept(context, next))).resolves.toBe(
40+
'ok',
41+
);
42+
43+
expect(logSpy).toHaveBeenCalledWith('GET /health 200 15ms');
44+
});
45+
46+
it('does not log sensitive request data', async () => {
47+
const interceptor = new RequestLoggingInterceptor();
48+
const context = createExecutionContext(
49+
{
50+
method: 'POST',
51+
url: '/auth/verify?token=sensitive-query-token',
52+
headers: { authorization: 'Bearer secret-token' },
53+
body: { password: 'super-secret', email: 'user@example.com' },
54+
query: { token: 'sensitive-query-token' },
55+
},
56+
{ statusCode: 401 },
57+
);
58+
const next: CallHandler = { handle: () => of('denied') };
59+
60+
jest.spyOn(Date, 'now').mockReturnValueOnce(200).mockReturnValueOnce(240);
61+
const logSpy = jest
62+
.spyOn(Logger.prototype, 'log')
63+
.mockImplementation(() => undefined);
64+
65+
await lastValueFrom(interceptor.intercept(context, next));
66+
67+
const [message] = logSpy.mock.calls.at(-1) ?? [];
68+
69+
expect(message).toBe('POST /auth/verify 401 40ms');
70+
expect(message).not.toContain('secret-token');
71+
expect(message).not.toContain('super-secret');
72+
expect(message).not.toContain('user@example.com');
73+
expect(message).not.toContain('sensitive-query-token');
74+
});
75+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
CallHandler,
3+
ExecutionContext,
4+
Injectable,
5+
Logger,
6+
NestInterceptor,
7+
} from '@nestjs/common';
8+
import { Observable, finalize } from 'rxjs';
9+
10+
interface HttpRequestLike {
11+
method?: string;
12+
originalUrl?: string;
13+
url?: string;
14+
}
15+
16+
interface HttpResponseLike {
17+
statusCode?: number;
18+
}
19+
20+
@Injectable()
21+
export class RequestLoggingInterceptor implements NestInterceptor {
22+
private readonly logger = new Logger(RequestLoggingInterceptor.name);
23+
24+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
25+
const request = context.switchToHttp().getRequest<HttpRequestLike>();
26+
const response = context.switchToHttp().getResponse<HttpResponseLike>();
27+
const startedAt = Date.now();
28+
29+
return next.handle().pipe(
30+
finalize(() => {
31+
const method = request.method ?? 'UNKNOWN';
32+
const url = this.sanitizeUrl(request.originalUrl ?? request.url);
33+
const statusCode = response.statusCode ?? 500;
34+
const durationMs = Date.now() - startedAt;
35+
36+
this.logger.log(`${method} ${url} ${statusCode} ${durationMs}ms`);
37+
}),
38+
);
39+
}
40+
41+
private sanitizeUrl(url: string | undefined): string {
42+
if (!url) {
43+
return 'UNKNOWN';
44+
}
45+
46+
return url.split('?')[0] || '/';
47+
}
48+
}

0 commit comments

Comments
 (0)