← назад к разделу

Когда что-то ломается в продакшне в три ночи, единственное, что помогает понять причину — это логи. Если они написаны правильно, расследование занимает минуты. Если нет — часы. Разберём, как устроено нормальное логирование в 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). В самом handler INFO пишут только для критичных команд (платежи).
  • Исходящий HTTPINFO на вызов («Вызов 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 и распределённая трассировка.