Kora Telemetry Logging
Kora sub-skill — obey the kora-v2 meta rules on every task: R0 ground the workspace on Kora 2.0 refs before starting (framework source at tag
2.0.0.RC1+kora-examplesatmigration/2.0;kora-docsis 1.x only) · R1 read this sub-skill before writing code · R2 Kora 2.0 APIs only — no Spring/Micronaut/Quarkus, no Kora 1.x APIs, no invented annotations or config keys · R3 journal any incorrect Kora usage. Add comments/Javadoc only if asked.
| Artifacts | io.koraframework:logging-logback (pulls logging-common, json-common, core:common), BOM io.koraframework:kora-bom |
| Modules | LogbackModule — io.koraframework.logging.logback; it extends LoggingModule — io.koraframework.logging.common |
| Logback classes | io.koraframework.logging.logback — KoraAsyncAppender, ConsoleTextRecordEncoder, KoraLoggingEvent, KoraMdcConverter, KoraLoggingMarkerConverter |
| Structured API | io.koraframework.logging.common.MDC, …logging.common.arg.{StructuredArgument, StructuredArgumentWriter, StructuredArgumentMapper}, …logging.common.masking.{MaskingRules, MaskingStrategy}, @…logging.common.annotation.Mask |
| Config | logging.levels (LoggingConfig.levels() → Map<String,String>); per component <path>.telemetry.logging.enabled |
| Third party | Logback 1.6.2, SLF4J 2.0.18, Jackson 3.2.1 under tools.jackson.core |
@Log and @Mdc are not in this skill — they are the declarative aspects, covered by
kora-aop-logging. This skill owns the logging backend: module
wiring, logback.xml, log levels, structured records, MDC values, and component telemetry logging.
The one thing that trips everyone: there are two independent switches
A Kora component writes a telemetry log record only when both are satisfied. They are unrelated keys with different defaults, and each fails silently on its own.
| # | Switch | Where | Default | What it controls |
|---|---|---|---|---|
| 1 | <path>.telemetry.logging.enabled |
component config section | false (TelemetryConfig.LoggingConfig.enabled()) |
Whether the component builds a real logger at all. When false it gets a Noop logger and emits nothing at any level. |
| 2 | logging.levels."<logger name>" |
logging config section |
root INFO, everything else inherits |
The SLF4J level of the logger the component writes to. Every component logger guards itself with isDebugEnabled() / isInfoEnabled() / isTraceEnabled(). |
jdbc {
poolName = "kora"
telemetry.logging.enabled = true # switch 1 — without it: nothing, ever
}
logging.levels {
"io.koraframework.database.kora.query" = "DEBUG" # switch 2 — DB query records are DEBUG-only
}
The database logger name is built as "io.koraframework.database." + poolName + ".query", so the
kora segment above is this pool's poolName, not a fixed string.
jdbc.telemetry.logging.enabled = true alone produces no output: DefaultDatabaseLogger
returns early unless the logger is at DEBUG. Raising the level alone produces no output either,
because the component holds a NOPLogger. Say which switch you mean whenever you answer a
"my logs are missing" question — see
component-telemetry-reference.md for the full
logger-name table and each component's config path.
telemetry.tracing.enabled defaults to true, telemetry.metrics.enabled to false — do not
generalise from one to the others.
Renamed / changed from Kora 1.x
| Kora 1.x | Kora 2.0 |
|---|---|
ru.tinkoff.kora:logging-logback / logging-common |
io.koraframework:logging-logback / logging-common |
ru.tinkoff.kora.logging.logback.* |
io.koraframework.logging.logback.* (LogbackModule, KoraAsyncAppender, ConsoleTextRecordEncoder all survive under the new package) |
ru.tinkoff.kora.logging.common.MDC backed by Kora Context |
io.koraframework.logging.common.MDC backed by ScopedValue<MDC> — Context no longer exists anywhere in the framework |
Jackson 2 generator (writeStringField, writeNumberField) |
Jackson 3 tools.jackson.core.JsonGenerator — writeStringProperty, writeNumberProperty, writeName |
ru.tinkoff.kora:kora-parent BOM |
io.koraframework:kora-bom |
db.telemetry.logging.enabled |
jdbc.telemetry.logging.enabled (the JDBC config section was renamed db → jdbc) |
META-INF/native-image/ru.tinkoff.kora.<x>/logback/ |
META-INF/native-image/io.koraframework.<x>/logback/ — rename the directory and the io.koraframework.logging.logback.* entries inside |
logging.levels kept its name and shape across the rename — LoggingConfig in 2.0 still exposes
Map<String,String> levels(), and LoggingModule still reads the logging section. The key is
levels, plural — never logging.level. A singular key is not a recognised HOCON/YAML key,
is ignored without a warning, and leaves every logger at its default level.
Quick start
1. Dependencies
Kora 2.0 is on Maven Central. Put koraVersion=2.0.0.RC1 in gradle.properties.
repositories { mavenCentral() }
configurations {
koraBom
annotationProcessor.extendsFrom(koraBom)
implementation.extendsFrom(koraBom)
}
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion")
annotationProcessor "io.koraframework:annotation-processors" // Kotlin: ksp "io.koraframework:symbol-processors"
implementation "io.koraframework:logging-logback" // Logback backend; pulls logging-common
implementation "io.koraframework:config-hocon" // MANDATORY — LoggingModule reads the `logging` config section
}
logging-logback declares api project(':logging:logging-common'), and logging-common declares
api project(':json:json-common') and api project(':core:common'). So JsonWriter, the
structured-argument API and the OpenTelemetry API that KoraAsyncAppender needs all arrive
transitively — no extra dependency. Never pin a version on an individual io.koraframework:*
artifact; the BOM does it.
2. Application graph
LogbackModule extends LoggingModule, and LoggingModule.loggingConfig(Config, ConfigValueMapper<LoggingConfig>)
reads config.get("logging") — so a config module (HoconConfigModule or YamlConfigModule) must
be in the graph too, or the graph will not build.
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends LogbackModule, HoconConfigModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
LogbackModule contributes one thing of its own: a LoggingLevelApplier that drives the Logback
LoggerContext. LoggingModule contributes LoggingConfig, an ILoggerFactory, the three
built-in MaskingStrategy components, the structured-argument mappers, and a @Root
LoggingLevelRefresher that applies logging.levels during graph initialization.
3. logback.xml
This is the shape every migrated example app uses: Kora's own text encoder inside a
ConsoleAppender, wrapped by KoraAsyncAppender.
<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="INFO">
<appender-ref ref="ASYNC"/>
</root>
<!-- Logger levels are configured in application.conf -->
</configuration>
ConsoleTextRecordEncoder is what renders traceId/spanId, Kora MDC entries and structured
arguments; a plain <pattern> encoder drops all of them. Use ConsoleTextRecordEncoder in
src/main/resources/logback.xml and keep the pattern encoder for logback-test.xml, exactly as
the examples do.
4. Levels come from the config, not from logback.xml
LoggingLevelRefresher.init() runs during graph initialization and calls
LoggingLevelApplier.reset() first: the Logback root logger is forced to INFO and every
other logger's level is set to null (inherit). Only then are the logging.levels entries
applied. Any <logger name="…" level="…"/> element and the <root level="…"> value in
logback.xml are therefore discarded the moment the graph starts.
logging.levels {
"ROOT" = "WARN"
"io.koraframework" = "INFO"
"com.example" = "DEBUG"
}
logging:
levels:
ROOT: "WARN"
io.koraframework: "INFO"
com.example: "DEBUG"
5. Log
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log = LoggerFactory.getLogger(UserService.class);
log.info("Created user with id={}", generatedId); // parameterized, never concatenated
What's in references/ and assets/
| Reference | Purpose |
|---|---|
| logback-config-reference.md | Module wiring, logback.xml patterns, the level-reset rule, native-image logback metadata, troubleshooting |
| component-telemetry-reference.md | telemetry.logging.enabled per component, the logger-name table, level→detail ladder, masking of headers/queries/bodies |
| structured-logging-reference.md | StructuredArgument arg/marker/value, the Jackson 3 generator API, StructuredArgumentMapper, @Mask / MaskingRules |
| mdc-context-reference.md | Kora MDC over ScopedValue, where a scope is bound, SLF4J MDC, seeding MDC from an HTTP interceptor |
| json-logging-reference.md | What Kora 2.0 actually ships for machine-readable output, and how to write a JSON Encoder<ILoggingEvent> |
| async-logging-reference.md | KoraAsyncAppender internals, AsyncAppenderBase parameters, shutdown, troubleshooting |
| Asset | Purpose |
|---|---|
Application.logging.java.template, Application.logging.kt.template |
@KoraApp with LogbackModule + HoconConfigModule |
build.gradle.logging.template |
BOM, processor, logging-logback + config-hocon |
logback.xml.template |
Production text output: ConsoleTextRecordEncoder + KoraAsyncAppender |
logback.dev.xml.template |
Local development, colourised pattern encoder |
logback-test.xml.template |
src/test/resources variant used by the example apps |
application.logging.conf.template |
logging.levels + per-component telemetry.logging.enabled |
LoggingInterceptor.java.template |
HttpServerInterceptor that seeds Kora MDC from request headers |
LoggingService.java.template |
StructuredArgument / MDC / @Mask usage in a @Component |
Core patterns
Structured arguments
io.koraframework.logging.common.arg.StructuredArgument attaches machine-readable JSON to a
record, as a parameter, a marker (metadata only), or a bare value for SLF4J key/value
pairs. In all three forms the JSON is appended on its own indented line as name={…}; the
parameter form additionally consumes a {} slot, where SLF4J prints the argument's toString()
(a bare record dump, not the JSON) — so keep the message a constant and prefer the marker or
addKeyValue form. Typed overloads exist for String, Integer, Long, Boolean and
Map<String,String>; anything else takes a writer lambda over the Jackson 3
tools.jackson.core.JsonGenerator.
import io.koraframework.logging.common.arg.StructuredArgument;
log.info("Request {} processed", StructuredArgument.arg("requestId", requestId));
log.info(StructuredArgument.marker("userId", userId), "User action performed");
log.atInfo()
.addKeyValue("user", StructuredArgument.value(gen -> {
gen.writeStartObject();
gen.writeStringProperty("id", user.id()); // Jackson 3 name — NOT writeStringField
gen.writeStringProperty("email", user.email());
gen.writeEndObject();
}))
.log("User created");
See structured-logging-reference.md.
Kora MDC
io.koraframework.logging.common.MDC holds structured (JSON-typed) values and is published
through public static final ScopedValue<MDC> VALUE. Kora binds a fresh MDC at the entry of
every request, message or job — the Undertow request handler, the gRPC transport filter, each
Kafka consumer batch/record, each scheduled job, each JMS message. Inside such a scope
MDC.put(...) works; outside one — graph init, a shutdown hook, a plain unit test —
MDC.get() throws because the ScopedValue is unbound.
import io.koraframework.logging.common.MDC;
MDC.put("orderId", orderId); // String / Integer / Long / Boolean / StructuredArgumentWriter
MDC.put("attempt", attempt);
log.info("Processing order"); // both keys attached to the record
KoraAsyncAppender snapshots the bound MDC into the queued event, so values survive the hop to
the appender thread; Logback's stock AsyncAppender does not. The string-only org.slf4j.MDC
also works (Kora speaks SLF4J) and is what a %X{} pattern reads. See
mdc-context-reference.md.
Masking
@io.koraframework.logging.common.annotation.Mask on a record/class field, and on the type
itself, tells the processor to generate a <Type>MaskingRulesModule supplying a
MaskingRules<T> component. MaskedStructuredArgumentMapper then replaces matched values through
a MaskingStrategy — MaskingFull (default, ***), MaskingKeepFirst, MaskingKeepLast, or
your own @Component. Rules match a bare field name globally (password), a dotted path from the
logged root (user.password), or a path with a * wildcard segment (users.*.password).
Common pitfalls
| Problem | Cause / fix |
|---|---|
| Component telemetry logs missing | Two switches — set <path>.telemetry.logging.enabled = true and the logger's level in logging.levels |
logging.level has no effect |
The key is logging.levels (plural). The singular form is an unknown key, silently ignored |
DB query logs missing with jdbc.telemetry.logging.enabled = true |
Query records are DEBUG; add "io.koraframework.database.<poolName>.query" = "DEBUG" to logging.levels |
<logger> / <root> levels in logback.xml are ignored |
LoggingLevelRefresher.init() resets every logger at graph start. Configure levels in logging.levels |
traceId, MDC or structured fields missing from the line |
The appender uses a plain <pattern> encoder — switch to ConsoleTextRecordEncoder, or write your own encoder that reads KoraLoggingEvent |
| Structured MDC empty behind an async appender | Use io.koraframework.logging.logback.KoraAsyncAppender, not Logback AsyncAppender |
MDC.get() throws NoSuchElementException |
Called outside a bound request/message/job scope. Guard with MDC.VALUE.isBound() |
writeStringField / writeNumberField does not compile |
Jackson 3: writeStringProperty / writeNumberProperty; the generator is tools.jackson.core.JsonGenerator |
Ported 1.x MDC helper built on Kora Context does not compile |
Context was removed from the whole framework. Use MDC directly inside the framework-bound scope |
Graph fails with a missing Config / ConfigValueMapper<LoggingConfig> |
LoggingModule needs a config module — add HoconConfigModule or YamlConfigModule |
Native image logs are empty or ignore logback.xml |
Metadata directory still on the old group, or a file named reflection-config.json (never read) — see logback-config-reference.md |
Anti-patterns
- Do not concatenate:
log.info("user " + id)— uselog.info("user {}", id). - Do not put per-package levels in
logback.xml; they are wiped at graph start. - Do not log secrets or PII. Use
@Maskfor structured values andmaskHeaders/maskQueriesfor HTTP telemetry. - Do not pin versions on
io.koraframework:*artifacts —io.koraframework:kora-bomdoes it. - Do not carry a Kora 1.x
Context-based MDC helper into 2.0; there is nothing to port it onto.