Опирается на правила:
R-ERR-LOG-1…R-ERR-LOG-4иR-ERR-LOG-X1…R-ERR-LOG-X2из Error Handling Style Guide → раздел 4. Логирование исключений.
Важно знать
DomainError→logger.warn(...)— ожидаемая ошибка, не баг. ERROR создаст ложные срабатывания алёртов.IntegrationError→logger.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/service | R-ERR-LOG-X1 | Убрать catch — пусть летит до фильтра; фильтр логирует |
logger.error(e.message) без e.stack | R-ERR-LOG-X2 | logger.error({ stack: e.stack, ... }) или передать объект целиком |
logger.error(...) для DomainError | R-ERR-LOG-1 | logger.warn(...) — ожидаемая ошибка, не баг |
| Логирование на каждом уровне call stack | R-ERR-LOG-4 | Один раз в Exception Filter на edge |
| PII в логах (email, номер карты, пароль) | AUTH-16 | redact в nestjs-pino config; маскировать до передачи в логгер |
logger.info(e) для технической ошибки | R-ERR-LOG-3 | logger.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.