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

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