Порядок уровней логирования в Java одинаков в SLF4J, Logback и Log4j2: TRACE < DEBUG < INFO < WARN < ERROR < FATAL. Логгер пишет сообщение только тогда, когда его уровень не ниже эффективного уровня самого логгера. При root level=INFO вызов logger.debug("...") не создаст ни одной строки в логе, хотя код выполнится полностью.
Рабочая схема для продакшена: root на INFO, отдельные пакеты на DEBUG только там, где разбирают инцидент. События WARN и ERROR уходят в отдельный файл с более длинным сроком хранения, а консольный appender остаётся для контейнеров и kubectl logs.
Ниже собраны готовые конфиги: logback.xml для XML-настройки, log4j2.properties для Log4j2, шаблоны MDC с requestId и userId, фильтры ThresholdFilter и LevelFilter, счётчики ошибок в Micrometer и чек-лист миграции с Log4j на Logback.
Уровни логирования в Java: полная иерархия и правила выбора
Шесть уровней покрывают весь путь сообщения: от трассировки вызовов до аварийного завершения процесса. Ниже таблица с примерами и рекомендациями, на каких стендах уровень уместен.
| Уровень | Что писать | Пример сообщения | Dev | Prod |
|---|---|---|---|---|
| TRACE | Вход и выход из методов, состояние циклов, полные тела HTTP-запросов к внешним API | "enter createOrder, items=12" | Да | Нет |
| DEBUG | Значения переменных, параметры SQL, выбранные ветки условий, содержимое кэша | "cache miss for key=user:42" | Да | Точечно |
| INFO | Старт и остановка приложения, успешные бизнес-операции, смена конфигурации | "User {} logged in" | Да | Да |
| WARN | Повторный запрос, устаревший вызов API, приближение к лимиту пула соединений | "Retry 2/3 for payment gateway" | Да | Да |
| ERROR | Ошибка, после которой приложение продолжает обслуживать трафик | "Order {} failed: timeout" | Да | Да |
| FATAL | Сбой, после которого процесс не может работать дальше | "Cannot bind port 8080" | Редко | Редко |
В SLF4J метода fatal() нет, в Logback класса FATAL тоже нет, он есть только в Log4j2. Практический вывод: на уровне ERROR в Logback пишите события, которые в Log4j2 уходили в FATAL, и не заводите отдельную ветку логики под этот уровень.
Пример расстановки вызовов в сервисе на SLF4J:
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
log.trace("enter createOrder, items={}", items.size());
log.debug("resolved warehouseId={} for sku={}", warehouseId, sku);
log.info("Order {} created, total={}", orderId, total);
log.warn("Payment gateway slow: {} ms, retry {}/3", elapsed, attempt);
log.error("Order {} failed, fallback to queue", orderId, ex);
Фигурные скобки в шаблоне подставляются только при включённом уровне, поэтому toString() у аргументов не вызывается впустую. Конкатенацию строк вида "Order " + orderId + " failed" в горячем коде не используйте: она выполняется всегда. Для дорогих вычислений оставляйте защиту: if (log.isDebugEnabled()) { ... }.
Как уровень логгера влияет на фильтрацию сообщений
У каждого логгера есть эффективный уровень. Если уровень не задан явно, он наследуется от ближайшего родителя в иерархии имён, вплоть до root. Сообщения ниже эффективного уровня отбрасываются до создания LoggingEvent, поэтому до appender они не доходят.
Пример: root level=INFO, logger com.example level=DEBUG. Тогда сообщения DEBUG из пакета com.example попадут в лог, а такие же DEBUG из com.other будут проигнорированы. Проверить, какой уровень оказался эффективным, можно программно: LoggerFactory.getLogger("com.example").isDebugEnabled() вернёт true, а для com.other тот же вызов даст false.
Уровень задаётся в конфиге, а не в коде. В Logback это элемент logger с атрибутом level, в Log4j2 строка logger.example.level = debug, в Spring Boot свойство logging.level.com.example=DEBUG, которое перекрывает значение из logback.xml.
Таблица соответствия уровней в SLF4J, Logback и Log4j2
| Уровень | SLF4J (метод API) | Logback (ch.qos.logback.classic.Level) | Log4j2 (org.apache.logging.log4j.Level) |
|---|---|---|---|
| TRACE | log.trace() | TRACE | TRACE |
| DEBUG | log.debug() | DEBUG | DEBUG |
| INFO | log.info() | INFO | INFO |
| WARN | log.warn() | WARN | WARN |
| ERROR | log.error() | ERROR | ERROR |
| FATAL | нет метода | нет константы | FATAL |
Порядок сравнения во всех трёх библиотеках совпадает, поэтому конфиг переносится между Logback и Log4j2 почти без правок. Единственное расхождение при миграции: вызовы log.fatal(...) нужно заменить на log.error(...) или на log.error(MarkerFactory.getMarker("FATAL"), ...), иначе проект не скомпилируется.
Настройка Logback через XML: готовый конфиг для production
Logback читает logback.xml из корня classpath. В Spring Boot берите logback-spring.xml, чтобы работали теги springProfile и springProperty. Ориентируйтесь на ветки Logback 1.5.x и SLF4J 2.0.x: связка SLF4J 1.7 остаётся только в legacy-проектах. Точный номер патча сверяйте в Maven Central перед деплоем, регрессии в связке 1.5.x случались именно на минорных обновлениях.
Пример logback.xml с комментариями
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Куда писать: консоль для контейнеров, файлы для виртуальных машин -->
<property name="LOG_DIR" value="${LOG_DIR:-./logs}"/>
<property name="PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} req=%X{requestId} user=%X{userId} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
<!-- Основной файл: ротация по дню и по 100 МБ, хранение 14 дней -->
<appender name="APP" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>14</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
<!-- Только ERROR и FATAL, храним месяц -->
<appender name="ERRORS" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/errors.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/errors.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
<!-- Точечный DEBUG: только на время разбора инцидента -->
<logger name="com.example" level="DEBUG"/>
<logger name="org.hibernate.SQL" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="APP"/>
<appender-ref ref="ERRORS"/>
</root>
</configuration>
Что здесь важно для эксплуатации. SizeAndTimeBasedRollingPolicy даёт в имени файла и дату, и индекс %i, поэтому ротация не затирает логи при всплеске объёма внутри суток. totalSizeCap=5GB защищает диск от переполнения, когда приложение внезапно начинает писать в 10 раз больше. Файл errors.log получает только ERROR и FATAL за счёт ThresholdFilter, поэтому дежурный читает его целиком, без grep по шуму.
Проверить конфиг без пересборки приложения можно так:
java -Dlogback.configurationFile=/path/to/logback.xml -jar app.jar
Фильтрация по уровню с помощью ThresholdFilter и LevelFilter
ThresholdFilter пропускает события с уровнем не ниже указанного, всё остальное отбрасывает. LevelFilter пропускает только один конкретный уровень и управляется двумя действиями: onMatch и onMismatch со значениями ACCEPT, DENY, NEUTRAL.
Файл warnings.log, куда попадают только предупреждения:
<appender name="WARN_ONLY" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/warnings.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>WARN</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/warnings.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
Фильтры в одном appender применяются по порядку и комбинируются: LevelFilter с onMismatch=DENY отсекает всё, кроме WARN, а добавленный следом ThresholdFilter с уровнем ERROR вернёт в поток ошибки, если они нужны в том же файле. Для отдельного файла с ошибками достаточно одного ThresholdFilter, как в примере выше.
Настройка Log4j2 через properties: пошаговое руководство
Log4j2 принимает XML, JSON, YAML и properties. Properties удобнее для ревью в Git: плоский список строк виден в diff целиком. Порядок блоков значения не имеет, важна только корректность имён. Ориентируйтесь на ветку 2.24.x; версии ниже 2.17 в продакшене использовать нельзя из-за Log4Shell.
Пример log4j2.properties с комментариями
# status=warn покажет внутренние ошибки конфигурации в stderr
status = warn
name = PropertiesConfig
# Консольный appender
appender.console.type = Console
appender.console.name = console
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} req=%X{requestId} user=%X{userId} - %msg%n
# Файловый appender с ротацией по времени и размеру
appender.rolling.type = RollingFile
appender.rolling.name = rolling
appender.rolling.fileName = logs/app.log
appender.rolling.filePattern = logs/app-%d{yyyy-MM-dd}-%i.log.gz
appender.rolling.layout.type = PatternLayout
appender.rolling.layout.pattern = %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} req=%X{requestId} user=%X{userId} - %msg%n
appender.rolling.policies.type = Policies
appender.rolling.policies.time.type = TimeBasedTriggeringPolicy
appender.rolling.policies.time.interval = 1
appender.rolling.policies.size.type = SizeBasedTriggeringPolicy
appender.rolling.policies.size.size = 100MB
appender.rolling.strategy.type = DefaultRolloverStrategy
appender.rolling.strategy.max = 14
# Отдельный файл только для ошибок
appender.errors.type = RollingFile
appender.errors.name = errors
appender.errors.fileName = logs/errors.log
appender.errors.filePattern = logs/errors-%d{yyyy-MM-dd}-%i.log.gz
appender.errors.layout.type = PatternLayout
appender.errors.layout.pattern = %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n
appender.errors.filter.threshold.type = ThresholdFilter
appender.errors.filter.threshold.level = error
appender.errors.policies.type = Policies
appender.errors.policies.time.type = TimeBasedTriggeringPolicy
appender.errors.policies.time.interval = 1
# Корневой логгер
rootLogger.level = info
rootLogger.appenderRef.console.ref = console
rootLogger.appenderRef.rolling.ref = rolling
rootLogger.appenderRef.errors.ref = errors
# Точечный DEBUG для пакета com.example
logger.example.name = com.example
logger.example.level = debug
logger.example.additivity = false
logger.example.appenderRef.console.ref = console
Две настройки экономят часы отладки. status = warn печатает в stderr сообщения о том, что Log4j2 не смог прочитать конфиг, при status = debug видно, откуда именно загружен файл. Параметр additivity = false останавливает передачу событий родительскому логгеру, иначе строки com.example попадут в консоль дважды: через собственный appender и через root.
java -Dlog4j2.configurationFile=/path/to/log4j2.properties -jar app.jar
Фильтрация по уровню в Log4j2 properties
Фильтр в properties задаётся тремя строками: тип, имя и уровень. ThresholdFilter в Log4j2 пропускает события не ниже указанного уровня, остальные отклоняет:
appender.errors.filter.threshold.type = ThresholdFilter appender.errors.filter.threshold.level = error
Чтобы такой файл вообще заполнялся, appender нужно подключить к rootLogger строкой rootLogger.appenderRef.errors.ref = errors. Полезно держать в конфиге и защиту от шума самого движка логирования: logger.log4j.name = org.apache.logging.log4j, logger.log4j.level = warn. Так внутренние сообщения библиотеки не попадут в общий поток.
MDC и контекст логирования: добавление requestId и userId
MDC (Mapped Diagnostic Context) хранит пары ключ-значение в контексте текущего потока, и PatternLayout выводит их через %X{key}. Значения живут до очистки, поэтому в шаблоне появляется связка запроса и пользователя без правки каждой строки логирования.
MDC.put("requestId", requestId);
MDC.put("userId", userId);
try {
process(request);
} finally {
MDC.clear();
}
В Log4j2 аналог называется ThreadContext, синтаксис тот же: ThreadContext.put("requestId", requestId). Шаблон вывода в Logback и Log4j2 совпадает: %X{requestId} %X{userId}. При работе с пулами потоков и @Async контекст не переносится автоматически: поток возвращается в пул вместе с чужими значениями. Для Reactor и MicrometerContext есть библиотека io.micrometer:context-propagation, которая прокидывает MDC между потоками. Подробнее о структурировании таких полей мы разбирали в материале про структурированное логирование и observability.
Пример MDC в Spring Boot с фильтром
Фильтр генерирует requestId, кладёт его в MDC до входа в контроллер и очищает контекст после ответа. Наследование от OncePerRequestFilter гарантирует один запуск на запрос.
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class MdcRequestFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String requestId = request.getHeader("X-Request-Id");
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
MDC.put("requestId", requestId);
MDC.put("userId", resolveUserId(request));
try {
chain.doFilter(request, response);
} finally {
response.setHeader("X-Request-Id", requestId);
MDC.clear();
}
}
}
Возврат X-Request-Id в заголовке ответа даёт бесплатную связку: клиент или балансировщик присылает этот идентификатор в тикет, и поиск в логах сводится к одному grep. MDC.clear() в finally обязателен, иначе значение протечёт в следующий запрос на том же потоке Tomcat.
Интеграция логирования со Spring Boot и Micrometer
Spring Boot даёт два уровня управления: свойства для быстрой смены уровней и обычный конфиг Logback или Log4j2 для appender'ов, фильтров и ротации. В application.properties достаточно четырёх строк:
logging.level.root=INFO
logging.level.com.example=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.file.name=logs/app.log
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} req=%X{requestId} - %msg%n
logging.logback.rollingpolicy.max-file-size=100MB
logging.logback.rollingpolicy.max-history=14
Свойства logging.level.* имеют приоритет над level в logback.xml, поэтому уровень для пакета можно поднять через переменную окружения LOGGING_LEVEL_COM_EXAMPLE=DEBUG без пересборки образа. Политику ротации для Logback удобнее держать в properties, тогда один и тот же образ работает и на стенде, и в продакшене.
Переключение Spring Boot на Log4j2
Стартер spring-boot-starter-logging тянет Logback, поэтому его исключают и добавляют spring-boot-starter-log4j2:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
Конфигурацию Boot ищет в файле log4j2-spring.xml, а не в log4j2.xml: только в нём работают теги springProfile и springProperty. После переключения проверьте, что в дереве зависимостей не осталось logback-classic, иначе оба движка будут писать в консоль одновременно.
Сбор метрик логирования с Micrometer
Уровни логирования хорошо превращаются в метрики: график роста ERROR показывает инцидент раньше, чем дежурный откроет файл. Счётчик по имени логгера регистрируется через MeterRegistry и уходит в Prometheus или Grafana.
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final MeterRegistry registry;
public OrderService(MeterRegistry registry) {
this.registry = registry;
}
public void process(String orderId) {
try {
// бизнес-логика заказа
} catch (Exception ex) {
registry.counter("app.log.errors", "logger", "OrderService").increment();
log.error("Order {} failed", orderId, ex);
}
}
}
Связку логирования и метрик удобно строить поверх общего конвейера телеметрии, где логи и метрики маршрутизируются одними правилами: готовый пример есть в руководстве по маршрутизации логов и метрик. Отдельный счётчик по каждому сообщению создавать не нужно, кардинальность метрики быстро выйдет из-под контроля: держите один счётчик с тегом уровня и тегом сервиса. Для автоматического учёта есть LogbackMetrics: биндер подписывается на события Logback и публикует метрику logback.events с тегом level, поэтому свои счётчики нужны только для бизнес-специфичных ошибок.
Миграция с Log4j на Logback: пошаговый чек-лист
Log4j 1.x не получает обновлений с 2015 года, а в Log4j2 уязвимость Log4Shell (CVE-2021-44228) закрыта в ветках 2.16 и 2.17. Если проект остаётся на Log4j2, держите ветку 2.24.x и проверяйте зависимости транзитивно. Если переходите на Logback, порядок шагов такой.
Замена зависимостей и конфигов
- Соберите текущее дерево зависимостей: mvn dependency:tree | grep -i log4j или ./gradlew dependencies | grep -i log4j. Так находятся транзитивные log4j-core и log4j:log4j, которые тянут старые библиотеки.
- Исключите оба артефакта и добавьте logback-classic версии 1.5.x вместе с SLF4J 2.0.x.
- Перенесите конфиг: log4j.properties или log4j.xml заменяется на logback.xml, структура отображается построчно.
- Замените импорты org.apache.log4j.Logger и org.apache.logging.log4j.Logger на org.slf4j.Logger, а Logger.getLogger(...) на LoggerFactory.getLogger(...).
- Прогоните тесты и проверьте логи на стенде до продакшена.
<dependency>
<groupId>com.example</groupId>
<artifactId>legacy-lib</artifactId>
<exclusions>
<exclusion>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.x</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.x</version>
</dependency>
configurations.all {
exclude group: "log4j", module: "log4j"
exclude group: "org.apache.logging.log4j", module: "log4j-core"
}
dependencies {
implementation "ch.qos.logback:logback-classic:1.5.x"
implementation "org.slf4j:slf4j-api:2.0.x"
}
Соответствие настроек при переносе конфига:
| Log4j | Logback |
|---|---|
| log4j.rootLogger=INFO, stdout | <root level="INFO"> с appender-ref на stdout |
| log4j.appender.stdout=org.apache.log4j.ConsoleAppender | <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> |
| log4j.appender.stdout.layout=org.apache.log4j.PatternLayout | <encoder><pattern>...</pattern></encoder> |
| log4j.logger.com.example=DEBUG | <logger name="com.example" level="DEBUG"/> |
| log4j.appender.file.MaxFileSize=100MB | SizeAndTimeBasedRollingPolicy с maxFileSize |
Дальше логи имеет смысл перевести в JSON, чтобы request_id и level стали полями, а не текстом: пошаговый план перехода без остановки сервисов разобран в статье про переход от plain text к JSON-логам.
Проверка после миграции
- В дереве зависимостей нет log4j: mvn dependency:tree | grep -i log4j возвращает пустой вывод.
- Приложение стартует, в консоли нет сообщений об ошибке конфигурации Logback. Для диагностики поднимите <configuration debug="true"> на стенде и уберите флаг после проверки.
- Логи пишутся и в консоль, и в файл: tail -f logs/app.log показывает свежие строки.
- Уровни соответствуют ожидаемым: grep -c "ERROR" logs/app.log и grep -c "DEBUG" logs/app.log дают правдоподобные числа.
- MDC выводится: в строке есть req= и user=, при отключённом фильтре значения не протекают между запросами.
- Ротация работает: после понижения maxFileSize до 1MB появляются файлы app.<дата>.0.log.gz и app.<дата>.1.log.gz.
Чек-лист по безопасности и воспроизводимости конфигов логирования
- Не пишите в лог пароли, токены, номера карт и персональные данные. Перед релизом пройдитесь по сообщениям уровней DEBUG и TRACE, критерии отбора собраны в материале что логировать, а что нет.
- В продакшене держите root на INFO или WARN. DEBUG включается точечно для конкретного пакета и снимается после инцидента.
- Настройте ротацию с ограничением объёма: maxHistory и totalSizeCap в Logback, DefaultRolloverStrategy.max в Log4j2.
- Ограничьте доступ к файлам: chmod 640 logs/app.log и каталог с правами 750, чтобы логи не читал посторонний пользователь в контейнере или на shared-хосте. Проверка: ls -l logs/.
- Зафиксируйте версии библиотек в манифесте сборки: Logback 1.5.x, SLF4J 2.0.x, Log4j2 2.24.x. Плавающие диапазоны версий усложняют воспроизведение бага на другом стенде.
- Проверьте конфиг на тестовом стенде до деплоя: java -Dlogback.configurationFile=... -jar app.jar и java -Dlog4j2.configurationFile=... -jar app.jar позволяют прогнать файл без пересборки.
- Очищайте MDC в finally, иначе контекст одного запроса попадёт в строки следующего.
- Для Spring Boot держите профили в logback-spring.xml или log4j2-spring.xml, чтобы dev и prod отличались уровнями, а не правками в коде.
- Проверьте, что логи с хоста не удаляются вместе с контейнером: смонтируйте каталог логов в volume или отправляйте поток в сборщик.
- Добавьте в CI шаг, который падает при появлении log4j-core в дереве зависимостей, если проект уже переведён на Logback.
Начните с малого: поднимите errors.log через ThresholdFilter и один счётчик app.log.errors в Micrometer. Эти два изменения дают дежурному точку входа в инцидент без чтения всего потока логов.