⚡ Раздел 7 · Вопрос #18

Как правильно логировать исключения

Правильное логирование исключений строится на трёх базовых принципах:


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Правильное логирование исключений строится на трёх базовых принципах:

  1. Использовать фасад логирования (SLF4J) вместо e.printStackTrace() или System.err.
  2. Передавать исключение последним аргументом (log.error("Failed to process orderId={}", orderId, e);). В SLF4J для исключения не нужно ставить заполнитель {} — библиотека автоматически извлечет и отформатирует полный стек-трейс.
  3. Соблюдать золотое правило “Log OR Throw, never both”: либо перехватить и залогировать исключение (если вы его обрабатываете), либо обернуть и пробросить дальше (если обрабатывать должен вызывающий слой). Одновременный лог и проброс дублирует стек-трейс на каждом слое архитектуры.

Канонический пример на Java 21 со SLF4J

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderProcessingService {
    private static final Logger log = LoggerFactory.getLogger(OrderProcessingService.class);

    public void processPayment(String orderId, double amount) {
        try {
            executeGatewayCharge(orderId, amount);
        } catch (PaymentGatewayException e) {
            // Исключение e передается ПОСЛЕДНИМ аргументом БЕЗ фигурных скобок {}
            log.error("Failed to charge credit card for orderId={}, amount={}", orderId, amount, e);
            throw new OrderBusinessException("Payment failed", e); // Либо логируем, либо бросаем!
        }
    }

    private void executeGatewayCharge(String id, double amount) throws PaymentGatewayException {
        // Логика взаимодействия
    }
}

Градация уровней логирования для исключений

Уровень Когда применять для исключений Пример ситуации
ERROR Неожиданные системные сбои, угроза доступности, инфраструктурные падения. Требует алерта дежурному инженеру. DatabaseConnectionException, NullPointerException, падение брокера Kafka
WARN Ожидаемые деградации системы, временные повторы (retry), срабатывание fallback-механизмов, исчерпание лимитов. Ошибка вызова внешнего API при наличии Circuit Breaker / Retry
INFO / DEBUG Ожидаемые клиентские и бизнес-ошибки, не являющиеся багом системы. Неверный пароль при аутентификации, 404 Not Found, отмена операции клиентом

🟡 Middle Level

Тонкости SLF4J: ловушка заполнителя {}

Одна из самых частых ошибок на собеседованиях:

// ❌ ГРУБАЯ ОШИБКА: стек-трейс НЕ будет залогирован!
// SLF4J подставит e в {} через toString() -> "Error: java.lang.NullPointerException: null"
log.error("Error occurred: {}", e);

// ❌ ПЛОХО: исключение превращено в строку вручную, стек потерян
log.error("Error: " + e.getMessage());

// ✅ ПРАВИЛЬНО: SLF4J проверяет последний аргумент, видит Throwable и печатает стек
log.error("Error occurred", e);

// ✅ ПРАВИЛЬНО с параметрами: для e фигурные скобки НЕ НУЖНЫ
log.error("Error processing orderId={} for userId={}", orderId, userId, e);

Антипаттерн “Log and Throw” (Залогируй и пробрось)

Если каждый разработчик в команде логирует исключение в блоке catch, а затем пробрасывает его выше, лог-файл засоряется лавиной одинаковых ошибок:

// ❌ АНТИПАТТЕРН: один инцидент будет залогирован 3 РАЗА!
// Слой DAO:
catch (SQLException e) {
    log.error("DAO error", e);
    throw new RepositoryException(e);
}
// Слой Service:
catch (RepositoryException e) {
    log.error("Service error", e);
    throw new ServiceException(e);
}
// Слой Controller:
catch (ServiceException e) {
    log.error("Controller error", e); // Итого: 3 огромных одинаковых стектрейса в ELK!
}

// ✅ ИДИОМАТИЧНОЕ ПРАВИЛО:
// В нижних слоях: только оборачиваем и пробрасываем (throw new ServiceException(e))
// В верхнем слое (например, @RestControllerAdvice в Spring): логируем ОДИН РАЗ

MDC (Mapped Diagnostic Context) и распределенная трассировка

В распределенных микросервисах стек-трейс бесполезен без контекста запроса. Механизм org.slf4j.MDC позволяет сохранять метаданные в контексте текущего потока:

import org.slf4j.MDC;

public class RequestInterceptor {
    public void handleRequest(String traceId, String userId, Runnable action) {
        MDC.put("traceId", traceId);
        MDC.put("userId", userId);
        try {
            action.run();
        } finally {
            // КРИТИЧЕСКИ ВАЖНО: очищать MDC при возврате потока в пул!
            MDC.clear();
        }
    }
}

Все логи (включая сообщения об исключениях), вызванные внутри action.run(), будут автоматически обогащены полями traceId и userId в Logback / Log4j2.


Структурированное логирование (JSON Layout)

Для поиска в Elasticsearch / Kibana / Grafana Loki логи форматируются в структурированный JSON через logstash-logback-encoder:

<appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>traceId</includeMdcKeyName>
        <includeMdcKeyName>userId</includeMdcKeyName>
    </encoder>
</appender>

Это разделяет строку лога на индексируемые поля: exception_class, exception_message, stack_trace, предотвращая разбиение стектрейса на десятки отдельных строк.


🔴 Senior Level

Защита от Log Flooding и Log Sampling

При аварии инфраструктуры (например, отказ базы данных под нагрузкой 20 000 RPS) сервис рискует сгенерировать гигабайты логов за секунды, переполнив диск и вызвав отказ хоста (Disk Full Outage).

Решения для Highload:

  1. Duplicate Message Filter (Logback): Ограничивает количество повторов идентичных стеков в единицу времени.
    <filter class="ch.qos.logback.classic.filter.DuplicateMessageFilter">
        <AllowedRepetitions>5</AllowedRepetitions>
    </filter>
    
  2. Асинхронные аппендеры (AsyncAppender / LMAX Disruptor): Вывод логов отделяется от рабочего потока через кольцевой буфер. Параметр discardingThreshold указывает сбрасывать логи уровня INFO и DEBUG при заполнении очереди более чем на 80%, резервируя ресурсы под ERROR.

Безопасность: Log Scrubbing и защита PII (Personally Identifiable Information)

Попадание в логи персональных данных (номера банковских карт, пароли, паспортные данные, JWT-токены) влечет уголовную ответственность и штрафы регуляторов (PCI DSS, GDPR).

// ❌ КРИТИЧЕСКАЯ УЯЗВИМОСТЬ: утечка сырого токена в лог
log.error("Auth failed for token: {}", rawJwtToken, e);

// ✅ ПРАВИЛЬНО: логировать только идентификаторы или хеши
log.error("Auth failed for principalId: {}", principalId, e);

В конфигурации логгера настраиваются паттерны маскирования (MaskingConverter), автоматически заменяющие шаблоны кредитных карт (\b(?:\d{4}[ -]?){3}\d{4}\b) на ****-****-****-****.


Virtual Threads (Java 21) и контекст логирования

С появлением виртуальных потоков (Project Loom) традиционный ThreadLocal-контекст (на котором базируется MDC) сталкивается с проблемами:

  • Создание миллионов виртуальных потоков с тяжелыми ThreadLocal-картами создает нагрузку на сборщик мусора.
  • В Java 21 представлен механизм Scoped Values (JEP 429/446): легковесные, иммутабельные значения, привязанные к скоупу выполнения, которые заменяют ThreadLocal и безопасны для параллельных задач внутри виртуальных потоков.

🎯 Шпаргалка для интервью

Вопросы для быстрой проверки

  1. Что произойдет, если написать log.error("Failed: {}", e) вместо log.error("Failed", e)? Ответ: В первом случае объект исключения подставится в фигурные скобки через метод e.toString(), и стек-трейс не будет напечатан. Во втором случае SLF4J распознает Throwable как последний аргумент и напечатает полный стек-трейс.
  2. В чем заключается вред антипаттерна “Log and Throw”? Ответ: Исключение логируется на каждом слое архитектуры (репозиторий, сервис, контроллер). В результате в логах появляется несколько огромных дублирующихся стек-трейсов для одного и того же сбоя, что искажает статистику мониторинга и затрудняет расследование инцидентов.
  3. Почему в блоке finally обязательно вызывать MDC.clear()? Ответ: Потоки веб-серверов (Tomcat, Jetty) переиспользуются из пула потоков. Если не очистить MDC, контекст предыдущего пользователя (userId, traceId) «прилипнет» к следующему запросу, выполняемому на этом же потоке.
  4. На каком уровне логирования следует фиксировать ошибку неверного пароля пользователя? Ответ: На уровне WARN или INFO. Это нормальный ожидаемый сценарий взаимодействия с клиентом. Логирование таких ошибок на уровне ERROR приведет к ложным срабатываниям дежурных алертов (Alert Fatigue).

Типичные ошибки и Red Flags

  • ❌ Использование e.printStackTrace() или System.out.println() вместо логгера: Признак отсутствия опыта в production-системах.
  • ❌ Логирование паролей, CVV кодов и личных данных (PII) в сообщениях ошибок: Грубое нарушение требований безопасности.
  • ❌ Практика “Log and Throw” (одновременное логирование и проброс): Засорение логов каскадными дублями стектрейсов.
  • ❌ Логирование log.error(e.getMessage()): Потеря имени класса исключения и полного стек-трейса.

Связанные темы