Перейти к содержанию
V1 V2

Логирование

Kora использует slf4j-api как общий фасад логирования во всем фреймворке. SLF4J отделяет код приложения от конкретной реализации логирования, а в качестве основной реализации Kora предполагает использование Logback.

Модуль логирования отвечает за получение Logger через стандартную фабрику SLF4J, управление уровнями логирования через конфигурацию Kora и передачу структурированных данных в записи логов. Структурированные данные можно добавлять через StructuredArgument, Marker, пары ключ-значение SLF4J и MDC, чтобы они выводились вместе с обычным текстовым сообщением.

Пошаговый разбор перед справочным описанием смотрите в разделе Наблюдаемость.

Использование

Logger создается через фабрику SLF4J:

Logger logger = LoggerFactory.getLogger(SomeService.class);
val logger = LoggerFactory.getLogger(SomeService::class.java)

Конфигурация

Уровни логирования описываются интерфейсом LoggingConfig, который отображается из секции logging файла конфигурации. В секции есть единственная карта levels, задающая уровень для ROOT, для пакета или для конкретного логгера:

logging {
  levels {  //(1)!
    "ROOT" = "WARN"
    "io.koraframework" = "INFO"
    "io.koraframework.http.server.common.HttpServer.request" = "DEBUG"
    "io.koraframework.example" = "INFO"
  }
}
  1. Уровни логирования по имени логгера (по умолчанию: пустая карта, необязательно).
logging:
  levels: #(1)!
    "ROOT": "WARN"
    "io.koraframework": "INFO"
    "io.koraframework.http.server.common.HttpServer.request": "DEBUG"
    "io.koraframework.example": "INFO"
  1. Уровни логирования по имени логгера (по умолчанию: пустая карта, необязательно).

levels — плоская карта «имя логгера — уровень»: ключ является полным именем логгера, а значение — обычной строкой.

Имена логгеров в HOCON берите в кавычки

HOCON трактует ключ с точками без кавычек как путь и разворачивает его во вложенные объекты, поэтому io.koraframework = "INFO" превращается в объект { io { koraframework = "INFO" } }. Вложенный объект не является строкой уровня, и отображение конфигурации падает с ошибкой о неожиданном типе значения. Всегда записывайте имена логгеров в кавычках — "io.koraframework" = "INFO". В YAML ключ с точками и так остается литеральным, но кавычки делают оба формата одинаковыми.

Note

Когда секция logging отсутствует, levels является пустой картой и Kora не применяет собственных уровней. Однако реализация Logback сбрасывает все логгеры при каждом (повторном) применении: ROOT нормализуется к INFO, а уровень каждого остального логгера очищается, чтобы он наследовался от родителя, после чего сверху применяются настроенные уровни. В результате значение <root level="..."> из logback.xml или свойство kora.logging.levels.root цепочки по умолчанию при запуске фактически заменяется на 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)!
  1. Логирование запросов к базе данных JDBC (по умолчанию: false).
  2. Логирование запросов к базе данных Cassandra (по умолчанию: false).
  3. Логирование запросов gRPC-сервера (по умолчанию: false).
  4. Логирование запросов HTTP-сервера (по умолчанию: false).
  5. Логирование запусков планировщика (по умолчанию: false).
  6. Логирование смены состояний предохранителя; у retry, timeout, fallback и rateLimiter есть свои подсекции (по умолчанию: false).
  7. Логирование запросов gRPC-клиента, указывается для конкретного сервиса (по умолчанию: false).
  8. Логирование запросов SOAP-клиента, указывается для конкретного сервиса (по умолчанию: false).
  9. Логирование запросов HTTP-клиента, указывается для конкретного клиента по его собственному пути конфигурации (по умолчанию: false).
  10. Логирование Kafka-потребителя, указывается для конкретного потребителя (по умолчанию: false).
  11. Логирование 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)!
  1. Логирование запросов к базе данных JDBC (по умолчанию: false).
  2. Логирование запросов к базе данных Cassandra (по умолчанию: false).
  3. Логирование запросов gRPC-сервера (по умолчанию: false).
  4. Логирование запросов HTTP-сервера (по умолчанию: false).
  5. Логирование запусков планировщика (по умолчанию: false).
  6. Логирование смены состояний предохранителя; у retry, timeout, fallback и rateLimiter есть свои подсекции (по умолчанию: false).
  7. Логирование запросов gRPC-клиента, указывается для конкретного сервиса (по умолчанию: false).
  8. Логирование запросов SOAP-клиента, указывается для конкретного сервиса (по умолчанию: false).
  9. Логирование запросов HTTP-клиента, указывается для конкретного клиента по его собственному пути конфигурации (по умолчанию: false).
  10. Логирование Kafka-потребителя, указывается для конкретного потребителя (по умолчанию: false).
  11. Логирование 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:

implementation "io.koraframework:logging-logback"

Модуль:

@KoraApp
public interface Application extends LogbackModule { }

Зависимость build.gradle.kts:

implementation("io.koraframework:logging-logback")

Модуль:

@KoraApp
interface Application : LogbackModule

LogbackModule наследует LoggingModule, поэтому отдельная зависимость logging-common не требуется.

Конфигурация

Файл logback.xml необязателен. Модуль регистрирует KoraLogbackConfigurator через SPI Configurator из Logback, и тот определяет конфигурацию в таком порядке:

  1. Если Logback находит файл конфигурации (logback-test.xml, logback.xml или файл, указанный системным свойством logback.configurationFile), файл применяется как обычно, и Kora не добавляет собственных аппендеров.
  2. Иначе Kora выбирает один энкодер и подключает его к корневому логгеру через ConsoleAppender с именем KORA_CONSOLE, обернутый в KoraAsyncAppender с именем KORA_ASYNC. Корневой уровень такой цепочки берется из свойства kora.logging.levels.root (по умолчанию: INFO).

Для нового приложения начните без XML: подключите logging-logback и LogbackModule для текстового вывода. Добавьте logging-logback-json, если сборщику логов нужен структурированный JSON; вне тестовых воркеров Gradle он станет форматом по умолчанию. Укажите kora.logging.encoder или KORA_LOGGING_ENCODER со значением text, pretty или json, если формат не должен зависеть от classpath и среды тестирования. Для json нужна зависимость logging-logback-json. Для изменения вывода с сохранением автоматической цепочки используйте фабрику энкодера. XML нужен для собственных аппендеров, маршрутизации Logback и других настроек, которые цепочка по умолчанию не поддерживает.

В обоих случаях java.util.logging перенаправляется в Logback: стандартный консольный обработчик JUL удаляется, а уровни Logback зеркалируются в JUL, так что библиотека, логирующая через JUL, подчиняется уровням из logging.levels. Мост не устанавливается повторно, если его уже установил файл конфигурации, и отключается свойством kora.logging.config.jul-bridge=false.

Файл конфигурации нужен, только когда цепочка по умолчанию не подходит, например чтобы добавить файловый аппендер или собственный шаблон. XML имеет приоритет над kora.logging.encoder: энкодер и цепочку аппендеров нужно задать явно. Этот необязательный logback.xml воспроизводит стандартную текстовую цепочку:

<configuration debug="false">
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="io.koraframework.logging.logback.text.ConsoleTextRecordEncoder"/>
    </appender>

    <appender name="ASYNC" class="io.koraframework.logging.logback.KoraAsyncAppender">
        <appender-ref ref="STDOUT"/>
    </appender>

    <root level="INFO">
        <appender-ref ref="ASYNC"/>
    </root>
</configuration>

Для JSON в таком необязательном XML добавьте logging-logback-json и замените класс энкодера на io.koraframework.logging.logback.json.JsonRecordEncoder. В обоих случаях сохраняйте KoraAsyncAppender, чтобы захватывать контекст MDC и трассировки. Без XML Kora создаст эту цепочку сама.

KoraAsyncAppender выполняет сразу две задачи. Он передает запись рабочему потоку для асинхронной записи, а перед этим снимает все, что живет в текущей области видимости и иначе потерялось бы по дороге: значения Kora MDC и текущий спан OpenTelemetry. И то и другое сохраняется в KoraLoggingEvent — типе события, который понимают энкодер и конвертеры Kora.

Контекст Kora виден только благодаря KoraAsyncAppender

Значения Kora MDC, traceId и spanId читаются из KoraLoggingEvent. Цепочка аппендеров без KoraAsyncAppender порождает обычные события Logback, и эти поля просто не появляются в выводе. Оборачивайте реальный аппендер в KoraAsyncAppender даже тогда, когда асинхронная запись сама по себе не нужна.

Значения по умолчанию у KoraAsyncAppender ставят приложение выше лога: очередь ограничена, при заполненной очереди запись отбрасывается, а не блокирует логирующий поток, и при остановке приложение ждет сброса очереди не дольше секунды. Они меняются через свойства начальной загрузки, а в logback.xml вложенные элементы аппендера <queueSize>, <discardingThreshold>, <maxFlushTime> и <neverBlock> переопределяют эти свойства.

Выбор энкодера

Энкодер цепочки по умолчанию создается фабрикой LogbackEncoderFactory. Фабрики находятся через java.util.ServiceLoader, поэтому достаточно положить модуль в classpath, чтобы его энкодер стал доступен, и используется ровно одна из них: названная в kora.logging.encoder (без учета регистра), а если имя не задано — фабрика с наибольшим приоритетом.

Имя Фабрика Модуль Приоритет Вывод
text ConsoleTextEncoderFactory logging-logback 0 обычный текст
pretty ColorConsoleTextEncoderFactory logging-logback 1000 в тестовом воркере Gradle, минимально возможный в остальных случаях тот же текст, где метка времени и уровень подсвечены цветами ANSI
json JsonEncoderFactory logging-logback-json 100 один объект JSON на строку

В итоге приложение по умолчанию пишет обычный текст, переходит на JSON, как только в classpath появляется logging-logback-json, и пишет цветной текст в тестах Gradle, которые распознаются по системному свойству org.gradle.test.worker. Тест, проверяющий вывод в JSON, отказывается от цветов через kora.logging.encoder=json, а цветной текст включается где угодно через KORA_LOGGING_ENCODER=pretty.

Значение none возвращает настройку конфигурации по умолчанию самого Logback. Неизвестное имя сообщается как ошибка статуса Logback и приводит к тому же результату. Собственный энкодер подключается так же — регистрацией своей фабрики.

Свойства начальной загрузки

Logback запускается раньше графа приложения и его файла конфигурации, поэтому модуль читает свои настройки из системных свойств JVM и переменных окружения. Каждое свойство сначала ищется как системное свойство, а затем как переменная окружения, имя которой — это имя свойства в верхнем регистре с заменой . и - на _:

Системное свойство Переменная окружения Описание
kora.logging.encoder KORA_LOGGING_ENCODER Имя энкодера цепочки по умолчанию или none (по умолчанию: найденный энкодер с наибольшим приоритетом).
kora.logging.levels.root KORA_LOGGING_LEVELS_ROOT Уровень корневого логгера цепочки по умолчанию до применения logging.levels (по умолчанию: INFO).
kora.logging.config.jul-bridge KORA_LOGGING_CONFIG_JUL_BRIDGE Перенаправляет java.util.logging в Logback (по умолчанию: true).
kora.logging.config.timestamp-epoch-millis KORA_LOGGING_CONFIG_TIMESTAMP_EPOCH_MILLIS Пишет метку времени записи как число миллисекунд от эпохи вместо отформатированной даты (по умолчанию: false).
kora.logging.config.queue-size KORA_LOGGING_CONFIG_QUEUE_SIZE Емкость очереди KoraAsyncAppender (по умолчанию: 512).
kora.logging.config.discarding-threshold KORA_LOGGING_CONFIG_DISCARDING_THRESHOLD Остаток емкости очереди, ниже которого записи TRACE, DEBUG и INFO отбрасываются, 0 отключает отбрасывание по уровню (по умолчанию: пятая часть размера очереди).
kora.logging.config.max-flush-time KORA_LOGGING_CONFIG_MAX_FLUSH_TIME Сколько остановка ждет сброса очереди, в виде 1s, 500ms или PT1S, 0 ждет без ограничения (по умолчанию: 1s).
kora.logging.config.never-block KORA_LOGGING_CONFIG_NEVER_BLOCK Отбрасывает запись вместо блокировки логирующего потока, когда очередь заполнена (по умолчанию: true).

Некорректное значение не мешает логированию запуститься: вместо него используется значение по умолчанию. Для jul-bridge и настроек KoraAsyncAppender значение дополнительно сообщается как предупреждение статуса Logback, неизвестное имя энкодера сообщается как ошибка статуса, а неизвестный корневой уровень молча заменяется на INFO.

Это не ключи конфигурации приложения

Эти свойства читаются только из системных свойств и переменных окружения, но никогда из application.conf или application.yaml. Уровни логгеров работающего приложения по-прежнему задаются секцией logging.levels, которая после старта графа заменяет корневой уровень цепочки по умолчанию.

Формат записи лога

Энкодеры text и pretty — это один и тот же io.koraframework.logging.logback.text.ConsoleTextRecordEncoder. Он формирует текстовую строку с добавленными структурированными полями, а не единый документ JSON, и складывается так:

  • метка времени yyyy-MM-dd HH:mm:ss.SSS в UTC (миллисекунды от эпохи при kora.logging.config.timestamp-epoch-millis=true), уровень, имя потока в квадратных скобках и имя логгера;
  • traceId=... spanId=..., если запись порождена внутри трассируемой операции;
  • записи Kora MDC в виде key=<json value>;
  • записи MDC из SLF4J в виде обычного key=value;
  • отформатированное сообщение;
  • по одной строке fieldName={json} с отступом табуляцией на каждое структурированное поле из маркеров, аргументов сообщения и пар ключ-значение SLF4J, значение которых — StructuredArgumentWriter;
  • стектрейс, если запись несет 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 символов.

Каждую из этих частей добавляет свой LoggingEventTextWriter из пакета io.koraframework.logging.logback.text.writer: DefaultLoggingEventTextWriter, DefaultTraceTextWriter, DefaultMdcTextWriter, DefaultMessageTextWriter, DefaultStructuredTextWriter и DefaultExceptionTextWriter, именно в таком порядке. Элементы <writer> в logback.xml заменяют список по умолчанию целиком, поэтому собственная раскладка перечисляет все части, которые в ней остаются:

<encoder class="io.koraframework.logging.logback.text.ConsoleTextRecordEncoder">
    <writer class="io.koraframework.logging.logback.text.writer.DefaultLoggingEventTextWriter"/>
    <writer class="io.koraframework.logging.logback.text.writer.DefaultTraceTextWriter"/>
    <writer class="io.koraframework.logging.logback.text.writer.DefaultMessageTextWriter"/>
    <writer class="io.koraframework.logging.logback.text.writer.DefaultExceptionTextWriter"/>
</encoder>

Писатель, выбросивший исключение, не теряет запись: энкодер откатывается на строку с уровнем, логгером, сообщением и описанием сбоя.

Формат JSON

JsonRecordEncoder пишет каждую запись одной строкой JSON, которую сборщики логов принимают без шаблона разбора. Он находится в отдельном модуле:

Зависимость build.gradle:

implementation "io.koraframework:logging-logback-json"

Зависимость build.gradle.kts:

implementation("io.koraframework:logging-logback-json")

Модуль не требует подключать интерфейс модуля Kora: его энкодер json находится в classpath и, имея приоритет выше, чем у text, становится энкодером по умолчанию.

Запись содержит следующие поля, а поле без значения опускается:

  • @timestamp — дата ISO 8601 в UTC или число миллисекунд от эпохи при kora.logging.config.timestamp-epoch-millis=true;
  • level, thread, logger, message;
  • traceId, spanId — если запись порождена внутри трассируемой операции;
  • mdc — объект с записями Kora MDC в виде типизированных значений JSON и записями MDC из SLF4J в виде строк; при совпадении ключа побеждает Kora MDC;
  • data — структурированный аргумент с именем data, куда аспект @Log пишет аргументы и результаты; несколько аргументов data сливаются в один объект;
  • args — остальные структурированные аргументы из маркеров и параметров сообщения и все пары ключ-значение SLF4J; простое значение пары сохраняет числовой или логический тип, а любой другой объект пишется через toString();
  • exception — объект с полями class, message и stacktrace, если запись несет Throwable; структурный аргумент с именем exception или throwable добавляется в него как data.
{"@timestamp":"2026-07-02T10:15:30.123Z","level":"INFO","thread":"kora-undertow-1","logger":"io.koraframework.example.SomeService","message":"user logged in","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"00f067aa0ba902b7","mdc":{"userId":42},"args":{"role":"admin"}}

Энкодер можно объявить и в logback.xml. Как и текстовый энкодер, он собирается из писателей — реализаций LoggingEventJsonWriter из пакета io.koraframework.logging.logback.json.writer, — и вложенные элементы <writer> заменяют список по умолчанию целиком. Писатель, выбросивший исключение, не теряет запись: энкодер откатывается на запись с @timestamp, level, logger, message и описанием сбоя в exception.

Собственный текстовый или JSON-энкодер без XML

Если нужно добавить поля в консольный или JSON-формат, но подходит стандартная асинхронная цепочка, реализуйте LogbackEncoderFactory. Код приложения продолжает использовать обычный SLF4J Logger; фабрика определяет запись событий. Фабрика возвращает незапущенный энкодер: KoraLogbackConfigurator задает ему контекст, запускает и оборачивает консольный аппендер в KoraAsyncAppender. Фабрика находится через ServiceLoader, а не как компонент графа Kora.

Для текста возьмите стандартных писателей и вставьте собственный LoggingEventTextWriter перед сообщением:

src/main/java/com/example/CompanyTextEncoderFactory.java
package com.example;

import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.encoder.Encoder;
import io.koraframework.logging.logback.LogbackEncoderFactory;
import io.koraframework.logging.logback.text.ConsoleTextRecordEncoder;
import java.util.ArrayList;

public final class CompanyTextEncoderFactory implements LogbackEncoderFactory {
    @Override public String name() { return "company-text"; }
    @Override public int priority() { return -1; }

    @Override public Encoder<ILoggingEvent> create(LoggerContext context) {
        var writers = new ArrayList<>(ConsoleTextRecordEncoder.defaultWriters(false));
        writers.add(3, (out, event) -> out.append("[service=orders] "));
        return new ConsoleTextRecordEncoder(writers);
    }
}

Для структурированного JSON дополните стандартный список реализацией LoggingEventJsonWriter; энкодер по-прежнему пишет по одному JSON-объекту на строку:

src/main/java/com/example/CompanyJsonEncoderFactory.java
package com.example;

import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.encoder.Encoder;
import io.koraframework.logging.logback.LogbackEncoderFactory;
import io.koraframework.logging.logback.json.JsonRecordEncoder;
import java.util.ArrayList;

public final class CompanyJsonEncoderFactory implements LogbackEncoderFactory {
    @Override public String name() { return "company-json"; }
    @Override public int priority() { return -1; }

    @Override public Encoder<ILoggingEvent> create(LoggerContext context) {
        var writers = new ArrayList<>(JsonRecordEncoder.defaultWriters());
        writers.add((gen, event) -> gen.writeStringProperty("service", "orders"));
        return new JsonRecordEncoder(writers);
    }
}

Для JSON-фабрики нужна зависимость logging-logback-json в дополнение к logging-logback. Зарегистрируйте одну или обе фабрики по полным именам классов, по одному в строке:

src/main/resources/META-INF/services/io.koraframework.logging.logback.LogbackEncoderFactory
com.example.CompanyTextEncoderFactory
com.example.CompanyJsonEncoderFactory

Выберите фабрику через kora.logging.encoder=company-text или kora.logging.encoder=company-json (либо KORA_LOGGING_ENCODER). Приоритет -1 не меняет формат по умолчанию, пока имя не задано явно. Реализация на Kotlin использует тот же интерфейс LogbackEncoderFactory и сервисный файл. Для более глубокого изменения формата реализуйте LoggingEventTextWriter или LoggingEventJsonWriter; включайте его вместе со стандартными писателями, чтобы сохранить поля трассировки, MDC и исключений. Маскирование полей показано в примере JSON.

Если провайдер находится в именованном Java-модуле, вместо файла META-INF/services укажите в module-info.java строку provides io.koraframework.logging.logback.LogbackEncoderFactory with com.example.CompanyTextEncoderFactory, com.example.CompanyJsonEncoderFactory;. Для структурированных значений в отдельных вызовах лога используйте структурированные аргументы с обычным логгером SLF4J.

Маскирование записи

JsonRecordEncoder умеет маскировать поля готовой записи по имени, кто бы их ни записал:

<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="io.koraframework.logging.logback.json.JsonRecordEncoder">
        <maskField>password</maskField>
        <maskField>token</maskField>
    </encoder>
</appender>

<appender name="ASYNC" class="io.koraframework.logging.logback.KoraAsyncAppender">
    <appender-ref ref="STDOUT"/>
</appender>

Значения <maskField> превращаются в FieldLoggingEventJsonMasker: поле с таким именем находится без учета регистра в любом месте записи — в mdc, data или args, — и его значение заменяется на "***", объект или массив целиком. Для других правил реализуйте LoggingEventJsonMasker и объявите его как <masker class="..."/>: shouldMask(path, fieldName) получает путь через точку от корня записи, например args.user.password, а writeMasked пишет замену (по умолчанию: "***"). Если объявлены и <masker>, и <maskField>, значения <maskField> игнорируются с предупреждением статуса.

Такое маскирование работает только по именам полей и ничего не знает о логируемых типах; чтобы маскировать значения типа везде, где он логируется, используйте @Mask.

Энкодер json цепочки по умолчанию создается без маскирования. Чтобы сохранить эту цепочку и не писать logback.xml, зарегистрируйте собственную фабрику энкодера:

public final class MaskedJsonEncoderFactory implements LogbackEncoderFactory {

    @Override
    public String name() {
        return "masked-json"; //(1)!
    }

    @Override
    public int priority() {
        return 200; //(2)!
    }

    @Override
    public Encoder<ILoggingEvent> create(LoggerContext context) {
        var encoder = new JsonRecordEncoder();
        encoder.addMaskField("password");
        encoder.addMaskField("token");
        return encoder; //(3)!
    }
}
  1. Имя, по которому энкодер выбирается через kora.logging.encoder=masked-json.
  2. Выше, чем у json (100), поэтому фабрика побеждает без явного имени; в тестах Gradle по-прежнему побеждает pretty (1000), если энкодер не назван явно.
  3. Энкодер возвращается незапущенным: KoraLogbackConfigurator сам задает ему контекст и запускает его.
class MaskedJsonEncoderFactory : LogbackEncoderFactory {

    override fun name(): String = "masked-json" //(1)!

    override fun priority(): Int = 200 //(2)!

    override fun create(context: LoggerContext): Encoder<ILoggingEvent> {
        val encoder = JsonRecordEncoder()
        encoder.addMaskField("password")
        encoder.addMaskField("token")
        return encoder //(3)!
    }
}
  1. Имя, по которому энкодер выбирается через kora.logging.encoder=masked-json.
  2. Выше, чем у json (100), поэтому фабрика побеждает без явного имени; в тестах Gradle по-прежнему побеждает pretty (1000), если энкодер не назван явно.
  3. Энкодер возвращается незапущенным: KoraLogbackConfigurator сам задает ему контекст и запускает его.

Фабрика находится через java.util.ServiceLoader, поэтому она указывается в сервисном файле, а не объявляется компонентом графа:

src/main/resources/META-INF/services/io.koraframework.logging.logback.LogbackEncoderFactory
com.example.MaskedJsonEncoderFactory

Собственный шаблон

Вместо 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, привязанный к логирующему потоку, выводя каждую запись как key: <json value>. В потоке без области видимости MDC — при инициализации графа, в shutdown hook, в пуле потоков библиотеки — он ничего не выводит, а остальная часть строки все равно пишется. За обычным асинхронным аппендером конвертер выполняется в потоке записи и вовсе не видит MDC, поэтому KoraAsyncAppender и снимает снимок в вызывающем потоке, прежде чем передать запись дальше.

Строка в логе не является контрактом запуска

Формулировки собственных стартовых сообщений Kora не входят в публичный контракт и меняются между версиями, а асинхронный аппендер под нагрузкой может потерять запись. Не ждите строку в логе, чтобы решить, что сервис поднялся — опрашивайте пробу готовности по пути /system/readiness.

Другая реализация

Kora использует slf4j-api как фасад логирования, поэтому можно подключить любую совместимую реализацию. Базовый модуль добавляет общие компоненты для структурированных логов и управления уровнями логирования через файл конфигурации.

Подключение

Требуется подключить общий модуль логирования:

Зависимость build.gradle:

implementation "io.koraframework:logging-common"

Модуль:

@KoraApp
public interface Application extends LoggingModule { }

Зависимость build.gradle.kts:

implementation("io.koraframework:logging-common")

Модуль:

@KoraApp
interface Application : LoggingModule

Использование

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)!
        //...
    }
}
  1. Применяет уровень к логгеру с указанным именем; уровень приходит из карты logging.levels в том виде, в каком записан в конфигурации.
  2. Возвращает все логгеры в исходное состояние; вызывается перед каждым проходом применения, в том числе при обновлении конфигурации.
@Component
class SomeLoggingLevelApplier : LoggingLevelApplier {

    override fun apply(logName: String, logLevel: String) { //(1)!
        //...
    }

    override fun reset() { //(2)!
        //...
    }
}
  1. Применяет уровень к логгеру с указанным именем; уровень приходит из карты logging.levels в том виде, в каком записан в конфигурации.
  2. Возвращает все логгеры в исходное состояние; вызывается перед каждым проходом применения, в том числе при обновлении конфигурации.

Если приложение использует структурированные данные, собственная реализация также должна отображать 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 добавляет структурированное поле к записи лога и не занимает место параметра в текстовом сообщении:

var logger = LoggerFactory.getLogger(getClass());
var marker = StructuredArgument.marker("key", "value");
logger.info(marker, "message");
val logger = LoggerFactory.getLogger(javaClass)
val marker = StructuredArgument.marker("key", "value")
logger.info(marker, "message")

Параметр

Параметр сообщения добавляет структурированное поле через обычный массив аргументов SLF4J:

var logger = LoggerFactory.getLogger(getClass());
var parameter = StructuredArgument.arg("key", "value");
logger.info("message", parameter);
val logger = LoggerFactory.getLogger(javaClass)
val parameter = StructuredArgument.arg("key", "value")
logger.info("message", parameter)

Пара ключ-значение

Текучий построитель SLF4J прикрепляет именованное значение, не затрагивая ни сообщение, ни список маркеров. StructuredArgument.value оборачивает писателя в StructuredArgumentWriter, который энкодер Kora отображает как структурированное поле — именно эту форму использует собственная телеметрия Kora:

var logger = LoggerFactory.getLogger(getClass());
logger.atInfo()
    .addKeyValue("user", StructuredArgument.value(gen -> {
        gen.writeStartObject();
        gen.writeStringProperty("id", "42");
        gen.writeStringProperty("role", "admin");
        gen.writeEndObject();
    }))
    .log("user logged in");
val logger = LoggerFactory.getLogger(javaClass)
logger.atInfo()
    .addKeyValue("user", StructuredArgument.value { gen ->
        gen.writeStartObject()
        gen.writeStringProperty("id", "42")
        gen.writeStringProperty("role", "admin")
        gen.writeEndObject()
    })
    .log("user logged in")

Сложный объект

Для значений, которые не являются String, числом, Boolean или Map<String, String>, передайте JsonWriter<T> (тот же генерируемый для типа писатель @Json) или необработанную лямбду StructuredArgumentWriter, которая пишет значение поля напрямую в JsonGenerator. Обе перегрузки предоставляют и arg, и marker:

var logger = LoggerFactory.getLogger(getClass());
var parameter = StructuredArgument.arg("user", gen -> {
    gen.writeStartObject();
    gen.writeStringProperty("id", "42");
    gen.writeStringProperty("role", "admin");
    gen.writeEndObject();
});
logger.info("user logged in", parameter);
val logger = LoggerFactory.getLogger(javaClass)
val parameter = StructuredArgument.arg("user") { gen ->
    gen.writeStartObject()
    gen.writeStringProperty("id", "42")
    gen.writeStringProperty("role", "admin")
    gen.writeEndObject()
}
logger.info("user logged in", parameter)

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));
    }
}
  1. Писатель, сгенерированный для типа с аннотацией @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)
    }
}
  1. Писатель, сгенерированный для типа с аннотацией @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.

MDC.put("key", "value");
try {
    logger.info("message");
} finally {
    MDC.remove("key");
}
MDC.put("key", "value")
try {
    logger.info("message")
} finally {
    MDC.remove("key")
}

put принимает значения String, Integer, Long и Boolean, а также необработанный StructuredArgumentWriter для произвольного JSON; типизированные значения выводятся как их тип JSON, а не как текст, а значение null выводится как null в JSON:

MDC.put("userId", 42); //(1)!
logger.info("user resolved");
  1. Отображается как JSON-число (userId=42), а не как строка.
MDC.put("userId", 42) //(1)!
logger.info("user resolved")
  1. Отображается как JSON-число (userId=42), а не как строка.

Область видимости 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");
});
  1. MDC.VALUE — это ScopedValue, хранящий текущий MDC; привязка видна только в этом потоке внутри этого блока.
ScopedValue.where(MDC.VALUE, MDC()).call<Unit, RuntimeException> { //(1)!
    MDC.put("jobId", jobId)
    logger.info("job started")
}
  1. MDC.VALUE — это ScopedValue, хранящий текущий MDC. Используется call, а не run, потому что run разрешился бы в расширение стандартной библиотеки Kotlin, а не в метод носителя.

Из-за этой же привязки к области видимости KoraAsyncAppender копирует MDC в момент добавления записи: у асинхронного рабочего потока собственной привязки нет, и именно скопированное в KoraLoggingEvent значение позже отображает энкодер.