Опирается на правила: R-ERR-LOG-1R-ERR-LOG-4 и R-ERR-LOG-X1R-ERR-LOG-X2 из Error Handling Style Guide → раздел 4. Логирование исключений.

Важно знать

  • DomainErrorlogger.warn(...) — ожидаемая ошибка, не баг. ERROR создаст ложные срабатывания алёртов.
  • IntegrationErrorlogger.warn(...) если CB закрыт (одиночный fail); logger.error(...) если CB открылся (инцидент).
  • TechnicalError и catch-all → logger.error(...) + полный stacktrace + контекст (requestId, customerId, operation).
  • Логируем один раз — в Exception Filter на edge. Не на каждом уровне call stack.
  • logger.error(e); throw e; — двойное логирование: либо логируй и обработай, либо проброс без логирования.
  • logger.error(e.message) без e.stack / объекта — теряется stacktrace. Передавать объект целиком.
  • nestjs-pino — JSON-логгер с автоматическим correlation через AsyncLocalStorage.

Логирование — это не «пишем везде на всякий случай». Это дисциплина: нужный уровень, нужный контекст, один раз. Лишний logger.error в handler'е создаёт ложный инцидент. Потерянный stacktrace в logger.error(e.message) делает отладку Production-инцидента вдвое дольше. Раскрытие правил R-ERR-LOG-* ниже.

Настройка nestjs-pino

nestjs-pino — JSON-логгер для NestJS на базе pino. Автоматически включает request context (requestId, method, url) в каждое лог-сообщение:

// app.module.ts
import { LoggerModule } from 'nestjs-pino';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
        transport: process.env.NODE_ENV !== 'production'
          ? { target: 'pino-pretty' }
          : undefined,
        redact: ['req.headers.authorization', 'req.headers.cookie'],
        serializers: {
          err: (err: Error) => ({
            type: err.name,
            message: err.message,
            stack: err.stack,
          }),
        },
      },
    }),
  ],
})
export class AppModule {}

redact — обязательно. PII не должен попасть в логи (AUTH-16).

В Exception Filters объявляем логгер в фильтре:

@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(DomainErrorFilter.name);
  // ...
}

DomainError — warn

R-ERR-LOG-1: DomainError — ожидаемая ошибка бизнес-правила. Это не баг, это нормальный исход операции с неверными preconditions. logger.error здесь создаёт ложные алёрты — в Prometheus будет рост error-серий, но инцидента нет.

// edge/filters/domain-error.filter.ts
@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(DomainErrorFilter.name);

  catch(err: DomainError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    this.logger.warn({
      msg: 'domain rule violated',
      errorName: err.name,
      errorMessage: err.message,
    });
    appErrorsTotal.inc({ type: 'domain', exception: err.name });
    sendProblem(res, 422, 'Operation cannot be completed', err.message, {
      type: `https://api.example.com/errors/${toKebabCase(err.name)}`,
    });
  }
}

Пример: клиент Сбера пытается пополнить счёт на сумму, превышающую суточный лимит. DailyLimitExceededError — ожидаемый исход, warn в логе, 422 клиенту.

IntegrationError — warn или error в зависимости от CB

R-ERR-LOG-2: одиночный fail внешней системы — warn. Circuit breaker открылся — error.

// adapters/out/payment/payment.resilient.adapter.ts
this.cbPolicy.onBreak(() => {
  logger.error({
    msg: 'PaymentGateway circuit breaker opened',
    system: 'payment-gateway',
  });
  appErrorsTotal.inc({ type: 'integration', exception: 'PaymentGatewayUnavailableError' });
});

this.cbPolicy.onReset(() => {
  logger.info({
    msg: 'PaymentGateway circuit breaker closed',
    system: 'payment-gateway',
  });
});
// edge/filters/payment-gateway.filter.ts
@Catch(PaymentGatewayError)
export class PaymentGatewayErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(PaymentGatewayErrorFilter.name);

  catch(err: PaymentGatewayError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    this.logger.warn({
      msg: 'payment gateway error',
      errorName: err.name,
      errorMessage: err.message,
    });
    appErrorsTotal.inc({ type: 'integration', exception: err.name });
    sendProblem(res, 502, 'Payment service unavailable',
      `Payment system error. traceId: ${getTraceId()}`);
  }
}

@Catch(PaymentGatewayUnavailableError)
export class PaymentGatewayUnavailableFilter implements ExceptionFilter {
  private readonly logger = new Logger(PaymentGatewayUnavailableFilter.name);

  catch(err: PaymentGatewayUnavailableError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    this.logger.error({
      msg: 'payment gateway circuit breaker open',
      errorName: err.name,
      errorMessage: err.message,
    });
    appErrorsTotal.inc({ type: 'integration', exception: err.name });
    sendProblem(res, 503, 'Payment service temporarily unavailable',
      `Payment system is temporarily unavailable. traceId: ${getTraceId()}`);
  }
}

Логика разграничения:

  • PaymentGatewayError — единственный 5xx, CB ещё закрыт. warn. Это нормальный шум внешней системы.
  • PaymentGatewayUnavailableError — CB открылся после N consecutive failures. error. Инцидент — система деградирует.

TechnicalError и catch-all — error + stacktrace + контекст

R-ERR-LOG-3: технические ошибки — всегда error с полным контекстом.

// edge/filters/technical-error.filter.ts
@Catch(TechnicalError)
export class TechnicalErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(TechnicalErrorFilter.name);

  catch(err: TechnicalError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    const req = host.switchToHttp().getRequest<Request>();
    this.logger.error({
      msg: 'technical error',
      errorName: err.name,
      stack: err.stack,
      requestId: req.headers['x-request-id'],
      path: req.path,
      method: req.method,
    });
    appErrorsTotal.inc({ type: 'technical', exception: err.name });
    sendProblem(res, 500, 'Internal Server Error',
      `An internal error occurred. traceId: ${getTraceId()}`);
  }
}
// edge/filters/unexpected.filter.ts
@Catch()
export class UnexpectedFilter implements ExceptionFilter {
  private readonly logger = new Logger(UnexpectedFilter.name);

  catch(err: unknown, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    const req = host.switchToHttp().getRequest<Request>();
    const name = err instanceof Error ? err.name : 'Unknown';
    const stack = err instanceof Error ? err.stack : String(err);
    this.logger.error({
      msg: 'unexpected error',
      errorName: name,
      stack,
      requestId: req.headers['x-request-id'],
      path: req.path,
      method: req.method,
    });
    appErrorsTotal.inc({ type: 'unexpected', exception: name });
    sendProblem(res, 500, 'Internal Server Error',
      `An unexpected error occurred. traceId: ${getTraceId()}`);
  }
}

Что обязательно в ERROR-логе:

  • stack — полный stacktrace. Не err.message — только с err.stack понятно, где именно упало.
  • requestId — из X-Request-Id заголовка или AsyncLocalStorage. Связывает лог с request.
  • path + method — контекст запроса. Без этого «unexpected error» в логе не информативен.
  • errorName — имя класса исключения. Нужно для Prometheus-метрики и фильтрации в Loki.

Один раз — в фильтре на edge

R-ERR-LOG-4: логируем один раз — в Exception Filter на edge. Не на каждом уровне call stack.

// ПЛОХО — двойное логирование
@Injectable()
export class CancelOrderHandler {
  async handle(cmd: CancelOrderCommand): Promise<void> {
    try {
      await this.payment.refund(refundCmd);
    } catch (e) {
      this.logger.error('Refund failed', e);   // первый лог
      throw e;                                  // улетает в фильтр — второй лог
    }
  }
}

// PREFER — никакого try/catch в handler
@Injectable()
export class CancelOrderHandler {
  async handle(cmd: CancelOrderCommand): Promise<void> {
    const order = await this.orders.findById(cmd.orderId);
    if (!order) throw new OrderNotFoundError(cmd.orderId);
    order.cancel(cmd.reason);
    await this.orders.save(order);
    await this.payment.refund(buildRefundCmd(order));
  }
}

Если ошибка логируется на каждом уровне, один инцидент даёт 3-4 строки ERROR в логах. Это «шум» — выглядит как несколько независимых инцидентов, при этом на самом деле происходит одно событие.

Исключение — логирование до re-throw в out-adapter, если нужно записать контекст запроса к внешней системе до того, как он потеряется:

// adapters/out/catalog/catalog.client.adapter.ts
async getProduct(productId: string): Promise<Product> {
  try {
    const { data } = await this.http.axiosRef.get(`/products/${productId}`);
    return toDomainProduct(data);
  } catch (e) {
    if (axios.isAxiosError(e)) {
      logger.debug({
        msg: 'catalog request failed',
        productId,
        status: e.response?.status,
        axiosCode: e.code,
      });
      throw new CatalogPortError(`catalog error for ${productId}`, { cause: e });
    }
    throw e;
  }
}

debug в адаптере — не warn/error. Бизнес-значимый лог — в фильтре на edge.

Что запрещено

АнтипаттернПравилоЧто взамен
logger.error(e); throw e; в handler/serviceR-ERR-LOG-X1Убрать catch — пусть летит до фильтра; фильтр логирует
logger.error(e.message) без e.stackR-ERR-LOG-X2logger.error({ stack: e.stack, ... }) или передать объект целиком
logger.error(...) для DomainErrorR-ERR-LOG-1logger.warn(...) — ожидаемая ошибка, не баг
Логирование на каждом уровне call stackR-ERR-LOG-4Один раз в Exception Filter на edge
PII в логах (email, номер карты, пароль)AUTH-16redact в nestjs-pino config; маскировать до передачи в логгер
logger.info(e) для технической ошибкиR-ERR-LOG-3logger.error(...) + err.stack + контекст запроса

Почему logger.error(e.message) катастрофичен

// ПЛОХО
catch (err: unknown) {
  if (err instanceof Error) {
    this.logger.error(err.message);   // только текст, без stacktrace
  }
}

В производственном инциденте: знаем, что упало с сообщением "Cannot connect to database". Но в каком файле, на какой строке, через какую цепочку вызовов — неизвестно. Отладка занимает часы вместо минут.

// PREFER
catch (err: unknown) {
  if (err instanceof Error) {
    this.logger.error({ msg: 'technical error', stack: err.stack, name: err.name });
  }
}

С err.stack в логе — видим полный call stack, сразу находим источник.

Куда дальше

  • Где throw, где catch — почему catch только в трёх местах.
  • Иерархия исключений — типы и их смысл.
  • Mapping в ProblemDetails — что уходит клиенту vs что остаётся в логах.
  • Observability — метрики и трейсинг поверх логирования.
  • Retry-семантика — IntegrationError: warn vs error зависит от CB.
  • Result-types vs exceptions — альтернативы исключениям.
  • Observability Style Guide → R-OBS-* — structured logging, correlation, sampling.