open atlas
↑ К треку
NestJS с нуля до senior NEST · 07 · 01

Exception filters и структурное логирование

Один глобальный @Catch()-filter мапит каждый выброс в единый JSON-конверт ошибки с requestId; структурный JSON-логгер плюс correlation id в AsyncLocalStorage делают один запрос прослеживаемым через множество лог-строк и сервисов.

NEST Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

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(…)
ЛогированиеСтруктурный логгер, один раз в filterJSON запрашиваем; один лог избегает дублей стека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). Что произойдёт?

Вспомните перед уходом
  1. 01
    Опиши production-grade глобальный exception filter: что он ловит, какой конверт выдаёт, как ставит статус, что логирует и что НЕ должен утекать.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.