Когда что-то ломается в продакшне в три ночи, единственное, что помогает понять причину — это логи. Если они написаны правильно, расследование занимает минуты. Если нет — часы. Разберём, как устроено нормальное логирование в Java/Spring.
Почему обычные логи не работают в продакшне
Новичок обычно пишет так:
System.out.println("Order created: " + order.getId());
или так:
Logger log = LoggerFactory.getLogger(OrderService.class);
log.info("Order created: " + order.getId() + " for customer " + order.getCustomerId());
Выглядит разумно. Но в реальном продакшне это не работает:
- Если сервис обрабатывает 1000 запросов в секунду, логи превращаются в миллион строк. Найти нужную без фильтрации невозможно.
- Нет контекста: кто делал запрос? В рамках какого трейса? Какой requestId?
- Строковая склейка через
+работает всегда, даже когда уровень выключен — лишний расход CPU. System.outне попадает в единый конвейер логов: без уровня, без формата, без метаданных.
Промышленное решение — structured logging: каждая строка лога это JSON-объект с фиксированными полями. Такие логи Loki, ELK и Datadog индексируют и фильтруют без регулярных выражений.
JSON в продакшне, текст при разработке
Logback — стандартный логгер в Spring Boot. Он поддерживает профили: в разработке удобнее читаемый текст, в продакшне нужен JSON.
<configuration>
<springProfile name="dev,test">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %X{traceId:-} %logger{30} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<springProfile name="prod,staging">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
Для JSON-кодирования подключают библиотеку logstash-logback-encoder. В продакшне каждая строка лога выглядит так:
{
"@timestamp": "2026-05-25T22:30:00.123Z",
"level": "INFO",
"logger_name": "ru.vikulinva.order.OrderService",
"message": "Order confirmed: orderId=12345",
"mdc": {
"traceId": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
"requestId": "0193a8f3-7c21-7e3f-9b4a-...",
"userId": "user-42"
}
}
@Slf4j — как объявить логгер
Раньше каждый класс начинался с такой строки:
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
Это шаблонный код, который легко написать неправильно (например, скопировать из другого класса и забыть поменять имя). Lombok решает проблему аннотацией @Slf4j:
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final OrderRepository orderRepository;
public Order confirm(Long orderId) {
log.info("Confirming order: orderId={}", orderId);
var order = orderRepository.findById(orderId).orElseThrow();
order.confirm();
return orderRepository.save(order);
}
}
Lombok генерирует private static final Logger log с правильным именем класса. Поле log появляется в скомпилированном коде, не в исходнике.
Параметры через {}, не через +
Slf4j поддерживает ленивые плейсхолдеры:
// правильно
log.info("Order created: orderId={} customerId={}", order.id(), order.customerId());
// неправильно
log.info("Order created: orderId=" + order.id() + " customerId=" + order.customerId());
Разница в том, когда вызывается toString(). При склейке через + — всегда, даже если уровень выключен. При {} — только если уровень активен. Для INFO разница небольшая. Для DEBUG на продакшне — критическая: если у объекта тяжёлый toString, он выполняется миллионы раз впустую.
Правило простое: всегда {}, никогда +.
Уровни логов и их смысл
Пять уровней, и у каждого своя семантика:
| Уровень | Когда использовать |
|---|---|
ERROR | Неустранимый сбой, требует действия: упавшая транзакция, недоступный внешний сервис, необработанное исключение. Всегда со stack trace. |
WARN | Проблема, от которой сервис оправился: retry, fallback, circuit breaker открылся. |
INFO | Важное бизнес-событие: «заказ подтверждён», «пользователь зарегистрирован», старт/остановка пакетной задачи с количеством. |
DEBUG | Детали для отладки. На продакшне выключен, включается временно при расследовании. |
TRACE | Максимальная детализация. Только локально при разработке. |
Примеры:
log.error("Failed to charge payment: paymentId={}", paymentId, ex);
log.warn("Circuit breaker OPEN for payment-provider, falling back to queue");
log.info("Order confirmed: orderId={} customerId={} amount={}",
order.id(), order.customerId(), order.amount());
log.debug("Order aggregate state after confirm: {}", order);
Частая ошибка начинающих — писать INFO на каждый HTTP-запрос («Handling GET /orders/123»). Это и есть access-лог, он существует отдельно. Без этой дисциплины 80% объёма продакшн-логов — шум, в котором ничего не найти.
MDC — контекст в каждом сообщении
MDC (Mapped Diagnostic Context) — это словарь, который Logback автоматически добавляет к каждому лог-сообщению в текущем потоке. Именно так traceId и requestId оказываются в JSON без явного указания в каждом log.info.
Три ключевых поля:
traceIdиspanId— добавляются автоматически через OpenTelemetry. Позволяют найти все логи конкретного запроса даже в распределённой системе.requestId— фильтр при входящем HTTP-запросе берёт заголовокX-Request-Idили генерирует UUID, кладёт в MDC.userId— добавляется после JWT-валидации в фильтр-цепочке Spring Security.
Благодаря MDC можно искать в Loki: {traceId="5e92c8a3..."} — и получить все лог-строки этого запроса от всех сервисов в хронологическом порядке. Без MDC каждое расследование начинается с нуля.
Подробнее о том, как настроить фильтры — Context propagation.
Что и где логировать
Логи полезны на границах — там, где сервис общается с внешним миром:
- Входящий REST-запрос — access-лог настраивается отдельно (
spring.mvc.log-request-details). В самом handlerINFOпишут только для критичных команд (платежи). - Исходящий HTTP —
INFOна вызов («Вызов payment-provider»),WARNна 4xx/5xx,ERRORна сетевую ошибку. - Доменные события —
INFOна публикацию: «Published OrderCreated: orderId=...». - Планировщики —
INFOна старт и конец с количеством: «Outbox relay опубликовал 100 событий за 50ms».
Внутри бизнес-логики логируют только важные решения или деградации. «Entering method», «Loaded N rows» — это шум.
Частые ошибки
Личные данные в логах
Email, телефон, ФИО, паспорт, номер карты, JWT-токены, пароли — всё это нельзя писать в логи в открытом виде. Доступ к логам обычно шире, чем к базе данных; хранятся они дольше; индексируются везде.
// плохо — персональные данные в открытом виде
log.info("User registered: email={} phone={}", user.email(), user.phone());
// хорошо — только внутренний идентификатор
log.info("User registered: userId={}", user.id());
// хорошо — если нужно для расследования, маскировать
log.info("Email verification sent: userId={} emailMask={}",
user.id(), maskEmail(user.email())); // u***@example.com
System.out.println и printStackTrace
Оба метода пишут в stdout без уровня, без MDC, без формата. Они не попадают в JSON-конвейер.
// плохо
System.out.println("Order: " + order);
e.printStackTrace();
// хорошо
log.info("Order: {}", order);
log.error("Unexpected error", e);
log.error без исключения
// плохо — stack trace теряется, причину не узнать
log.error("Failed to charge: " + e.getMessage());
// хорошо — Slf4j видит последний аргумент Throwable и добавляет stack_trace в JSON
log.error("Failed to charge: orderId={}", orderId, e);
Полный request body в логах для денег и персональных данных
// плохо — в теле запроса могут быть реквизиты карты
log.info("Charge request: {}", chargeRequest);
// хорошо — только идентификаторы
log.info("Charge request: orderId={} amount={}",
chargeRequest.orderId(), chargeRequest.amount());
Коротко
- В продакшне — JSON через
logstash-logback-encoder, в разработке — читаемый текстовый pattern. Настраивается через профили Logback. - Объявляй логгер через
@Slf4j(Lombok), не черезLoggerFactory.getLoggerвручную. - Параметры всегда через
{}, никогда через+— Slf4j ленив и не вызываетtoStringна выключенных уровнях. ERROR— требует действия и всегда со stack trace.WARN— деградация, от которой оправились.INFO— важное бизнес-событие.DEBUGиTRACE— только для разработки.- MDC автоматически добавляет
traceId,requestId,userIdк каждому лог-сообщению через OpenTelemetry и фильтры. - Личные данные (email, телефон, паспорт, токены) в логах — серьёзное нарушение. Только идентификаторы или маскированные значения.
System.out.printlnиe.printStackTrace()не работают в structured-конвейере — заменяй наlog.*.
Что почитать дальше
- Context propagation (MDC) — как traceId и requestId попадают в MDC.
- Metrics — Micrometer и Prometheus: что измерять и как.
- Tracing — OpenTelemetry и распределённая трассировка.