feat(shared): add error handling & structured logging strategy
- Global exception filter with consistent error response format - Domain exceptions (NotFoundException, ValidationException, etc.) - Error codes enum for domain-specific error identification - Correlation ID middleware for request tracing - Request/response logging middleware with structured JSON - PII masking in logs (emails, phone numbers, sensitive fields) - Enhanced LoggerService with pino formatters and ISO timestamps - Tests for exception filter, domain exceptions, and PII masker Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
|
const logger = app.get(LoggerService);
|
||||||
|
app.useLogger(logger);
|
||||||
|
|
||||||
const port = process.env['PORT'] ?? 3001;
|
const port = process.env['PORT'] ?? 3001;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`API running on http://localhost:${port}`);
|
logger.log(`API running on http://localhost:${port}`, 'Bootstrap');
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
DomainException,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
ValidationException,
|
||||||
|
} from '../domain-exception';
|
||||||
|
import { ErrorCode } from '../error-codes';
|
||||||
|
|
||||||
|
describe('DomainException', () => {
|
||||||
|
it('should create with error code and message', () => {
|
||||||
|
const ex = new DomainException(ErrorCode.INTERNAL_ERROR, 'Something failed');
|
||||||
|
expect(ex.errorCode).toBe(ErrorCode.INTERNAL_ERROR);
|
||||||
|
expect(ex.message).toBe('Something failed');
|
||||||
|
expect(ex.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept details', () => {
|
||||||
|
const ex = new DomainException(ErrorCode.VALIDATION_FAILED, 'Invalid', HttpStatus.BAD_REQUEST, {
|
||||||
|
field: 'email',
|
||||||
|
});
|
||||||
|
expect(ex.details).toEqual({ field: 'email' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotFoundException', () => {
|
||||||
|
it('should format message with entity and id', () => {
|
||||||
|
const ex = new NotFoundException('User', '123');
|
||||||
|
expect(ex.message).toBe("User with id '123' not found");
|
||||||
|
expect(ex.getStatus()).toBe(HttpStatus.NOT_FOUND);
|
||||||
|
expect(ex.errorCode).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format message without id', () => {
|
||||||
|
const ex = new NotFoundException('Course');
|
||||||
|
expect(ex.message).toBe('Course not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ValidationException', () => {
|
||||||
|
it('should be BAD_REQUEST with details', () => {
|
||||||
|
const ex = new ValidationException('Invalid input', { field: 'name' });
|
||||||
|
expect(ex.getStatus()).toBe(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(ex.errorCode).toBe(ErrorCode.VALIDATION_FAILED);
|
||||||
|
expect(ex.details).toEqual({ field: 'name' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConflictException', () => {
|
||||||
|
it('should be CONFLICT', () => {
|
||||||
|
const ex = new ConflictException('Already exists');
|
||||||
|
expect(ex.getStatus()).toBe(HttpStatus.CONFLICT);
|
||||||
|
expect(ex.errorCode).toBe(ErrorCode.CONFLICT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UnauthorizedException', () => {
|
||||||
|
it('should default message', () => {
|
||||||
|
const ex = new UnauthorizedException();
|
||||||
|
expect(ex.message).toBe('Unauthorized');
|
||||||
|
expect(ex.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ForbiddenException', () => {
|
||||||
|
it('should default message', () => {
|
||||||
|
const ex = new ForbiddenException();
|
||||||
|
expect(ex.message).toBe('Forbidden');
|
||||||
|
expect(ex.getStatus()).toBe(HttpStatus.FORBIDDEN);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
apps/api/src/modules/shared/domain/domain-exception.ts
Normal file
56
apps/api/src/modules/shared/domain/domain-exception.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
|
export interface ErrorResponseBody {
|
||||||
|
statusCode: number;
|
||||||
|
errorCode: ErrorCode;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
correlationId?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DomainException extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly errorCode: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
statusCode: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
public readonly details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message, statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundException extends DomainException {
|
||||||
|
constructor(entity: string, id?: string) {
|
||||||
|
super(
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
id ? `${entity} with id '${id}' not found` : `${entity} not found`,
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationException extends DomainException {
|
||||||
|
constructor(message: string, details?: Record<string, unknown>) {
|
||||||
|
super(ErrorCode.VALIDATION_FAILED, message, HttpStatus.BAD_REQUEST, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictException extends DomainException {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(ErrorCode.CONFLICT, message, HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedException extends DomainException {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(ErrorCode.UNAUTHORIZED, message, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenException extends DomainException {
|
||||||
|
constructor(message = 'Forbidden') {
|
||||||
|
super(ErrorCode.FORBIDDEN, message, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/api/src/modules/shared/domain/error-codes.ts
Normal file
36
apps/api/src/modules/shared/domain/error-codes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Domain-specific error codes for consistent error identification across the platform.
|
||||||
|
* Format: DOMAIN_ACTION_REASON
|
||||||
|
*/
|
||||||
|
export enum ErrorCode {
|
||||||
|
// General
|
||||||
|
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||||
|
VALIDATION_FAILED = 'VALIDATION_FAILED',
|
||||||
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
CONFLICT = 'CONFLICT',
|
||||||
|
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||||
|
FORBIDDEN = 'FORBIDDEN',
|
||||||
|
BAD_REQUEST = 'BAD_REQUEST',
|
||||||
|
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS',
|
||||||
|
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
|
||||||
|
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
|
||||||
|
AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS',
|
||||||
|
|
||||||
|
// User
|
||||||
|
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||||
|
USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS',
|
||||||
|
USER_INVALID_PHONE = 'USER_INVALID_PHONE',
|
||||||
|
|
||||||
|
// Course
|
||||||
|
COURSE_NOT_FOUND = 'COURSE_NOT_FOUND',
|
||||||
|
COURSE_ALREADY_PUBLISHED = 'COURSE_ALREADY_PUBLISHED',
|
||||||
|
COURSE_ENROLLMENT_CLOSED = 'COURSE_ENROLLMENT_CLOSED',
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
PAYMENT_FAILED = 'PAYMENT_FAILED',
|
||||||
|
PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED',
|
||||||
|
PAYMENT_INVALID_AMOUNT = 'PAYMENT_INVALID_AMOUNT',
|
||||||
|
}
|
||||||
@@ -3,3 +3,13 @@ export { AggregateRoot } from './aggregate-root';
|
|||||||
export { ValueObject } from './value-object';
|
export { ValueObject } from './value-object';
|
||||||
export type { DomainEvent } from './domain-event';
|
export type { DomainEvent } from './domain-event';
|
||||||
export { Result } from './result';
|
export { Result } from './result';
|
||||||
|
export { ErrorCode } from './error-codes';
|
||||||
|
export {
|
||||||
|
DomainException,
|
||||||
|
NotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
ConflictException,
|
||||||
|
UnauthorizedException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from './domain-exception';
|
||||||
|
export type { ErrorResponseBody } from './domain-exception';
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DomainException } from '../../domain/domain-exception';
|
||||||
|
import { ErrorCode } from '../../domain/error-codes';
|
||||||
|
import { GlobalExceptionFilter } from '../filters/global-exception.filter';
|
||||||
|
import { LoggerService } from '../logger.service';
|
||||||
|
|
||||||
|
function createMockHost(correlationId?: string) {
|
||||||
|
const json = vi.fn();
|
||||||
|
const status = vi.fn().mockReturnValue({ json });
|
||||||
|
const request = {
|
||||||
|
headers: { 'x-correlation-id': correlationId },
|
||||||
|
};
|
||||||
|
const response = { status };
|
||||||
|
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => response,
|
||||||
|
}),
|
||||||
|
json,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GlobalExceptionFilter', () => {
|
||||||
|
const logger = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
verbose: vi.fn(),
|
||||||
|
child: vi.fn(),
|
||||||
|
} as unknown as LoggerService;
|
||||||
|
const filter = new GlobalExceptionFilter(logger);
|
||||||
|
|
||||||
|
it('should handle DomainException with correct error code', () => {
|
||||||
|
const exception = new DomainException(
|
||||||
|
ErrorCode.USER_NOT_FOUND,
|
||||||
|
'User not found',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
const host = createMockHost('test-corr-id');
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
|
errorCode: ErrorCode.USER_NOT_FOUND,
|
||||||
|
message: 'User not found',
|
||||||
|
correlationId: 'test-corr-id',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standard HttpException', () => {
|
||||||
|
const exception = new HttpException('Bad request', HttpStatus.BAD_REQUEST);
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.BAD_REQUEST,
|
||||||
|
message: 'Bad request',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown errors as 500', () => {
|
||||||
|
const exception = new Error('Something broke');
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.INTERNAL_ERROR,
|
||||||
|
message: 'Internal server error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include timestamp in every response', () => {
|
||||||
|
const exception = new Error('test');
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.json).toHaveBeenCalledWith(expect.objectContaining({ timestamp: expect.any(String) }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { maskPii } from '../pii-masker';
|
||||||
|
|
||||||
|
describe('maskPii', () => {
|
||||||
|
it('should redact sensitive keys', () => {
|
||||||
|
const input = {
|
||||||
|
username: 'john',
|
||||||
|
password: 'secret123',
|
||||||
|
token: 'abc-def',
|
||||||
|
authorization: 'Bearer xyz',
|
||||||
|
};
|
||||||
|
const result = maskPii(input) as Record<string, unknown>;
|
||||||
|
expect(result['username']).toBe('john');
|
||||||
|
expect(result['password']).toBe('[REDACTED]');
|
||||||
|
expect(result['token']).toBe('[REDACTED]');
|
||||||
|
expect(result['authorization']).toBe('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask email addresses in strings', () => {
|
||||||
|
const input = 'Contact user@example.com for details';
|
||||||
|
const result = maskPii(input);
|
||||||
|
expect(result).toBe('Contact u***@example.com for details');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask Vietnamese phone numbers', () => {
|
||||||
|
const input = 'Call 0912345678 or +84912345678';
|
||||||
|
const result = maskPii(input) as string;
|
||||||
|
expect(result).not.toContain('0912345678');
|
||||||
|
expect(result).not.toContain('+84912345678');
|
||||||
|
expect(result).toContain('091****678');
|
||||||
|
expect(result).toContain('+84****678');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
const input = {
|
||||||
|
user: {
|
||||||
|
name: 'John',
|
||||||
|
password: 'secret',
|
||||||
|
email: 'john@test.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = maskPii(input) as Record<string, Record<string, unknown>>;
|
||||||
|
expect(result['user']!['name']).toBe('John');
|
||||||
|
expect(result['user']!['password']).toBe('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays', () => {
|
||||||
|
const input = [{ password: 'a' }, { password: 'b' }];
|
||||||
|
const result = maskPii(input) as Record<string, unknown>[];
|
||||||
|
expect(result[0]!['password']).toBe('[REDACTED]');
|
||||||
|
expect(result[1]!['password']).toBe('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null/undefined as-is', () => {
|
||||||
|
expect(maskPii(null)).toBeNull();
|
||||||
|
expect(maskPii(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return numbers as-is', () => {
|
||||||
|
expect(maskPii(42)).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
type ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
type ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception';
|
||||||
|
import { ErrorCode } from '../../domain/error-codes';
|
||||||
|
import { LoggerService } from '../logger.service';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
|
constructor(private readonly logger: LoggerService) {}
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
const correlationId = (request.headers['x-correlation-id'] as string) ?? undefined;
|
||||||
|
|
||||||
|
const errorResponse = this.buildErrorResponse(exception, correlationId);
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`[${errorResponse.errorCode}] ${errorResponse.message}`,
|
||||||
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
|
'GlobalExceptionFilter',
|
||||||
|
);
|
||||||
|
|
||||||
|
response.status(errorResponse.statusCode).json(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorResponse(exception: unknown, correlationId?: string): ErrorResponseBody {
|
||||||
|
if (exception instanceof DomainException) {
|
||||||
|
return {
|
||||||
|
statusCode: exception.getStatus(),
|
||||||
|
errorCode: exception.errorCode,
|
||||||
|
message: exception.message,
|
||||||
|
details: exception.details,
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
const message =
|
||||||
|
typeof exceptionResponse === 'string'
|
||||||
|
? exceptionResponse
|
||||||
|
: (exceptionResponse as Record<string, unknown>)['message'] ?? exception.message;
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: status,
|
||||||
|
errorCode: this.httpStatusToErrorCode(status),
|
||||||
|
message: Array.isArray(message) ? message.join('; ') : String(message),
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.INTERNAL_ERROR,
|
||||||
|
message: 'Internal server error',
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private httpStatusToErrorCode(status: number): ErrorCode {
|
||||||
|
const map: Record<number, ErrorCode> = {
|
||||||
|
[HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,
|
||||||
|
[HttpStatus.UNAUTHORIZED]: ErrorCode.UNAUTHORIZED,
|
||||||
|
[HttpStatus.FORBIDDEN]: ErrorCode.FORBIDDEN,
|
||||||
|
[HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND,
|
||||||
|
[HttpStatus.CONFLICT]: ErrorCode.CONFLICT,
|
||||||
|
[HttpStatus.TOO_MANY_REQUESTS]: ErrorCode.TOO_MANY_REQUESTS,
|
||||||
|
};
|
||||||
|
return map[status] ?? ErrorCode.INTERNAL_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,7 @@ export { PrismaService } from './prisma.service';
|
|||||||
export { RedisService } from './redis.service';
|
export { RedisService } from './redis.service';
|
||||||
export { LoggerService } from './logger.service';
|
export { LoggerService } from './logger.service';
|
||||||
export { EventBusService } from './event-bus.service';
|
export { EventBusService } from './event-bus.service';
|
||||||
|
export { GlobalExceptionFilter } from './filters/global-exception.filter';
|
||||||
|
export { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';
|
||||||
|
export { RequestLoggingMiddleware } from './middleware/request-logging.middleware';
|
||||||
|
export { maskPii } from './pii-masker';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, type LoggerService as NestLoggerService } from '@nestjs/common';
|
import { Injectable, type LoggerService as NestLoggerService } from '@nestjs/common';
|
||||||
import pino, { type Logger } from 'pino';
|
import pino, { type Logger } from 'pino';
|
||||||
|
import { maskPii } from './pii-masker';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LoggerService implements NestLoggerService {
|
export class LoggerService implements NestLoggerService {
|
||||||
@@ -12,6 +13,12 @@ export class LoggerService implements NestLoggerService {
|
|||||||
process.env['NODE_ENV'] !== 'production'
|
process.env['NODE_ENV'] !== 'production'
|
||||||
? { target: 'pino-pretty', options: { colorize: true } }
|
? { target: 'pino-pretty', options: { colorize: true } }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
formatters: {
|
||||||
|
log(object) {
|
||||||
|
return maskPii(object) as Record<string, unknown>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
const CORRELATION_ID_HEADER = 'x-correlation-id';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CorrelationIdMiddleware implements NestMiddleware {
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const correlationId = (req.headers[CORRELATION_ID_HEADER] as string) || randomUUID();
|
||||||
|
req.headers[CORRELATION_ID_HEADER] = correlationId;
|
||||||
|
res.setHeader(CORRELATION_ID_HEADER, correlationId);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { LoggerService } from '../logger.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestLoggingMiddleware implements NestMiddleware {
|
||||||
|
private readonly pinoChild;
|
||||||
|
|
||||||
|
constructor(private readonly logger: LoggerService) {
|
||||||
|
this.pinoChild = this.logger.child({ component: 'http' });
|
||||||
|
}
|
||||||
|
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const start = Date.now();
|
||||||
|
const correlationId = req.headers['x-correlation-id'] as string | undefined;
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const logData = {
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
duration,
|
||||||
|
correlationId,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
ip: req.ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (res.statusCode >= 500) {
|
||||||
|
this.pinoChild.error(logData, 'request completed');
|
||||||
|
} else if (res.statusCode >= 400) {
|
||||||
|
this.pinoChild.warn(logData, 'request completed');
|
||||||
|
} else {
|
||||||
|
this.pinoChild.info(logData, 'request completed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/api/src/modules/shared/infrastructure/pii-masker.ts
Normal file
53
apps/api/src/modules/shared/infrastructure/pii-masker.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* PII masking utility for structured logs.
|
||||||
|
* Redacts sensitive fields in log data before they reach the transport.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SENSITIVE_KEYS = new Set([
|
||||||
|
'password',
|
||||||
|
'token',
|
||||||
|
'accessToken',
|
||||||
|
'refreshToken',
|
||||||
|
'secret',
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'creditCard',
|
||||||
|
'cardNumber',
|
||||||
|
'cvv',
|
||||||
|
'ssn',
|
||||||
|
'cmnd',
|
||||||
|
'cccd',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
||||||
|
const PHONE_REGEX = /(?:\+84|0)\d{9,10}/g;
|
||||||
|
|
||||||
|
export function maskPii(obj: unknown): unknown {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
if (typeof obj === 'string') return maskString(obj);
|
||||||
|
if (Array.isArray(obj)) return obj.map(maskPii);
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
const masked: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
if (SENSITIVE_KEYS.has(key)) {
|
||||||
|
masked[key] = '[REDACTED]';
|
||||||
|
} else {
|
||||||
|
masked[key] = maskPii(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskString(value: string): string {
|
||||||
|
let result = value;
|
||||||
|
result = result.replace(EMAIL_REGEX, (match) => {
|
||||||
|
const [local, domain] = match.split('@');
|
||||||
|
return `${local![0]}***@${domain}`;
|
||||||
|
});
|
||||||
|
result = result.replace(PHONE_REGEX, (match) => {
|
||||||
|
return match.slice(0, 3) + '****' + match.slice(-3);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
|
||||||
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { EventBusService } from './infrastructure/event-bus.service';
|
import { EventBusService } from './infrastructure/event-bus.service';
|
||||||
|
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||||
import { LoggerService } from './infrastructure/logger.service';
|
import { LoggerService } from './infrastructure/logger.service';
|
||||||
|
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
|
||||||
|
import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware';
|
||||||
import { PrismaService } from './infrastructure/prisma.service';
|
import { PrismaService } from './infrastructure/prisma.service';
|
||||||
import { RedisService } from './infrastructure/redis.service';
|
import { RedisService } from './infrastructure/redis.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [EventEmitterModule.forRoot()],
|
imports: [EventEmitterModule.forRoot()],
|
||||||
providers: [PrismaService, RedisService, LoggerService, EventBusService],
|
providers: [
|
||||||
|
PrismaService,
|
||||||
|
RedisService,
|
||||||
|
LoggerService,
|
||||||
|
EventBusService,
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: GlobalExceptionFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
exports: [PrismaService, RedisService, LoggerService, EventBusService],
|
exports: [PrismaService, RedisService, LoggerService, EventBusService],
|
||||||
})
|
})
|
||||||
export class SharedModule {}
|
export class SharedModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
consumer.apply(CorrelationIdMiddleware, RequestLoggingMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user