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:
@@ -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 { 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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user