-
Notifications
You must be signed in to change notification settings - Fork 5
Better logging of requests / reasons for 400 errors to help LPU #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,21 +6,23 @@ import { | |
| HttpStatus, | ||
| Logger, | ||
| } from '@nestjs/common'; | ||
| import { Response } from 'express'; | ||
| import { Request, Response } from 'express'; | ||
| import { ValidationError } from 'class-validator'; | ||
| import { sanitizeForLogging } from '../util/log-sanitizer.util'; | ||
|
|
||
| @Catch(HttpException) | ||
| export class ValidationExceptionFilter implements ExceptionFilter { | ||
| private readonly logger = new Logger(ValidationExceptionFilter.name); | ||
|
|
||
| catch(exception: HttpException, host: ArgumentsHost) { | ||
| const ctx = host.switchToHttp(); | ||
| const request = ctx.getRequest<Request>(); | ||
| const response = ctx.getResponse<Response>(); | ||
| const status = exception.getStatus(); | ||
| const exceptionResponse = exception.getResponse(); | ||
|
|
||
| let message = exception.message; | ||
| let errors: any = null; | ||
| let errors: Record<string, string> | null = null; | ||
|
|
||
| // Handle class-validator ValidationPipe errors specifically | ||
| if ( | ||
|
|
@@ -57,17 +59,36 @@ export class ValidationExceptionFilter implements ExceptionFilter { | |
| message = (exceptionResponse as { message?: string }).message || message; | ||
| } | ||
|
|
||
| if (status === HttpStatus.BAD_REQUEST) { | ||
| const reason = this.extractBadRequestReason( | ||
| exceptionResponse, | ||
| message, | ||
| errors, | ||
| ); | ||
| this.logger.warn( | ||
| `400 Bad Request: ${request.method} ${request.originalUrl} - Reason: ${reason}`, | ||
| ); | ||
|
|
||
| if (this.isCreateUserRequest(request.method, request.originalUrl)) { | ||
| this.logger.warn( | ||
| `400 Request Body: ${JSON.stringify(sanitizeForLogging(request.body))}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| response.status(status).json({ | ||
| statusCode: status, | ||
| message: message, | ||
| errors: errors, // Include formatted validation errors if present | ||
| timestamp: new Date().toISOString(), | ||
| path: ctx.getRequest().url, | ||
| path: request.url, | ||
| }); | ||
| } | ||
|
|
||
| private formatValidationErrors(validationErrors: ValidationError[]) { | ||
| const formattedErrors = {}; | ||
| private formatValidationErrors( | ||
| validationErrors: ValidationError[], | ||
| ): Record<string, string> { | ||
| const formattedErrors: Record<string, string> = {}; | ||
| validationErrors.forEach((err) => { | ||
| formattedErrors[err.property] = Object.values(err.constraints || {}).join( | ||
| ', ', | ||
|
|
@@ -81,4 +102,41 @@ export class ValidationExceptionFilter implements ExceptionFilter { | |
| }); | ||
| return formattedErrors; | ||
| } | ||
|
|
||
| private extractBadRequestReason( | ||
| exceptionResponse: unknown, | ||
| fallbackMessage: string, | ||
| errors: Record<string, string> | null, | ||
| ): string { | ||
| if (typeof exceptionResponse === 'string') { | ||
| return exceptionResponse; | ||
| } | ||
|
|
||
| if ( | ||
| typeof exceptionResponse === 'object' && | ||
| exceptionResponse !== null && | ||
| 'message' in exceptionResponse | ||
| ) { | ||
| const responseMessage = (exceptionResponse as { message?: unknown }) | ||
| .message; | ||
| if (Array.isArray(responseMessage)) { | ||
| return responseMessage.map((entry) => String(entry)).join('; '); | ||
| } | ||
| if (typeof responseMessage === 'string') { | ||
| return responseMessage; | ||
| } | ||
| return JSON.stringify(sanitizeForLogging(responseMessage)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [❗❗ |
||
| } | ||
|
|
||
| if (errors && Object.keys(errors).length > 0) { | ||
| return JSON.stringify(sanitizeForLogging(errors)); | ||
| } | ||
|
|
||
| return fallbackMessage; | ||
| } | ||
|
|
||
| private isCreateUserRequest(method: string, originalUrl: string): boolean { | ||
| const pathOnly = originalUrl.split('?')[0]; | ||
| return method.toUpperCase() === 'POST' && /\/v6\/users\/?$/.test(pathOnly); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; | ||
| import { Request, Response, NextFunction } from 'express'; | ||
| import { sanitizeForLogging } from '../util/log-sanitizer.util'; | ||
|
|
||
| @Injectable() | ||
| export class RequestLoggerMiddleware implements NestMiddleware { | ||
|
|
@@ -12,7 +13,15 @@ export class RequestLoggerMiddleware implements NestMiddleware { | |
| this.logger.log( | ||
| `---> ${method} ${originalUrl} - User-Agent: ${userAgent} - IP: ${req.ip}`, | ||
| ); | ||
| this.logger.debug(`---> Request Headers: ${JSON.stringify(headers)}`); | ||
| this.logger.debug( | ||
| `---> Request Headers: ${JSON.stringify(sanitizeForLogging(headers))}`, | ||
| ); | ||
|
|
||
| if (this.shouldLogCreateUserBody(method, originalUrl)) { | ||
| this.logger.debug( | ||
| `---> Request Body: ${JSON.stringify(sanitizeForLogging(req.body))}`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| ); | ||
| } | ||
|
|
||
| // Optionally log when the request finishes | ||
| res.on('finish', () => { | ||
|
|
@@ -24,4 +33,17 @@ export class RequestLoggerMiddleware implements NestMiddleware { | |
|
|
||
| next(); | ||
| } | ||
|
|
||
| private shouldLogCreateUserBody( | ||
| method: string, | ||
| originalUrl: string, | ||
| ): boolean { | ||
| const normalizedMethod = method.toUpperCase(); | ||
| if (normalizedMethod !== 'POST') { | ||
| return false; | ||
| } | ||
|
|
||
| const pathOnly = originalUrl.split('?')[0]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| return /\/v6\/users\/?$/.test(pathOnly); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| const CIRCULAR_REFERENCE = '[Circular]'; | ||
| const REDACTED = '[REDACTED]'; | ||
|
|
||
| function normalizeKey(key: string): string { | ||
| return key.toLowerCase().replace(/[^a-z0-9]/g, ''); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| } | ||
|
|
||
| function isSensitiveKey(key: string): boolean { | ||
| const normalizedKey = normalizeKey(key); | ||
| return ( | ||
| normalizedKey.includes('password') || | ||
| normalizedKey.includes('token') || | ||
| normalizedKey.includes('secret') || | ||
| normalizedKey.includes('authorization') || | ||
| normalizedKey.includes('cookie') || | ||
| normalizedKey === 'otp' || | ||
| normalizedKey.endsWith('otp') || | ||
| normalizedKey === 'apikey' || | ||
| normalizedKey.endsWith('apikey') | ||
| ); | ||
| } | ||
|
|
||
| function sanitizeValue(value: unknown, seen: WeakSet<object>): unknown { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| if (value === null || value === undefined) { | ||
| return value; | ||
| } | ||
|
|
||
| if (Array.isArray(value)) { | ||
| return value.map((item) => sanitizeValue(item, seen)); | ||
| } | ||
|
|
||
| if (value instanceof Date) { | ||
| return value.toISOString(); | ||
| } | ||
|
|
||
| if (typeof value !== 'object') { | ||
| return value; | ||
| } | ||
|
|
||
| if (Buffer.isBuffer(value)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| return `[Buffer:${value.length}]`; | ||
| } | ||
|
|
||
| if (seen.has(value)) { | ||
| return CIRCULAR_REFERENCE; | ||
| } | ||
|
|
||
| seen.add(value); | ||
|
|
||
| const source = value as Record<string, unknown>; | ||
| const sanitized: Record<string, unknown> = {}; | ||
|
|
||
| for (const [key, nestedValue] of Object.entries(source)) { | ||
| if (isSensitiveKey(key)) { | ||
| sanitized[key] = REDACTED; | ||
| continue; | ||
| } | ||
|
|
||
| sanitized[key] = sanitizeValue(nestedValue, seen); | ||
| } | ||
|
|
||
| return sanitized; | ||
| } | ||
|
|
||
| export function sanitizeForLogging(value: unknown): unknown { | ||
| return sanitizeValue(value, new WeakSet<object>()); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[⚠️
correctness]Consider handling potential JSON serialization errors when logging the request body. If
request.bodycontains circular references or non-serializable values,JSON.stringifywill throw an error.