⚡ Розділ 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

Під час масштабної аварії інфраструктури (наприклад, збій кластера PostgreSQL під навантаженням 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)

Потрапляння в логи персональних або секретних даних (номери платіжних карток, паролі, паспортні дані, 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-картами створює додаткове навантаження на Garbage Collector.
  • У 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()): Втрата типу класу винятку та повного стек-трейсу з номерами рядків.

Пов’язані теми