Exception filters и структурное логирование
Один глобальный @Catch()-filter мапит каждый выброс в единый JSON-конверт ошибки с requestId; структурный JSON-логгер плюс correlation id в AsyncLocalStorage делают один запрос прослеживаемым через множество лог-строк и сервисов.
2:14 ночи, пейдж от on-call: «checkout периодически отдаёт 500». Открываешь логи и видишь стену строк console.log без таймстемпов, без границ запросов и без способа понять, какая строка относится к тому единственному запросу, что упал. Сама ошибка? Хендлер поймал её, залогировал err.message голым console.error, потом перебросил, так что один и тот же стек залогировали трижды три слоя — а ответ, ушедший клиенту, был HTTP 200 с {"error":"something went wrong"}, потому что кто-то вызвал res.json(...) и забыл res.status(...). Грепом отсюда не выбраться. Проблема не в баге; проблема в том, что у сервиса нет ни границы ошибок, ни корреляции. Тебе придётся построить и то и другое, прежде чем ты вообще увидишь баг.
Глобальный filter: одна граница ошибок, один конверт
Когда что-то в pipeline выбрасывает, встроенный слой исключений Nest ловит это: HttpException и его наследники мапятся на свой статус-код, а всё остальное становится 500. Кастомный @Catch()-filter, зарегистрированный глобально, позволяет владеть этой формой ответа один раз, вместо форматирования ошибок руками в девятнадцати хендлерах. Контракт: прочитай статус, выдай один единый JSON-конверт, выстави статус правильно.
import {
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { getRequestId } from './request-context';
@Catch() // без аргумента -> ловит ВСЁ, не только HttpException
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const requestId = getRequestId();
// Логируем ОДИН раз, здесь на границе, с полным контекстом — включая стек.
this.logger.error(
{ requestId, path: req.url, status, err: exception },
exception instanceof Error ? exception.stack : 'non-error thrown',
);
// Никогда не утекай внутренности на 500: в проде отдай общее сообщение.
// getResponse() возвращает string | object (payload HttpException обычно
// { statusCode, message, error }), поэтому нормализуем к строке-сообщению.
const detail =
exception instanceof HttpException ? exception.getResponse() : null;
const message =
status === HttpStatus.INTERNAL_SERVER_ERROR
? 'Internal server error'
: typeof detail === 'string'
? detail
: ((detail as { message?: unknown }).message ?? 'Error');
res.status(status).json({ // <-- .status() здесь несущий
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: req.url,
requestId,
});
}
}Две сеньорские детали. Первая: res.status(status).json(...) — вызови res.json(...) без res.status(...), и Express дефолтит HTTP-статус в 200, так что упавший запрос уедет с успешным статусом и телом ошибки, а каждый клиент, который ветвится по статус-коду, посчитает сбой успехом. Вторая: на 500 ты возвращаешь клиенту общее "Internal server error", но логируешь полный стек с контекстом на сервере: утечка стек-трейса или сырого err.message (где может быть фрагмент SQL, путь к файлу или внутренний хостнейм) — это баг раскрытия информации. Мапь известные доменные ошибки на безопасные наследники HttpException (NotFoundException, ConflictException); пусть всё остальное проваливается в намеренно расплывчатый 500.
Регистрируй его глобально через токен APP_FILTER, чтобы он мог использовать dependency injection:
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './all-exceptions.filter';
@Module({
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],
})
export class AppModule {}Структурное логирование: JSON вместо console.log
Почему формат лог-строк важен в 2 ночи, когда всё горит? Потому что их должна читать машина — а машина не парсит свободные строки. console.log('user', userId, 'failed checkout') выдаёт строку, которую прочтёт человек, но не машина. Замени встроенный логгер на структурный JSON-логгер — nestjs-pino (тонкая Nest-обёртка над pino) — частый выбор — выставленный в bootstrap с bufferLogs: true, чтобы даже логи, выданные во время старта, прошли через твой логгер, как только он будет готов:
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger)); // подменяем дефолтный логгер Nest на pino
await app.listen(3000);
}
bootstrap();Теперь каждая лог-строка — JSON-объект — {"level":"error","time":...,"requestId":"...","msg":"..."} — который твоя лог-платформа (Loki, CloudWatch, Datadog) парсит в запрашиваемые поля. Можно фильтровать level >= warn, группировать по path или вытащить каждую строку для одного requestId одним запросом. Со свободными строками это невозможно. Соблюдай уровни лога (debug/log/warn/error), чтобы прод мог отбросить debug и ты не платил за хранение шума. (Собственный ConsoleLogger Nest тоже поддерживает new ConsoleLogger({ json: true }), если не хочешь зависимость, — но pino даёт редактирование чувствительных полей и куда более высокую пропускную способность.)
Корреляция: один request id во всех строках
Недостающее звено в хуке — это корреляция: нет способа связать двенадцать лог-строк одного запроса между собой, не говоря о том, чтобы проследить этот запрос в downstream-сервис. Решение — per-request id, сгенерированный один раз на краю и сохранённый в AsyncLocalStorage (встроенный Node-механизм хранения данных, привязанных к асинхронной цепочке вызовов), чтобы любой код — твой filter, глубокий метод сервиса, исходящий HTTP-вызов — мог прочитать его, не протаскивая через сигнатуру каждой функции.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { Request, Response, NextFunction } from 'express';
export const als = new AsyncLocalStorage<{ requestId: string }>();
export const getRequestId = () => als.getStore()?.requestId ?? 'no-request-id';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Переиспользуй id сверху, если он есть (трассировка между сервисами), иначе выпусти новый.
const requestId = (req.headers['x-request-id'] as string) ?? randomUUID();
res.setHeader('x-request-id', requestId);
als.run({ requestId }, () => next()); // store переживает каждый await в цепочке
}
}Поскольку middleware оборачивает next() внутрь als.run(...), store жив всё асинхронное время жизни этого запроса, и getRequestId() возвращает один и тот же id в твоём логгере и filter без всякого протаскивания параметров. Что важно, middleware пробрасывает входящий заголовок x-request-id, а не всегда выпускает новый, — так что когда сервис A зовёт сервис B с этим заголовком, оба сервиса логируют один id, и ты можешь сшить путь одного запроса через всю систему. Настрой структурный логгер так, чтобы он цеплял requestId к каждой строке (pino mixin или genReqId), и корреляция станет автоматической.
Где живёт каждая ответственность
Весь урок — это одно решение о размещении: форма ошибки принадлежит глобальному filter, логирование ошибки — структурному логгеру, вызванному один раз на этой границе, а корреляция — request-scoped контексту. Поставь их не туда — и получишь хук: трижды залогированные стеки, 200 на сбое и непрослеживаемые запросы.
| Ответственность | Где живёт | Почему там | Анти-паттерн |
|---|---|---|---|
| Форма ошибки | Глобальный @Catch()-filter | Единый сток для каждого выброса → один конверт | Форматирование руками в хендлере |
| Статус-код | res.status(s).json(…) | Пропуск .status() → Express дефолтит 200 | Один res.json(…) |
| Логирование | Структурный логгер, один раз в filter | JSON запрашиваем; один лог избегает дублей стека | console.log в каждом слое |
| Корреляция | Request id в AsyncLocalStorage | Один id во всех строках + downstream-вызов | Нет id; непрослеживаемые запросы |
| Сообщение 500 | Общее клиенту, полный стек в логи | Стек/err.message утекают внутренности | Возврат сырого стека |
▸Почему это работает
Почему AsyncLocalStorage, а не просто req? Ты мог бы положить id на req и таскать req повсюду — но это значит протаскивать объект запроса через каждый метод сервиса, репозиторий и хелпер, который может захотеть логировать, что связывает весь стек с HTTP-слоем. AsyncLocalStorage даёт тебе окружающий, request-scoped store, который переживает каждый await: глубокий PaymentService.charge() может вызвать getRequestId() без аргументов и получить верный id, хотя он понятия не имеет, что HTTP-запрос вообще существует. Это node-примитив, который делает корреляцию возможной без засорения сигнатур, — та же идея, что thread-local storage в других рантаймах.
Нужна единая форма ответа-ошибки, полезные логи и возможность проследить один запрос между сервисами. Где живут форматирование ошибок и логирование?
Твой filter вызывает res.json({ statusCode: 500, message: 'oops' }), но никогда не вызывает res.status(...). Какой HTTP-статус получит клиент?
Голый throw new Error('boom') (не HttpException) доходит до filter, декорированного @Catch(HttpException). Что произойдёт?
- 01Опиши production-grade глобальный exception filter: что он ловит, какой конверт выдаёт, как ставит статус, что логирует и что НЕ должен утекать.
- 02Почему структурное JSON-логирование лучше console.log и как AsyncLocalStorage даёт correlation id через весь запрос и между сервисами?
Ошибки и observability сводятся к трём размещениям. Первое, форма ошибки: зарегистрируй один глобальный AllExceptionsFilter через токен APP_FILTER, декорированный @Catch() (без аргумента), чтобы он ловил всё; мапь HttpException на его статус, а всё остальное на 500, и выдавай один единый JSON-конверт { statusCode, message, timestamp, path, requestId } через res.status(status).json(…) — забудь .status(), и Express отправит 200 на сбое. Второе, логирование: замени console.log на структурный JSON-логгер (nestjs-pino, выставленный в bootstrap с bufferLogs: true и app.useLogger), потому что JSON-строки машинно-запрашиваемы, а уровни позволяют проду отбросить шум; логируй ошибку ровно один раз на границе filter с полным контекстом и стеком, чтобы одна и та же ошибка не логировалась трижды. Не утекай внутренности — возвращай клиенту общее сообщение на 500, логируя полный стек на сервере, и мапь известные доменные ошибки на безопасные HTTP-формы. Третье, корреляция: middleware выпускает или переиспользует per-request id и хранит его в AsyncLocalStorage, чтобы каждая лог-строка и конверт ошибки несли один и тот же id; пробрось входящий заголовок x-request-id, чтобы один запрос был прослеживаем через хендлеры и downstream-сервисы. Форма на границе, лог один раз, корреляция по id. Теперь, когда следующей ночью прилетит пейдж и перед тобой окажется стена несвязанных строк — первое, что ты сделаешь, это возьмёшь requestId, и всё сложится на своих местах.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.