Логирование
Kora использует slf4j-api как общий фасад логирования во всем фреймворке.
SLF4J отделяет код приложения от конкретной реализации логирования, а в качестве основной реализации Kora предполагает использование Logback.
Модуль логирования отвечает за получение Logger через стандартную фабрику SLF4J, управление уровнями логирования через конфигурацию Kora и передачу структурированных данных в записи логов.
Структурированные данные можно добавлять через StructuredArgument, Marker, пары ключ-значение SLF4J и MDC, чтобы они выводились вместе с обычным текстовым сообщением.
Пошаговый разбор перед справочным описанием смотрите в разделе Наблюдаемость.
Использование¶
Logger создается через фабрику SLF4J:
Конфигурация¶
Уровни логирования описываются интерфейсом LoggingConfig, который отображается из секции logging файла конфигурации.
В секции есть единственная карта levels, задающая уровень для ROOT, для пакета или для конкретного логгера:
levels — плоская карта «имя логгера — уровень»: ключ является полным именем логгера, а значение — обычной строкой.
Имена логгеров в HOCON берите в кавычки
HOCON трактует ключ с точками без кавычек как путь и разворачивает его во вложенные объекты, поэтому io.koraframework = "INFO" превращается в объект { io { koraframework = "INFO" } }.
Вложенный объект не является строкой уровня, и отображение конфигурации падает с ошибкой о неожиданном типе значения.
Всегда записывайте имена логгеров в кавычках — "io.koraframework" = "INFO".
В YAML ключ с точками и так остается литеральным, но кавычки делают оба формата одинаковыми.
Note
Когда секция logging отсутствует, levels является пустой картой и Kora не применяет собственных уровней.
Однако реализация Logback сбрасывает все логгеры при каждом (повторном) применении: ROOT нормализуется к INFO, а уровень каждого остального логгера очищается, чтобы он наследовался от родителя, после чего сверху применяются настроенные уровни.
В результате значение <root level="..."> из logback.xml при запуске фактически заменяется на INFO, если только уровень ROOT не задан в конфигурации.
Обновление уровней во время работы¶
Настроенные уровни применяются компонентом LoggingLevelRefresher — корневым компонентом, который при запуске сбрасывает все логгеры и применяет уровни из секции logging через LoggingLevelApplier.
Он повторно запускается при каждом обновлении конфигурации, поэтому когда активен наблюдатель конфигурации, изменение уровня в файле конфигурации вступает в силу во время работы без перезапуска приложения.
Поскольку перед применением всегда выполняется сброс, удаленный из конфигурации уровень возвращается к наследуемому значению, а не остается на прежней настройке.
Модули¶
Логирование запросов и ответов отдельных компонентов Kora является сигналом телеметрии этих компонентов и включается в их собственной секции конфигурации через telemetry.logging.enabled.
Это отдельная настройка от уровней логирования выше: telemetry.logging.enabled решает, порождает ли компонент записи логов вообще, а logging.levels — насколько эти записи подробны и проходят ли они фильтр по уровню.
По умолчанию логирование телеметрии выключено для всех компонентов, поэтому ниже приведена конфигурация для включения его у большинства из них:
jdbc.telemetry.logging.enabled = true //(1)!
cassandra.telemetry.logging.enabled = true //(2)!
grpcServer.telemetry.logging.enabled = true //(3)!
httpServer.telemetry.logging.enabled = true //(4)!
scheduling.telemetry.logging.enabled = true //(5)!
resilient.telemetry.circuitBreaker.logging.enabled = true //(6)!
grpcClient.SomeGrpcServiceName.telemetry.logging.enabled = true //(7)!
soapClient.SomeSoapServiceName.telemetry.logging.enabled = true //(8)!
SomePathToConfigHttpClient.telemetry.logging.enabled = true //(9)!
kafka.consumer.SomeConsumerName.telemetry.logging.enabled = true //(10)!
kafka.producer.SomePublisherName.telemetry.logging.enabled = true //(11)!
- Логирование запросов к базе данных JDBC (по умолчанию:
false). - Логирование запросов к базе данных Cassandra (по умолчанию:
false). - Логирование запросов gRPC-сервера (по умолчанию:
false). - Логирование запросов HTTP-сервера (по умолчанию:
false). - Логирование запусков планировщика (по умолчанию:
false). - Логирование смены состояний предохранителя; у
retry,timeout,fallbackиrateLimiterесть свои подсекции (по умолчанию:false). - Логирование запросов gRPC-клиента, указывается для конкретного сервиса (по умолчанию:
false). - Логирование запросов SOAP-клиента, указывается для конкретного сервиса (по умолчанию:
false). - Логирование запросов HTTP-клиента, указывается для конкретного клиента по его собственному пути конфигурации (по умолчанию:
false). - Логирование Kafka-потребителя, указывается для конкретного потребителя (по умолчанию:
false). - Логирование Kafka-производителя, указывается для конкретного производителя (по умолчанию:
false).
jdbc.telemetry.logging.enabled: true #(1)!
cassandra.telemetry.logging.enabled: true #(2)!
grpcServer.telemetry.logging.enabled: true #(3)!
httpServer.telemetry.logging.enabled: true #(4)!
scheduling.telemetry.logging.enabled: true #(5)!
resilient.telemetry.circuitBreaker.logging.enabled: true #(6)!
grpcClient.SomeGrpcServiceName.telemetry.logging.enabled: true #(7)!
soapClient.SomeSoapServiceName.telemetry.logging.enabled: true #(8)!
SomePathToConfigHttpClient.telemetry.logging.enabled: true #(9)!
kafka.consumer.SomeConsumerName.telemetry.logging.enabled: true #(10)!
kafka.producer.SomePublisherName.telemetry.logging.enabled: true #(11)!
- Логирование запросов к базе данных JDBC (по умолчанию:
false). - Логирование запросов к базе данных Cassandra (по умолчанию:
false). - Логирование запросов gRPC-сервера (по умолчанию:
false). - Логирование запросов HTTP-сервера (по умолчанию:
false). - Логирование запусков планировщика (по умолчанию:
false). - Логирование смены состояний предохранителя; у
retry,timeout,fallbackиrateLimiterесть свои подсекции (по умолчанию:false). - Логирование запросов gRPC-клиента, указывается для конкретного сервиса (по умолчанию:
false). - Логирование запросов SOAP-клиента, указывается для конкретного сервиса (по умолчанию:
false). - Логирование запросов HTTP-клиента, указывается для конкретного клиента по его собственному пути конфигурации (по умолчанию:
false). - Логирование Kafka-потребителя, указывается для конкретного потребителя (по умолчанию:
false). - Логирование Kafka-производителя, указывается для конкретного производителя (по умолчанию:
false).
Компоненты пишут телеметрию через выделенные логгеры, названные по компоненту, а подробность вывода зависит от уровня этого логгера.
HTTP-сервер использует io.koraframework.http.server.common.HttpServer.request и ...HttpServer.response: на INFO логируется операция, на DEBUG добавляются параметры запроса и заголовки, на TRACE — тело.
Пул базы данных использует io.koraframework.database.<poolName>.query, начинает логировать на DEBUG и добавляет текст SQL на TRACE.
Телеметрия включена, но ничего не логируется
Должны выполняться сразу два условия: telemetry.logging.enabled = true у компонента и достаточно низкий уровень в levels для логгера этого компонента.
Пул базы данных, который логирует только на DEBUG, будет молчать при уровне ROOT, равном INFO.
Параметры логирования конкретных модулей описываются в документации этих модулей, например HTTP сервер, HTTP клиент, gRPC-клиент. Некоторые из них расширяют секцию логирования собственными ключами, например маскированием заголовков и параметров запроса у HTTP-сервера.
Logback¶
Модуль предоставляет реализацию логирования на основе Logback, добавляет поддержку структурированных логов и позволяет управлять уровнями логирования через файл конфигурации.
Подключение¶
Зависимость build.gradle:
Модуль:
Зависимость build.gradle.kts:
Модуль:
LogbackModule наследует LoggingModule, поэтому отдельная зависимость logging-common не требуется.
Конфигурация¶
Logback настраивается через logback.xml, а в конфигурации Kora обычно указываются только уровни логирования.
Пример logback.xml:
<configuration debug="false">
<statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="io.koraframework.logging.logback.ConsoleTextRecordEncoder"/>
</appender>
<appender name="ASYNC" class="io.koraframework.logging.logback.KoraAsyncAppender">
<appender-ref ref="STDOUT"/>
</appender>
<root level="WARN">
<appender-ref ref="ASYNC"/>
</root>
</configuration>
KoraAsyncAppender выполняет сразу две задачи.
Он передает запись рабочему потоку для асинхронной записи, а перед этим снимает все, что живет в текущей области видимости и иначе потерялось бы по дороге: значения Kora MDC и текущий спан OpenTelemetry.
И то и другое сохраняется в KoraLoggingEvent — типе события, который понимают энкодер и конвертеры Kora.
Контекст Kora виден только благодаря KoraAsyncAppender
Значения Kora MDC, traceId и spanId читаются из KoraLoggingEvent.
Цепочка аппендеров без KoraAsyncAppender порождает обычные события Logback, и эти поля просто не появляются в выводе.
Оборачивайте реальный аппендер в KoraAsyncAppender даже тогда, когда асинхронная запись сама по себе не нужна.
Формат записи лога¶
ConsoleTextRecordEncoder — единственный энкодер, поставляемый модулем.
Он формирует текстовую строку с добавленными структурированными полями, а не единый документ JSON, и складывается так:
- метка времени
yyyy-MM-dd HH:mm:ss.SSSвUTC, уровень, имя потока в квадратных скобках и имя логгера; traceId=... spanId=..., если запись порождена внутри трассируемой операции;- записи Kora
MDCв видеkey=<json value>; - записи
MDCизSLF4Jв виде обычногоkey=value; - отформатированное сообщение;
- по одной строке
fieldName={json}с отступом табуляцией на каждое структурированное поле из маркеров, аргументов сообщения и пар ключ-значениеSLF4J; - стектрейс, если запись несет
Throwable.
2026-07-02 10:15:30.123 INFO [kora-undertow-1] io.koraframework.example.SomeService - traceId=4bf92f3577b34da6a3ce929d0e0e4736 spanId=00f067aa0ba902b7 userId=42 user logged in
role="admin"
Имя логгера выводится целиком и сокращается по пакетам только когда превышает 100 символов.
Собственный шаблон¶
Вместо ConsoleTextRecordEncoder можно использовать стандартный PatternLayoutEncoder вместе с конвертерами, которые отображают структурированные данные Kora.
KoraMdcConverter отображает MDC контекста Kora, а KoraLoggingMarkerConverter отображает маркер StructuredArgument; зарегистрируйте их как слова преобразования и сошлитесь на них в шаблоне:
<configuration>
<conversionRule conversionWord="koraMdc" converterClass="io.koraframework.logging.logback.KoraMdcConverter"/>
<conversionRule conversionWord="koraMarker" converterClass="io.koraframework.logging.logback.KoraLoggingMarkerConverter"/>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger - %koraMdc%msg %koraMarker%n</pattern>
</encoder>
</appender>
<appender name="ASYNC" class="io.koraframework.logging.logback.KoraAsyncAppender">
<appender-ref ref="STDOUT"/>
</appender>
<root level="INFO">
<appender-ref ref="ASYNC"/>
</root>
</configuration>
KoraMdcConverter в первую очередь берет снимок MDC, который несет KoraLoggingEvent, и лишь в противном случае читает текущий привязанный MDC напрямую.
Такое чтение определено только внутри области видимости MDC, и это еще одна причина держать KoraAsyncAppender перед аппендером с шаблоном: с ним записи, порожденные при инициализации графа или из shutdown hook, кодируются безопасно.
Строка в логе не является контрактом запуска
Формулировки собственных стартовых сообщений Kora не входят в публичный контракт и меняются между версиями, а асинхронный аппендер под нагрузкой может потерять запись.
Не ждите строку в логе, чтобы решить, что сервис поднялся — опрашивайте пробу готовности по пути /system/readiness.
Другая реализация¶
Kora использует slf4j-api как фасад логирования, поэтому можно подключить любую совместимую реализацию.
Базовый модуль добавляет общие компоненты для структурированных логов и управления уровнями логирования через файл конфигурации.
Подключение¶
Требуется подключить общий модуль логирования:
Зависимость build.gradle:
Модуль:
Зависимость build.gradle.kts:
Модуль:
Использование¶
LoggingModule предоставляет компонент LoggingConfig, корневой компонент LoggingLevelRefresher и компонент ILoggerFactory, полученный из LoggerFactory.getILoggerFactory().
LoggingLevelApplier он не предоставляет, поэтому собственная реализация должна дать его сама:
@Component
public final class SomeLoggingLevelApplier implements LoggingLevelApplier {
@Override
public void apply(String logName, String logLevel) { //(1)!
//...
}
@Override
public void reset() { //(2)!
//...
}
}
- Применяет уровень к логгеру с указанным именем; уровень приходит из карты
logging.levelsв том виде, в каком записан в конфигурации. - Возвращает все логгеры в исходное состояние; вызывается перед каждым проходом применения, в том числе при обновлении конфигурации.
@Component
class SomeLoggingLevelApplier : LoggingLevelApplier {
override fun apply(logName: String, logLevel: String) { //(1)!
//...
}
override fun reset() { //(2)!
//...
}
}
- Применяет уровень к логгеру с указанным именем; уровень приходит из карты
logging.levelsв том виде, в каком записан в конфигурации. - Возвращает все логгеры в исходное состояние; вызывается перед каждым проходом применения, в том числе при обновлении конфигурации.
Если приложение использует структурированные данные, собственная реализация также должна отображать StructuredArgument, StructuredArgumentWriter и Kora MDC — так же, как это делает ConsoleTextRecordEncoder для Logback.
Структурированные логи¶
Структурированные логи позволяют передавать в запись лога не только текст, но и именованные поля. Такие поля удобны для средств сбора логов и могут использоваться для поиска, фильтрации и построения представлений.
Передать структурированные данные в запись лога можно тремя способами:
- через
Marker; - через параметр сообщения;
- через пару ключ-значение
SLF4J.
Все три строятся из io.koraframework.logging.common.arg.StructuredArgument, а фабричные методы marker и arg принимают значения String, Integer, Long, Boolean и Map<String, String>.
Для любого другого типа передайте JsonWriter<T> или StructuredArgumentWriter.
Marker¶
Marker добавляет структурированное поле к записи лога и не занимает место параметра в текстовом сообщении:
Параметр¶
Параметр сообщения добавляет структурированное поле через обычный массив аргументов SLF4J:
Пара ключ-значение¶
Текучий построитель SLF4J прикрепляет именованное значение, не затрагивая ни сообщение, ни список маркеров.
StructuredArgument.value оборачивает писателя в StructuredArgumentWriter, который энкодер Kora отображает как структурированное поле — именно эту форму использует собственная телеметрия Kora:
Сложный объект¶
Для значений, которые не являются String, числом, Boolean или Map<String, String>, передайте JsonWriter<T> (тот же генерируемый для типа писатель @Json) или необработанную лямбду StructuredArgumentWriter, которая пишет значение поля напрямую в JsonGenerator.
Обе перегрузки предоставляют и arg, и marker:
JsonGenerator здесь — тот же генератор Jackson, что используется во всей Kora, поэтому поля объекта пишутся через writeStringProperty / writeNumberProperty, а имена — через writeName.
Запись не объявляет проверяемых исключений, поэтому try/catch не нужен.
Перегрузка с JsonWriter<T> принимает значение и его писателя, а для null пишет null:
@Component
public final class SomeService {
private static final Logger logger = LoggerFactory.getLogger(SomeService.class);
private final JsonWriter<User> userWriter;
public SomeService(JsonWriter<User> userWriter) { //(1)!
this.userWriter = userWriter;
}
public void handle(User user) {
logger.info("user logged in", StructuredArgument.arg("user", user, userWriter));
}
}
- Писатель, сгенерированный для типа с аннотацией
@Json, является обычным компонентом графа и внедряется как зависимость.
@Component
class SomeService(
private val userWriter: JsonWriter<User>, //(1)!
) {
fun handle(user: User) {
logger.info("user logged in", StructuredArgument.arg("user", user, userWriter))
}
companion object {
private val logger = LoggerFactory.getLogger(SomeService::class.java)
}
}
- Писатель, сгенерированный для типа с аннотацией
@Json, является обычным компонентом графа и внедряется как зависимость.
Преобразователь аргумента¶
StructuredArgumentMapper<T> — контракт, который превращает значение типа T в структурированное поле.
LoggingModule поставляет две реализации по умолчанию, построенные поверх существующего JsonWriter<T>:
JsonStructuredArgumentMapper— пишет значение какJSON;MaskedStructuredArgumentMapper— пишет значение какJSON, заменяя поля, описанные вMaskingRules.
Обе объявлены как @DefaultComponent, поэтому собственный компонент StructuredArgumentMapper<T> для типа заменяет реализацию по умолчанию для этого типа.
Именно эти преобразователи использует аспект @Log для сериализации аргументов и результатов методов, там же описаны правила маскирования.
MDC¶
Структурированные данные можно прикрепить ко всем записям в рамках текущей области видимости с помощью класса io.koraframework.logging.common.MDC.
Значение будет добавляться в каждую запись лога, пока оно не будет удалено из MDC:
Импорт
Используйте io.koraframework.logging.common.MDC, а не org.slf4j.MDC.
MDC из SLF4J — это thread-local со строками: Kora очищает его в начале каждого HTTP-запроса, он не следует за работой, переданной в другой поток, а его значения выводятся обычным текстом.
Kora MDC живет в области видимости запроса, переносится через собственные передачи работы между потоками в Kora, а его значения выводятся как типизированный JSON.
Декларативную альтернативу смотрите в @Mdc.
put принимает значения String, Integer, Long и Boolean, а также необработанный StructuredArgumentWriter для произвольного JSON; типизированные значения выводятся как их тип JSON, а не как текст, а значение null выводится как null в JSON:
Область видимости MDC¶
MDC не является глобальным thread-local: он привязан к области видимости, и MDC.put / MDC.remove / MDC.get работают только внутри нее.
Kora открывает такую область на каждой точке входа, которой владеет — запрос HTTP-сервера, вызов gRPC-сервера, запись Kafka, запуск планировщика — и каждая область начинается пустой, поэтому значения не утекают из одного запроса в следующий.
Когда Kora сама передает работу в другой поток, она заново привязывает MDC явно; например, слушатель Kafka получает fork() от MDC уровня опроса на каждую запись, поэтому значения отдельных записей остаются изолированными.
Код, который выполняется вне этих точек входа — инициализация графа, shutdown hook, задача в обычном ExecutorService, модульный тест — не имеет привязанного MDC, и вызов MDC.put там завершится ошибкой.
Для такого кода откройте область видимости явно:
ScopedValue.where(MDC.VALUE, new MDC()).run(() -> { //(1)!
MDC.put("jobId", jobId);
logger.info("job started");
});
MDC.VALUE— этоScopedValue, хранящий текущийMDC; привязка видна только в этом потоке внутри этого блока.
ScopedValue.where(MDC.VALUE, MDC()).call<Unit, RuntimeException> { //(1)!
MDC.put("jobId", jobId)
logger.info("job started")
}
MDC.VALUE— этоScopedValue, хранящий текущийMDC. Используетсяcall, а неrun, потому чтоrunразрешился бы в расширение стандартной библиотекиKotlin, а не в метод носителя.
Из-за этой же привязки к области видимости KoraAsyncAppender копирует MDC в момент добавления записи: у асинхронного рабочего потока собственной привязки нет, и именно скопированное в KoraLoggingEvent значение позже отображает энкодер.