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

Важно знать

  • DomainException — WARN в edge-handler'е. Это ожидаемая ошибка (бизнес-правило сработало), не баг сервиса.
  • IntegrationException — WARN при одиночном сбое (CB ещё закрыт). ERROR когда CB открылся — это уже инцидент.
  • TechnicalException и catch-all (Throwable) — ERROR с полным stacktrace и структурным контекстом (request-id, customer-id, operation).
  • Логируем один раз — на edge. Не на каждом уровне call stack. Иначе одна ошибка превращается в 5 строк лога с разной полнотой контекста.
  • log.error("...", e); throw e; — двойное логирование запрещено. Либо логируй и обработай, либо проброс — выбирай.
  • log.error(e.getMessage()) без объекта e — теряется stacktrace. Используй log.error("Context: {}", value, e) — последний аргумент без {}.

Логирование исключения — это не «на всякий случай», это источник правды для оператора и алёрта. Перелогирование (одна ошибка в трёх местах) создаёт шум; недолог (catch без логирования) скрывает проблему. Уровень — это семантика для алёртов: WARN = «нормально, в норме»; ERROR = «надо смотреть». Раскрытие правил R-ERR-LOG-* ниже.

DomainException — WARN

R-ERR-LOG-1: бизнес-правило сработало. Это ожидаемая ситуация, не баг.

@ExceptionHandler(DomainException.class)
public ResponseEntity<ProblemDetail> handleDomain(DomainException ex) {
    log.warn("Domain rule violated: {}", ex.getMessage(), ex);        // ← WARN
    // ...
}

Почему не ERROR:

  • Ложные алёрты. ERROR-алёрты обычно настроены «звонить on-call». Если каждый InsufficientFundsException шлёт SMS дежурному в 3 часа ночи, оператор быстро отключит алёрты.
  • DomainException — это нормально. Пользователь нажал «Списать», денег не хватило — это ожидаемая ветка флоу, а не сбой. Логирование нужно для аналитики (сколько раз сегодня сработало это правило), но не для алёрта «срочно лечить».
  • Метрики разделяют. На WARN-исключения смотрим через app_errors_total{type="domain"}. Резкий рост = бизнес-условие изменилось (не уведомили). Стабильный baseline = всё ок.

IntegrationException — WARN или ERROR

R-ERR-LOG-2: зависит от состояния CircuitBreaker'а.

WARN — одиночный сбой во внешней системе, CB ещё закрыт. Пара 5xx в потоке нормальной нагрузки — норма (сети, deploy внешки, etc.).

@ExceptionHandler(SberRegisterException.class)
public ResponseEntity<ProblemDetail> handleSberRegister(SberRegisterException ex) {
    log.warn("Sber register failed: {}", ex.getMessage(), ex);        // ← WARN
    // ...
}

ERROR — CB открылся. Это уже инцидент: внешка деградировала достаточно, чтобы наш CB защитил основную нагрузку.

@ExceptionHandler(SberUnavailableException.class)
public ResponseEntity<ProblemDetail> handleSberUnavailable(SberUnavailableException ex) {
    log.error("Sber circuit breaker open", ex);                       // ← ERROR
    // ...
}

(SberUnavailableException бросается из fallback-метода после CallNotPermittedException. См. Где throw, где catch.)

На практике алёрт «CB открыт» лучше делать через метрику Resilience4j (resilience4j_circuitbreaker_state), а не через лог-агрегатор — метрика быстрее и надёжнее.

TechnicalException и catch-all — ERROR

R-ERR-LOG-3: внутренняя проблема — полный stacktrace и контекст.

@ExceptionHandler(TechnicalException.class)
public ResponseEntity<ProblemDetail> handleTechnical(TechnicalException ex) {
    log.error("Technical error", ex);                                 // ← ERROR + stacktrace
    // ...
}

@ExceptionHandler(Throwable.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Throwable ex) {
    log.error("Unexpected error — not in hierarchy", ex);             // ← ERROR + сигнал бага
    // ...
}

Что важно при ERROR:

  • Полный stacktrace. Передаём объект ex последним аргументом — SLF4J/Logback сам выведет stacktrace.
  • Структурный контекст через MDC. requestId, userId, traceId уже в MDC (см. Observability Style Guide → R-OBS-MDC-*) — Logback включает их в JSON-output.
  • Алёрт. ERROR-логи или unexpected-counter — основа алёртов on-call.

Логируем один раз — на edge

R-ERR-LOG-4: исключение логируется в @RestControllerAdvice, и больше нигде.

// ПЛОХО — двойное логирование
@UseCaseHandler
public Order handle(CancelOrderCommand cmd) {
    try {
        // ...
    } catch (DomainException e) {
        log.warn("Domain error in cancel", e);                        // ← лог №1
        throw e;
    }
}

@ExceptionHandler(DomainException.class)
public ResponseEntity<...> handleDomain(DomainException ex) {
    log.warn("Domain rule violated", ex);                             // ← лог №2 (одна и та же ошибка)
    // ...
}

Что не так:

  • Одна ошибка = два-три лога разного качества (где-то полный stacktrace, где-то только message). Оператор путается.
  • Метрика app_errors_total инкрементируется один раз (в advice), но лог-stream показывает «несколько ошибок» — путаница.
  • Контекст разный. На каждом уровне call stack доступны разные данные. Делать «полный лог» нужно один раз — на edge, где собран максимальный контекст.

Принцип: исключение проходит насквозь до edge без логирования. Edge — единственное место, где логируется.

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

R-ERR-LOG-X1: log.error("...", e); throw e; — двойное логирование.

// ПЛОХО
try {
    paymentPort.register(cmd);
} catch (IntegrationException e) {
    log.error("Payment failed", e);                                   // ← лог №1
    throw e;                                                           // ← дальше advice залогирует №2
}

Это самый частый антипаттерн. Кажется, что «полезно залогировать прямо тут на случай если выше не залогируют», но:

  • Advice обязательно залогирует (catch-all Throwable в crash-сценарии тоже залогирует).
  • Двойной лог попадает в Loki/ELK как два события, на алёртах оба считаются.

Правило выбора: либо логируй и обработай (catch + return fallback, без re-throw), либо проброс без лога. Не оба сразу.

R-ERR-LOG-X2: log.error(e.getMessage()) без объекта e.

// ПЛОХО
log.error("Failed: " + e.getMessage());                                // ← stacktrace потерян

Только message — без stacktrace. Получаем строку "Failed: relation \"orders\" does not exist", без где это произошло, через какой call chain, с какими параметрами.

Правильно:

// ХОРОШО — последний аргумент без {} — SLF4J включит stacktrace
log.error("Failed to register payment for order {}", cmd.orderId(), e);

SLF4J/Logback узнаёт, что последний аргумент — это Throwable (не плейсхолдер для {}), и автоматически добавит stacktrace к выводу. Это то, что нам нужно для ERROR-логов.

Куда дальше