Custom Exception Filters: спеціалізовані фільтри
Custom Exception Filters: спеціалізовані фільтри
🎯 Мета лекції
- Зрозуміти необхідність спеціалізованих exception filters для різних типів помилок
- Опанувати створення
ValidationExceptionFilterдля детальної обробки помилок валідації DTO - Навчитися розробляти
DatabaseExceptionFilterдля трансформації помилок TypeORM/Sequelize - Вивчити
HttpExceptionFilterдля форматування стандартних HTTP-виключень - Засвоїти інтеграцію з Sentry/Datadog для централізованого моніторингу помилок
- Практикувати створення
TimeoutExceptionFilterтаUnauthorizedExceptionFilter - Розуміти композицію filters та їх пріоритетність виконання
🔑 Ключові терміни
- ValidationExceptionFilter (фільтр валідації): filter для обробки помилок
class-validatorз деталями про поля - DatabaseExceptionFilter (фільтр БД): filter для трансформації помилок ORM у зрозумілі HTTP-відповіді
- Exception Transformation (трансформація виключень): перетворення низькорівневих помилок у структуровані відповіді
- Sentry Integration (інтеграція Sentry): автоматична відправка помилок у систему моніторингу
- Error Context Enrichment (збагачення контексту): додавання метаданих (user, request, trace) до логів помилок
- Filter Composition (композиція фільтрів): комбінування кількох filters для різних типів виключень
Необхідність спеціалізованих exception filters
Різні типи помилок вимагають різної обробки:
- Валідаційні помилки: потребують списку полів з constraints
- Помилки бази даних: приховують SQL-деталі, перетворюють коди помилок
- Помилки зовнішніх API: обробляють timeout, connection refused, 503
- Помилки аутентифікації: додають WWW-Authenticate заголовки
- Помилки авторизації: логують спроби несанкціонованого доступу
Загальний filter vs спеціалізовані:
@Catch()
export class GeneralFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
// Складна логіка if-else для кожного типу
if (exception instanceof ValidationError) { /* ... */ }
else if (exception instanceof QueryFailedError) { /* ... */ }
else if (exception instanceof UnauthorizedException) { /* ... */ }
// 100+ рядків коду
}
}
@Catch(BadRequestException)
export class ValidationExceptionFilter { /* ... */ }
@Catch(QueryFailedError)
export class DatabaseExceptionFilter { /* ... */ }
@Catch(UnauthorizedException)
export class AuthExceptionFilter { /* ... */ }
// Кожен filter фокусується на одному типі
ValidationExceptionFilter: обробка помилок валідації
ValidationPipe автоматично викидає BadRequestException при помилках class-validator. Створимо filter для детального форматування:
Базова версія ValidationExceptionFilter
import { ExceptionFilter, Catch, ArgumentsHost, BadRequestException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(BadRequestException)
export class ValidationExceptionFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse: any = exception.getResponse();
// Витягування помилок валідації
const validationErrors = exceptionResponse.message;
// Структурування відповіді
const errorResponse = {
statusCode: status,
error: 'Validation Failed',
message: 'Request validation failed',
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
errors: this.formatValidationErrors(validationErrors),
};
response.status(status).json(errorResponse);
}
private formatValidationErrors(errors: any): any[] {
if (!Array.isArray(errors)) {
return [{ message: errors }];
}
return errors.map(error => ({
field: error.property,
value: error.value,
constraints: Object.values(error.constraints || {}),
}));
}
}
Приклад використання:
// create-user.dto.ts
import { IsEmail, IsString, MinLength, IsInt, Min, Max } from 'class-validator';
export class CreateUserDto {
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
@IsInt()
@Min(18, { message: 'Must be at least 18 years old' })
@Max(120, { message: 'Invalid age' })
age: number;
}
// users.controller.ts
@Controller('users')
@UseFilters(ValidationExceptionFilter)
export class UsersController {
@Post()
async create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}
HTTP-запит з невалідними даними:
POST /users HTTP/1.1
Content-Type: application/json
{
"email": "invalid-email",
"password": "123",
"age": 15
}
JSON-відповідь:
{
"statusCode": 400,
"error": "Validation Failed",
"message": "Request validation failed",
"timestamp": "2026-09-05T18:00:00.123Z",
"path": "/users",
"method": "POST",
"errors": [
{
"field": "email",
"value": "invalid-email",
"constraints": ["Invalid email format"]
},
{
"field": "password",
"value": "123",
"constraints": ["Password must be at least 8 characters"]
},
{
"field": "age",
"value": 15,
"constraints": ["Must be at least 18 years old"]
}
]
}
Розширена версія з групуванням помилок
@Catch(BadRequestException)
export class AdvancedValidationExceptionFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const exceptionResponse: any = exception.getResponse();
const validationErrors = exceptionResponse.message;
// Групування помилок за полями
const fieldErrors = this.groupErrorsByField(validationErrors);
response.status(400).json({
statusCode: 400,
error: 'Validation Failed',
message: `${Object.keys(fieldErrors).length} field(s) failed validation`,
timestamp: new Date().toISOString(),
path: request.url,
fields: fieldErrors,
});
}
private groupErrorsByField(errors: any): Record<string, string[]> {
if (!Array.isArray(errors)) {
return { general: [errors] };
}
const grouped: Record<string, string[]> = {};
errors.forEach(error => {
const field = error.property;
const messages = Object.values(error.constraints || {}) as string[];
grouped[field] = messages;
});
return grouped;
}
}
JSON-відповідь (згруповано):
{
"statusCode": 400,
"error": "Validation Failed",
"message": "3 field(s) failed validation",
"timestamp": "2026-09-05T18:00:00.123Z",
"path": "/users",
"fields": {
"email": ["Invalid email format"],
"password": ["Password must be at least 8 characters"],
"age": ["Must be at least 18 years old"]
}
}
fields['email'] → <input name="email" error="...">.DatabaseExceptionFilter: трансформація помилок ORM
Помилки бази даних (TypeORM, Sequelize, Prisma) містять SQL-деталі, які не повинні потрапляти до клієнта. DatabaseExceptionFilter трансформує їх у зрозумілі повідомлення:
Filter для TypeORM
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
import { Response } from 'express';
@Catch(QueryFailedError)
export class DatabaseExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(DatabaseExceptionFilter.name);
catch(exception: QueryFailedError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
// Логування повної помилки (для дебагу)
this.logger.error('Database error', {
message: exception.message,
query: exception.query,
parameters: exception.parameters,
driverError: exception.driverError,
});
// Витягування коду помилки (PostgreSQL)
const errorCode = (exception.driverError as any)?.code;
const constraint = (exception.driverError as any)?.constraint;
// Трансформація у HTTP-відповідь
const { statusCode, message } = this.mapDatabaseError(errorCode, constraint, exception);
response.status(statusCode).json({
statusCode,
error: 'Database Error',
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
private mapDatabaseError(
code: string,
constraint: string,
exception: QueryFailedError
): { statusCode: number; message: string } {
switch (code) {
case '23505': // unique_violation
return {
statusCode: HttpStatus.CONFLICT,
message: this.extractUniqueViolationMessage(constraint, exception),
};
case '23503': // foreign_key_violation
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Referenced record does not exist',
};
case '23502': // not_null_violation
return {
statusCode: HttpStatus.BAD_REQUEST,
message: this.extractNotNullViolationMessage(exception),
};
case '22P02': // invalid_text_representation
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Invalid data format',
};
case '22001': // string_data_right_truncation
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Data too long for field',
};
case '42P01': // undefined_table
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Database configuration error',
};
default:
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'An unexpected database error occurred',
};
}
}
private extractUniqueViolationMessage(constraint: string, exception: QueryFailedError): string {
// Витягування назви поля з constraint name
// Наприклад: "users_email_key" → "email"
const match = constraint?.match(/^(.+?)_(.+?)_key$/);
const field = match ? match[2] : 'field';
return `Duplicate value: ${field} already exists`;
}
private extractNotNullViolationMessage(exception: QueryFailedError): string {
// Витягування назви колонки з повідомлення помилки
const match = exception.message.match(/column "(.+?)"/);
const column = match ? match[1] : 'field';
return `Required field missing: ${column}`;
}
}
Приклад використання:
// users.service.ts
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async create(dto: CreateUserDto): Promise<User> {
const user = this.usersRepository.create(dto);
// Якщо email вже існує, TypeORM викине QueryFailedError з кодом 23505
return await this.usersRepository.save(user);
}
}
// users.controller.ts
@Controller('users')
@UseFilters(DatabaseExceptionFilter)
export class UsersController {
@Post()
async create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}
HTTP-запит (дублікат email):
POST /users HTTP/1.1
Content-Type: application/json
{
"email": "john@example.com",
"password": "password123"
}
JSON-відповідь (без фільтра - розкриває SQL):
{
"statusCode": 500,
"message": "duplicate key value violates unique constraint \"users_email_key\""
}
JSON-відповідь (з фільтром - зрозуміло клієнту):
{
"statusCode": 409,
"error": "Database Error",
"message": "Duplicate value: email already exists",
"timestamp": "2026-09-05T18:30:00.000Z",
"path": "/users"
}
Filter для Prisma
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const { statusCode, message } = this.mapPrismaError(exception);
response.status(statusCode).json({
statusCode,
error: 'Database Error',
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
private mapPrismaError(
exception: Prisma.PrismaClientKnownRequestError
): { statusCode: number; message: string } {
switch (exception.code) {
case 'P2002': // Unique constraint failed
const target = (exception.meta?.target as string[]) || [];
return {
statusCode: HttpStatus.CONFLICT,
message: `Duplicate value: ${target.join(', ')} already exists`,
};
case 'P2003': // Foreign key constraint failed
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Referenced record does not exist',
};
case 'P2025': // Record not found
return {
statusCode: HttpStatus.NOT_FOUND,
message: 'Record not found',
};
case 'P2014': // Invalid ID
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Invalid identifier',
};
default:
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Database error occurred',
};
}
}
}
Logger або Sentry для збереження деталей.HttpExceptionFilter: форматування стандартних виключень
Універсальний filter для всіх HttpException з додаванням метаданих:
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
// Витягування повідомлення
let message: string | object;
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else {
message = (exceptionResponse as any).message || exceptionResponse;
}
// Логування з контекстом
this.logger.warn(`HTTP ${status} - ${request.method} ${request.url}`, {
statusCode: status,
message,
user: request['user']?.id,
ip: request.ip,
userAgent: request.get('user-agent'),
});
// Формування відповіді
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message,
...(process.env.NODE_ENV === 'development' && {
stack: exception.stack,
}),
};
response.status(status).json(errorResponse);
}
}
Реєстрація глобально:
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}
Інтеграція з Sentry: моніторинг помилок
Sentry — це платформа для відстеження помилок у production. Інтегруємо її з exception filter:
Встановлення Sentry
npm install @sentry/node @sentry/tracing
Ініціалізація Sentry
// main.ts
import { NestFactory } from '@nestjs/core';
import * as Sentry from '@sentry/node';
import { AppModule } from './app.module';
async function bootstrap() {
// Ініціалізація Sentry
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1.0,
});
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
SentryExceptionFilter
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { Request, Response } from 'express';
@Catch()
export class SentryExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// Визначення статусу
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// Відправлення в Sentry (лише 5xx помилки)
if (status >= 500) {
Sentry.withScope(scope => {
// Додавання контексту запиту
scope.setExtra('request', {
method: request.method,
url: request.url,
headers: request.headers,
body: request.body,
query: request.query,
params: request.params,
});
// Додавання користувача
if (request['user']) {
scope.setUser({
id: request['user'].id,
email: request['user'].email,
username: request['user'].username,
});
}
// Додавання тегів
scope.setTag('http.status_code', status);
scope.setTag('http.method', request.method);
scope.setTag('http.url', request.url);
// Встановлення рівня severity
scope.setLevel(status >= 500 ? 'error' : 'warning');
// Відправлення помилки
if (exception instanceof Error) {
Sentry.captureException(exception);
} else {
Sentry.captureMessage(`Non-error exception: ${JSON.stringify(exception)}`);
}
});
}
// Формування відповіді
const message = exception instanceof HttpException
? exception.message
: 'Internal server error';
response.status(status).json({
statusCode: status,
message: status >= 500 ? 'Internal server error' : message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
Реєстрація:
// main.ts
app.useGlobalFilters(new SentryExceptionFilter());
Додавання Breadcrumbs
// logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class SentryBreadcrumbsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
// Додавання breadcrumb для кожного запиту
Sentry.addBreadcrumb({
category: 'http',
message: `${request.method} ${request.url}`,
level: 'info',
data: {
method: request.method,
url: request.url,
params: request.params,
query: request.query,
},
});
return next.handle().pipe(
tap(() => {
Sentry.addBreadcrumb({
category: 'http',
message: `${request.method} ${request.url} - Success`,
level: 'info',
});
})
);
}
}
Реєстрація глобально:
// main.ts
app.useGlobalInterceptors(new SentryBreadcrumbsInterceptor());
TimeoutExceptionFilter: обробка таймаутів
Обробка помилок при перевищенні часу виконання запиту:
import { ExceptionFilter, Catch, ArgumentsHost, RequestTimeoutException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
import { TimeoutError } from 'rxjs';
@Catch(TimeoutError, RequestTimeoutException)
export class TimeoutExceptionFilter implements ExceptionFilter {
catch(exception: TimeoutError | RequestTimeoutException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const errorResponse = {
statusCode: HttpStatus.REQUEST_TIMEOUT,
error: 'Request Timeout',
message: 'The request took too long to process. Please try again later.',
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
suggestion: 'Consider reducing the complexity of the request or checking the server status',
};
response.status(HttpStatus.REQUEST_TIMEOUT).json(errorResponse);
}
}
Використання з Timeout Interceptor:
// timeout.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(5000), // 5 секунд
catchError(err => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException('Operation timed out'));
}
return throwError(() => err);
}),
);
}
}
// app.module.ts
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
{ provide: APP_FILTER, useClass: TimeoutExceptionFilter },
],
})
export class AppModule {}
UnauthorizedExceptionFilter: спеціалізована обробка 401
Додавання WWW-Authenticate заголовків для помилок аутентифікації:
import { ExceptionFilter, Catch, ArgumentsHost, UnauthorizedException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(UnauthorizedException)
export class UnauthorizedExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(UnauthorizedExceptionFilter.name);
catch(exception: UnauthorizedException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// Логування спроб несанкціонованого доступу
this.logger.warn('Unauthorized access attempt', {
path: request.url,
method: request.method,
ip: request.ip,
userAgent: request.get('user-agent'),
authHeader: request.headers.authorization ? 'present' : 'missing',
});
// Додавання WWW-Authenticate заголовку
response.setHeader('WWW-Authenticate', 'Bearer realm="api"');
// Формування відповіді
const errorResponse = {
statusCode: 401,
error: 'Unauthorized',
message: 'Authentication is required to access this resource',
timestamp: new Date().toISOString(),
path: request.url,
actions: [
{
type: 'login',
label: 'Login',
url: '/auth/login',
},
{
type: 'register',
label: 'Create Account',
url: '/auth/register',
},
],
};
response.status(401).json(errorResponse);
}
}
ForbiddenExceptionFilter: обробка 403 з логуванням
import { ExceptionFilter, Catch, ArgumentsHost, ForbiddenException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(ForbiddenException)
export class ForbiddenExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ForbiddenExceptionFilter.name);
catch(exception: ForbiddenException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// Логування спроб доступу до заборонених ресурсів
this.logger.warn('Forbidden access attempt', {
userId: request['user']?.id,
path: request.url,
method: request.method,
roles: request['user']?.roles,
ip: request.ip,
});
const errorResponse = {
statusCode: 403,
error: 'Forbidden',
message: 'You do not have permission to access this resource',
timestamp: new Date().toISOString(),
path: request.url,
requiredPermissions: exception['requiredPermissions'], // З метаданих Guard
};
response.status(403).json(errorResponse);
}
}
Композиція exception filters: комбінування
Реєстрація кількох filters для різних типів помилок:
Глобальна реєстрація через модуль
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import {
ValidationExceptionFilter,
DatabaseExceptionFilter,
HttpExceptionFilter,
TimeoutExceptionFilter,
UnauthorizedExceptionFilter,
ForbiddenExceptionFilter,
SentryExceptionFilter,
} from './filters';
@Module({
providers: [
// Специфічні filters (виконуються першими)
{ provide: APP_FILTER, useClass: ValidationExceptionFilter },
{ provide: APP_FILTER, useClass: DatabaseExceptionFilter },
{ provide: APP_FILTER, useClass: TimeoutExceptionFilter },
{ provide: APP_FILTER, useClass: UnauthorizedExceptionFilter },
{ provide: APP_FILTER, useClass: ForbiddenExceptionFilter },
// Загальні filters (виконуються останніми)
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_FILTER, useClass: SentryExceptionFilter }, // Catch-all
],
})
export class AppModule {}
Порядок виконання:
- ValidationExceptionFilter → перехоплює
BadRequestExceptionз помилками валідації - DatabaseExceptionFilter → перехоплює
QueryFailedErrorз TypeORM - TimeoutExceptionFilter → перехоплює
RequestTimeoutException - UnauthorizedExceptionFilter → перехоплює
UnauthorizedException - ForbiddenExceptionFilter → перехоплює
ForbiddenException - HttpExceptionFilter → перехоплює всі інші
HttpException - SentryExceptionFilter → перехоплює всі необроблені виключення (catch-all)
@Catch(), виключення передається наступному filter. Тому catch-all filter (@Catch()) має бути останнім.Умовна реєстрація залежно від середовища
// filters.provider.ts
import { Provider } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { DevelopmentExceptionFilter } from './development-exception.filter';
import { ProductionExceptionFilter } from './production-exception.filter';
export const exceptionsFilterProvider: Provider = {
provide: APP_FILTER,
useClass: process.env.NODE_ENV === 'production'
? ProductionExceptionFilter
: DevelopmentExceptionFilter,
};
// app.module.ts
@Module({
providers: [exceptionsFilterProvider],
})
export class AppModule {}
Інтеграція з Datadog: APM та логування
Datadog — це платформа для моніторингу та APM (Application Performance Monitoring):
Встановлення Datadog
npm install dd-trace
Ініціалізація Datadog
// tracer.ts
import tracer from 'dd-trace';
tracer.init({
service: 'nestjs-app',
env: process.env.NODE_ENV,
logInjection: true,
});
export default tracer;
// main.ts
import './tracer'; // Імпортувати першим рядком
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
DatadogExceptionFilter
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import tracer from 'dd-trace';
import { Request, Response } from 'express';
@Catch()
export class DatadogExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(DatadogExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// Отримання активного span з Datadog APM
const span = tracer.scope().active();
if (span) {
span.setTag('error', true);
span.setTag('http.status_code', status);
span.setTag('error.message', exception instanceof Error ? exception.message : 'Unknown error');
span.setTag('error.type', exception?.constructor?.name || 'UnknownError');
if (exception instanceof Error) {
span.setTag('error.stack', exception.stack);
}
}
// Логування у Datadog Logs
this.logger.error('Exception occurred', {
statusCode: status,
message: exception instanceof Error ? exception.message : 'Unknown error',
stack: exception instanceof Error ? exception.stack : undefined,
userId: request['user']?.id,
path: request.url,
method: request.method,
dd: {
trace_id: span?.context().toTraceId(),
span_id: span?.context().toSpanId(),
},
});
// Формування відповіді
response.status(status).json({
statusCode: status,
message: status >= 500 ? 'Internal server error' : exception['message'],
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
error: true до span, щоб помилкові запити виділялися в інтерфейсі Datadog.Тестування спеціалізованих filters
Unit-тестування ValidationExceptionFilter
import { BadRequestException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { ValidationExceptionFilter } from './validation-exception.filter';
describe('ValidationExceptionFilter', () => {
let filter: ValidationExceptionFilter;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [ValidationExceptionFilter],
}).compile();
filter = module.get(ValidationExceptionFilter);
});
it('should format validation errors correctly', () => {
const exception = new BadRequestException({
message: [
{
property: 'email',
value: 'invalid',
constraints: { isEmail: 'email must be an email' },
},
{
property: 'age',
value: 15,
constraints: { min: 'age must not be less than 18' },
},
],
});
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockResponse = { status: mockStatus };
const mockRequest = { url: '/users', method: 'POST' };
const host = {
switchToHttp: () => ({
getResponse: () => mockResponse,
getRequest: () => mockRequest,
}),
} as any;
filter.catch(exception, host);
expect(mockStatus).toHaveBeenCalledWith(400);
expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 400,
error: 'Validation Failed',
errors: expect.arrayContaining([
expect.objectContaining({ field: 'email' }),
expect.objectContaining({ field: 'age' }),
]),
})
);
});
});
Unit-тестування DatabaseExceptionFilter
import { Test } from '@nestjs/testing';
import { QueryFailedError } from 'typeorm';
import { DatabaseExceptionFilter } from './database-exception.filter';
describe('DatabaseExceptionFilter', () => {
let filter: DatabaseExceptionFilter;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [DatabaseExceptionFilter],
}).compile();
filter = module.get(DatabaseExceptionFilter);
});
it('should handle unique constraint violation (23505)', () => {
const exception = new QueryFailedError(
'INSERT INTO users ...',
[],
{
code: '23505',
constraint: 'users_email_key',
} as any
);
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockResponse = { status: mockStatus };
const mockRequest = { url: '/users', method: 'POST' };
const host = {
switchToHttp: () => ({
getResponse: () => mockResponse,
getRequest: () => mockRequest,
}),
} as any;
filter.catch(exception, host);
expect(mockStatus).toHaveBeenCalledWith(409);
expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 409,
message: expect.stringContaining('email'),
})
);
});
it('should handle foreign key violation (23503)', () => {
const exception = new QueryFailedError(
'INSERT INTO posts ...',
[],
{ code: '23503' } as any
);
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockResponse = { status: mockStatus };
const mockRequest = { url: '/posts', method: 'POST' };
const host = {
switchToHttp: () => ({
getResponse: () => mockResponse,
getRequest: () => mockRequest,
}),
} as any;
filter.catch(exception, host);
expect(mockStatus).toHaveBeenCalledWith(400);
expect(mockJson).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Referenced record does not exist',
})
);
});
});
E2E-тестування з композицією filters
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, HttpStatus } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('Exception Filters Composition (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('ValidationExceptionFilter should handle validation errors', () => {
return request(app.getHttpServer())
.post('/users')
.send({ email: 'invalid', age: 15 })
.expect(HttpStatus.BAD_REQUEST)
.expect(res => {
expect(res.body.error).toBe('Validation Failed');
expect(res.body.errors).toHaveLength(2);
});
});
it('DatabaseExceptionFilter should handle duplicate email', async () => {
// Створення користувача
await request(app.getHttpServer())
.post('/users')
.send({ email: 'test@example.com', password: '12345678', age: 25 });
// Спроба дублікату
return request(app.getHttpServer())
.post('/users')
.send({ email: 'test@example.com', password: '12345678', age: 25 })
.expect(HttpStatus.CONFLICT)
.expect(res => {
expect(res.body.message).toContain('email');
});
});
it('UnauthorizedExceptionFilter should handle missing token', () => {
return request(app.getHttpServer())
.get('/profile')
.expect(HttpStatus.UNAUTHORIZED)
.expect(res => {
expect(res.body.error).toBe('Unauthorized');
expect(res.headers['www-authenticate']).toBeDefined();
});
});
it('HttpExceptionFilter should handle 404', () => {
return request(app.getHttpServer())
.get('/users/999')
.expect(HttpStatus.NOT_FOUND)
.expect(res => {
expect(res.body.statusCode).toBe(404);
expect(res.body.timestamp).toBeDefined();
});
});
});
Візуалізація потоку виконання спеціалізованих filters
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
skinparam handwritten false
participant "Controller" as Controller
participant "Service" as Service
participant "TypeORM" as ORM
participant "ValidationFilter" as VF
participant "DatabaseFilter" as DF
participant "HttpFilter" as HF
participant "SentryFilter" as SF
participant "Response" as Response
== Scenario 1: Validation Error ==
Controller -> Service: create(dto)
Service -> Service: Invalid DTO data
Service --x VF: throw BadRequestException
activate VF
VF -> VF: Format validation errors
VF -> Response: 400 + errors list
deactivate VF
== Scenario 2: Database Error ==
Controller -> Service: create(validDto)
Service -> ORM: save(user)
ORM -> ORM: Unique constraint violation
ORM --x DF: throw QueryFailedError (23505)
activate DF
DF -> DF: Map error code 23505 → 409
DF -> Response: 409 "Email exists"
deactivate DF
== Scenario 3: HTTP Exception ==
Controller -> Service: findById(id)
Service -> Service: User not found
Service --x HF: throw NotFoundException
activate HF
HF -> HF: Format HTTP exception
HF -> Response: 404 + metadata
deactivate HF
== Scenario 4: Unexpected Error ==
Controller -> Service: complexOperation()
Service -> Service: Unhandled Error
Service --x SF: throw Error
activate SF
SF -> SF: Log to Sentry
SF -> SF: Send error report
SF -> Response: 500 "Internal error"
deactivate SF
@enduml
Опис сценаріїв:
- Validation Error:
BadRequestException→ValidationExceptionFilter→ 400 з деталями полів - Database Error:
QueryFailedError→DatabaseExceptionFilter→ 409/400 з читабельним повідомленням - HTTP Exception:
NotFoundException→HttpExceptionFilter→ 404 з метаданими - Unexpected Error:
Error→SentryExceptionFilter(catch-all) → логування + 500
Найкращі практики спеціалізованих filters
1. Один filter — один тип помилки
@Catch()
export class MegaFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
if (exception instanceof BadRequestException) {
// 50 рядків обробки валідації
} else if (exception instanceof QueryFailedError) {
// 80 рядків обробки БД
} else if (exception instanceof UnauthorizedException) {
// 40 рядків обробки авторизації
}
// 300+ рядків коду
}
}
@Catch(BadRequestException)
export class ValidationExceptionFilter { /* ... */ }
@Catch(QueryFailedError)
export class DatabaseExceptionFilter { /* ... */ }
@Catch(UnauthorizedException)
export class AuthExceptionFilter { /* ... */ }
2. Не розкривати чутливу інформацію
catch(exception: QueryFailedError, host: ArgumentsHost) {
response.json({
message: exception.message, // "duplicate key value violates unique constraint \"users_email_key\""
query: exception.query, // SQL-запит
parameters: exception.parameters, // Параметри запиту
});
}
catch(exception: QueryFailedError, host: ArgumentsHost) {
// Логування повної помилки для дебагу
this.logger.error('Database error', {
message: exception.message,
query: exception.query,
});
// Клієнту лише загальне повідомлення
response.json({
statusCode: 409,
message: 'Duplicate value: email already exists',
});
}
3. Логування з контекстом для дебагу
this.logger.error('Exception occurred', {
// Деталі помилки
message: exception.message,
stack: exception.stack,
type: exception.constructor.name,
// Контекст запиту
method: request.method,
url: request.url,
params: request.params,
query: request.query,
body: request.body, // Обережно з паролями!
// Контекст користувача
userId: request.user?.id,
userEmail: request.user?.email,
userRoles: request.user?.roles,
// Технічний контекст
ip: request.ip,
userAgent: request.get('user-agent'),
requestId: request.headers['x-request-id'],
timestamp: new Date().toISOString(),
});
4. Різні відповіді для development та production
const errorResponse = {
statusCode: status,
message: exception.message,
timestamp: new Date().toISOString(),
path: request.url,
// Деталі лише у development
...(process.env.NODE_ENV === 'development' && {
stack: exception.stack,
query: exception['query'],
details: exception,
}),
};
5. Структуровані відповіді з action hints
response.json({
statusCode: 409,
error: 'Conflict',
message: 'Email already registered',
timestamp: new Date().toISOString(),
// Підказки для клієнта
actions: [
{ type: 'login', label: 'Login with this email', url: '/auth/login' },
{ type: 'reset', label: 'Reset password', url: '/auth/reset-password' },
],
// Додаткові деталі
field: 'email',
value: dto.email,
});
6. Centralізація mapping помилок БД
// database-error.mapper.ts
export class DatabaseErrorMapper {
static mapPostgresError(code: string, constraint: string): { statusCode: number; message: string } {
const errorMap = {
'23505': { statusCode: 409, message: 'Duplicate entry' },
'23503': { statusCode: 400, message: 'Referenced record does not exist' },
'23502': { statusCode: 400, message: 'Required field missing' },
'22P02': { statusCode: 400, message: 'Invalid data format' },
};
return errorMap[code] || { statusCode: 500, message: 'Database error' };
}
}
// database-exception.filter.ts
@Catch(QueryFailedError)
export class DatabaseExceptionFilter implements ExceptionFilter {
catch(exception: QueryFailedError, host: ArgumentsHost) {
const errorCode = (exception.driverError as any)?.code;
const constraint = (exception.driverError as any)?.constraint;
const { statusCode, message } = DatabaseErrorMapper.mapPostgresError(errorCode, constraint);
// ...
}
}
7. Інтеграція з APM через interceptor
// apm.interceptor.ts
@Injectable()
export class ApmInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
return next.handle().pipe(
catchError(error => {
// Додавання метаданих до помилки
error['metadata'] = {
controller: context.getClass().name,
handler: context.getHandler().name,
method: request.method,
url: request.url,
userId: request.user?.id,
};
throw error;
}),
);
}
}
// В exception filter витягуйте метадані
const metadata = exception['metadata'];
Sentry.setContext('request', metadata);
Підсумок
🎯 ValidationExceptionFilter
Обробляє BadRequestException з помилками class-validator. Форматує список полів з constraints у структурований JSON. Групує помилки за полями для зручності відображення у формах.
Приклад:
@Catch(BadRequestException)
export class ValidationExceptionFilter { /* ... */ }
💾 DatabaseExceptionFilter
Трансформує помилки TypeORM/Prisma (QueryFailedError) у зрозумілі HTTP-відповіді. Мапить коди помилок PostgreSQL (23505 → 409 Conflict). Приховує SQL-деталі від клієнта.
Приклад:
@Catch(QueryFailedError)
export class DatabaseExceptionFilter { /* ... */ }
📡 Sentry Integration
Автоматична відправка 5xx помилок у Sentry з контекстом запиту, користувача, тегами. Додавання Breadcrumbs через interceptor для трейсингу виконання. Збагачення помилок метаданими.
Приклад:
Sentry.captureException(error, { extra: { userId, url } });
⏱️ TimeoutExceptionFilter
Обробляє RequestTimeoutException та TimeoutError з RxJS. Повертає 408 Request Timeout з підказками для клієнта. Використовується з TimeoutInterceptor.
Приклад:
@Catch(TimeoutError, RequestTimeoutException)
export class TimeoutExceptionFilter { /* ... */ }
🔒 UnauthorizedExceptionFilter
Спеціалізована обробка 401 помилок. Додає WWW-Authenticate заголовок. Логує спроби несанкціонованого доступу з IP та User-Agent. Надає action hints для логіну/реєстрації.
Приклад:
response.setHeader('WWW-Authenticate', 'Bearer realm="api"');
🧩 Filter Composition
Комбінування кількох filters через APP_FILTER токен. Порядок виконання: специфічний → загальний. Catch-all filter (@Catch()) реєструється останнім для обробки необроблених помилок.
Приклад:
{ provide: APP_FILTER, useClass: ValidationExceptionFilter },
{ provide: APP_FILTER, useClass: SentryExceptionFilter }, // Останній
📊 Datadog APM
Інтеграція з Datadog для APM (Application Performance Monitoring). Додавання тегів до span: error: true, http.status_code. Логування з trace_id та span_id для кореляції.
Приклад:
span.setTag('error', true);
span.setTag('http.status_code', 500);
🧪 Testing Filters
Unit-тести з мокуванням ArgumentsHost. E2E-тести для перевірки композиції filters. Тестування mapping помилок БД, форматування валідації, інтеграції з Sentry.
Приклад:
expect(mockStatus).toHaveBeenCalledWith(409);
expect(mockJson).toHaveBeenCalledWith({ message: 'Duplicate' });
Часті запитання (FAQ)
Використовуйте regex для парсингу назви constraint:
private extractFieldName(constraint: string): string {
// constraint format: "table_field_key"
const match = constraint?.match(/^(.+?)_(.+?)_key$/);
return match ? match[2] : 'field';
}
// Використання
const field = this.extractFieldName('users_email_key'); // "email"
Альтернативно, витягуйте з exception.message:
const match = exception.message.match(/Key \((.+?)\)=/);
const field = match ? match[1] : 'unknown';
Залежить від статус-коду:
- 4xx (Client Errors): логувати на рівні
warnабо не логувати взагалі (це очікувані помилки) - 5xx (Server Errors): логувати на рівні
errorз повним стеком та контекстом
const logLevel = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'log';
this.logger[logLevel]('Exception occurred', { /* ... */ });
Рекомендація: логуйте 401/403 для виявлення атак, але не логуйте 404 (зайвий шум).
Мокуйте Sentry у тестах:
jest.mock('@sentry/node', () => ({
captureException: jest.fn(),
withScope: jest.fn(callback => callback({
setExtra: jest.fn(),
setUser: jest.fn(),
setTag: jest.fn(),
})),
}));
import * as Sentry from '@sentry/node';
it('should send error to Sentry', () => {
const exception = new Error('Test error');
filter.catch(exception, host);
expect(Sentry.captureException).toHaveBeenCalledWith(exception);
});
Створіть спеціалізований filter:
export class ExternalApiException extends HttpException {
constructor(
public readonly service: string,
public readonly statusCode: number,
message: string,
) {
super({ service, statusCode, message }, HttpStatus.BAD_GATEWAY);
}
}
@Catch(ExternalApiException)
export class ExternalApiExceptionFilter implements ExceptionFilter {
catch(exception: ExternalApiException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
response.status(502).json({
statusCode: 502,
error: 'External Service Error',
message: `${exception.service} service unavailable`,
originalStatus: exception.statusCode,
timestamp: new Date().toISOString(),
});
}
}
// Використання у сервісі
try {
const data = await axios.get('https://external-api.com/data');
return data.data;
} catch (error) {
throw new ExternalApiException('ExternalAPI', error.response?.status, error.message);
}
Так, налаштуйте ValidationPipe з кастомним exceptionFactory:
// main.ts
app.useGlobalPipes(
new ValidationPipe({
exceptionFactory: (errors) => {
const formattedErrors = errors.map(error => ({
field: error.property,
value: error.value,
errors: Object.values(error.constraints || {}),
}));
return new BadRequestException({
statusCode: 400,
error: 'Validation Failed',
fields: formattedErrors,
});
},
}),
);
Тоді ValidationExceptionFilter отримає вже структуровані помилки.
Використовуйте middleware для генерації requestId:
// request-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
req.id = uuidv4();
res.setHeader('X-Request-Id', req.id);
next();
}
}
// app.module.ts
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
// В exception filter
const errorResponse = {
statusCode: status,
message: exception.message,
requestId: request.id, // UUID з middleware
timestamp: new Date().toISOString(),
};
Для асинхронних jobs (Bull, BullMQ) створіть окремий error handler:
// job-error.handler.ts
import { Injectable, Logger } from '@nestjs/common';
import * as Sentry from '@sentry/node';
@Injectable()
export class JobErrorHandler {
private readonly logger = new Logger(JobErrorHandler.name);
handleJobError(job: any, error: Error) {
this.logger.error(`Job ${job.name} failed`, {
jobId: job.id,
data: job.data,
error: error.message,
stack: error.stack,
});
Sentry.captureException(error, {
tags: { job: job.name },
extra: { jobId: job.id, data: job.data },
});
}
}
// email.processor.ts
@Processor('email')
export class EmailProcessor {
constructor(private errorHandler: JobErrorHandler) {}
@Process('send')
async handleSend(job: Job) {
try {
await this.emailService.send(job.data);
} catch (error) {
this.errorHandler.handleJobError(job, error);
throw error; // Bull помітить як failed job
}
}
}
Створіть централізований logging service:
// error-logging.service.ts
@Injectable()
export class ErrorLoggingService {
private readonly logger = new Logger(ErrorLoggingService.name);
private loggedErrors = new Set<string>();
logError(exception: unknown, context: any) {
const errorKey = this.generateErrorKey(exception, context);
// Уникнення дублювання
if (this.loggedErrors.has(errorKey)) {
return;
}
this.loggedErrors.add(errorKey);
setTimeout(() => this.loggedErrors.delete(errorKey), 5000);
this.logger.error('Exception occurred', {
message: exception instanceof Error ? exception.message : 'Unknown',
stack: exception instanceof Error ? exception.stack : undefined,
...context,
});
}
private generateErrorKey(exception: unknown, context: any): string {
const message = exception instanceof Error ? exception.message : 'unknown';
return `${message}-${context.url}`;
}
}
// Використання в filters
@Catch()
export class LoggingExceptionFilter implements ExceptionFilter {
constructor(private errorLogging: ErrorLoggingService) {}
catch(exception: unknown, host: ArgumentsHost) {
const request = host.switchToHttp().getRequest();
this.errorLogging.logError(exception, { url: request.url });
// ...
}
}
У наступній лекції 16. Execution Context ми розглянемо API ExecutionContext, методи ArgumentsHost, роботу з Reflector для витягування метаданих та практичне застосування у Guards, Interceptors та Filters.