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:
Ho Ngoc Hai
2026-04-08 00:18:21 +07:00
parent 1fb7bb39d2
commit c981bff771
14 changed files with 564 additions and 5 deletions

View File

@@ -1,11 +1,15 @@
import { NestFactory } from '@nestjs/core';
import { LoggerService } from '@modules/shared';
import { AppModule } from './app.module';
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;
await app.listen(port);
console.log(`API running on http://localhost:${port}`);
logger.log(`API running on http://localhost:${port}`, 'Bootstrap');
}
bootstrap();

View File

@@ -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);
});
});

View 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);
}
}

View 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',
}

View File

@@ -3,3 +3,13 @@ export { AggregateRoot } from './aggregate-root';
export { ValueObject } from './value-object';
export type { DomainEvent } from './domain-event';
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';

View File

@@ -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) }));
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -2,3 +2,7 @@ export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { LoggerService } from './logger.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';

View File

@@ -1,5 +1,6 @@
import { Injectable, type LoggerService as NestLoggerService } from '@nestjs/common';
import pino, { type Logger } from 'pino';
import { maskPii } from './pii-masker';
@Injectable()
export class LoggerService implements NestLoggerService {
@@ -12,6 +13,12 @@ export class LoggerService implements NestLoggerService {
process.env['NODE_ENV'] !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
formatters: {
log(object) {
return maskPii(object) as Record<string, unknown>;
},
},
timestamp: pino.stdTimeFunctions.isoTime,
});
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View 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;
}

View File

@@ -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 { EventBusService } from './infrastructure/event-bus.service';
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
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 { RedisService } from './infrastructure/redis.service';
@Global()
@Module({
imports: [EventEmitterModule.forRoot()],
providers: [PrismaService, RedisService, LoggerService, EventBusService],
providers: [
PrismaService,
RedisService,
LoggerService,
EventBusService,
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
],
exports: [PrismaService, RedisService, LoggerService, EventBusService],
})
export class SharedModule {}
export class SharedModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(CorrelationIdMiddleware, RequestLoggingMiddleware).forRoutes('*');
}
}