Logging
The declarative logging module lets you describe method logging with @Log and @Mdc annotations.
At compile time, Kora creates an aspect wrapper for the method; the wrapper logs method entry, method exit, result, error, and MDC values without manual code in business logic.
This is useful for consistent call diagnostics, especially when you need to quickly understand which method was called, with which arguments, and how it completed.
For a step-by-step walkthrough before the reference details, see Observability.
Dependency¶
Annotations and helper classes are provided by the logging-common dependency.
Usually it is already brought by other Kora modules or by Logback, but when using the annotations directly, the dependency can be added explicitly:
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
Aspect generation also requires the common annotation processors or KSP processors.
In a regular Kora application, they are already connected as part of the basic project setup.
Logging¶
Method logging is configured with annotation combinations:
@Log- logs method entry and exit (default:INFO).@Log.in- logs only method entry (default:INFO).@Log.out- logs only method exit (default:INFO).@Log.result- sets the level from which the result value is added to the log (default:DEBUG).@Log.off- disables logging of the method result or a specific parameter.@Log(Level)on a parameter - sets the level from which the parameter is added to structured data (default:DEBUGfor a parameter without a separate annotation).
The entry or exit event itself is written at the level specified in @Log, @Log.in, or @Log.out.
Argument and result values are added to structured data only when the corresponding detail level is enabled.
Which detail level is active depends on the effective logger level configured through logging.level / logging.levels — see logging levels configuration.
Arguments¶
| Logging level | Log |
|---|---|
| DEBUG |
INFO [main] r.t.e.e.Example.doWork: > {data: {numParam: "4"}} |
| TRACE |
INFO [main] r.t.e.e.Example.doWork: > {data: {numParam: "4"}} |
| INFO |
INFO [main] r.t.e.e.Example.doWork: > |
Result¶
| Logging level | Log |
|---|---|
| DEBUG |
INFO [main] r.t.e.e.Example.doWork: < {data: {out: "testResult"}} |
| TRACE |
INFO [main] r.t.e.e.Example.doWork: < {data: {out: "testResult"}} |
| INFO |
INFO [main] r.t.e.e.Example.doWork: < |
Arguments And Result¶
| Logging level | Log |
|---|---|
| DEBUG |
INFO [main] r.t.e.e.Example.doWork: > {data: {strParam: "s", numParam: "4"}} INFO [main] r.t.e.e.Example.doWork: < {data: {out: "testResult"}} |
| TRACE |
INFO [main] r.t.e.e.Example.doWork: > {data: {strParam: "s", numParam: "4"}} INFO [main] r.t.e.e.Example.doWork: < {data: {out: "testResult"}} |
| INFO |
INFO [main] r.t.e.e.Example.doWork: > INFO [main] r.t.e.e.Example.doWork: < |
If a method completes with an error, the aspect logs method exit with error data: errorType and errorMessage.
When DEBUG is enabled, the exception object is also passed to the log.
Selective Logging¶
| Logging level | Log |
|---|---|
| TRACE, DEBUG |
INFO [main] r.t.e.e.Example.doWork: < |
| INFO |
INFO [main] r.t.e.e.Example.doWork: < |
In this example, @Log.off on the method disables writing the result value, but does not disable the method exit event itself.
To exclude a specific argument from the log, put @Log.off on the parameter.
Parameter detail level can be configured separately:
At INFO, only id is added to structured data, while payload appears only when TRACE is enabled.
The result value can be emitted already at INFO by explicitly setting @Log.result(Level.INFO):
Structured Parameter¶
If a string representation of a parameter is not suitable for the log, the parameter type can implement the StructuredArgument interface.
In that case, the object defines the field name through fieldName() and writes the value to JsonGenerator through writeTo(...).
public record Entity(String name, String code) implements StructuredArgument {
@Override
public String fieldName() {
return "name";
}
@Override
public void writeTo(JsonGenerator generator) throws IOException {
generator.writeString(name);
}
}
@Log.in
public String doWork(Entity entity) {
return "testResult";
}
| Logging level | Log |
|---|---|
| DEBUG, TRACE |
INFO [main] r.t.e.e.Example.doWork: > data={"entity":"Bob"} |
| INFO |
INFO [main] r.t.e.e.Example.doWork: > |
When you need a structured value without introducing a dedicated type, the StructuredArgument interface exposes static factory helpers:
arg(fieldName, value) / arg(fieldName, value, JsonWriter) build a structured argument (overloads accept String, Integer, Long, Boolean, Map<String, String>, a JsonWriter, or a raw StructuredArgumentWriter),
while marker(fieldName, value) builds an org.slf4j.Marker for a single log call. The resulting StructuredArgument can also be passed straight into MDC.put.
Parameter Conversion¶
If the parameter type cannot be changed, describe an external StructuredArgumentMapper and specify it through @Mapping on the required argument.
The mapper receives the original parameter value and writes the structured value to JsonGenerator.
public record Entity(String name, String code) { }
public final class EntityLogMapper implements StructuredArgumentMapper<Entity> {
public void write(JsonGenerator gen, Entity value) throws IOException {
gen.writeString(value.name());
}
}
@Log.in
public String doWork(@Mapping(EntityLogMapper.class) Entity entity) {
return "testResult";
}
data class Entity(val name: String, val code: String)
class EntityLogMapper : StructuredArgumentMapper<Entity> {
@Throws(IOException::class)
override fun write(gen: JsonGenerator, value: Entity) = gen.writeString(value.name)
}
@Log.`in`
fun doWork(@Mapping(EntityLogMapper::class) entity: Entity): String {
return "testResult"
}
| Logging level | Log |
|---|---|
| DEBUG, TRACE |
INFO [main] r.t.e.e.Example.doWork: > data={"entity":"Bob"} |
| INFO |
INFO [main] r.t.e.e.Example.doWork: > |
MDC (Mapped Diagnostic Context)¶
The @Mdc annotation adds key-value pairs to MDC (Mapped Diagnostic Context).
MDC stores execution context and lets you add it to log messages: for example, request, user, or operation identifiers.
The annotation can be applied to methods and method parameters.
Repeated @Mdc usage is supported on methods.
Values added without global = true are restored after method execution.
@Mdc annotation parameters:
key()-MDCentry key (default:"").value()-MDCentry value (default:"").global()- keep the value inMDCafter method exit (default:false).
For @Mdc on a method, non-empty key and value are required.
For @Mdc on a parameter, the key is taken from key, then from value; if both values are empty, the parameter name is used.
The entry value is the parameter value.
Parameter Annotation¶
In this case, the MDC key matches the parameter name s, and the value is the parameter value.
Parameter Annotation With Key¶
Here, the MDC key is 123, and the value is the parameter value s.
Method Annotation¶
In this example, the key1=value2 entry is added to MDC before the method call.
After the method completes, the previous key1 value is restored.
Combined¶
In this example, two @Mdc annotations are applied to the method, and one is applied to the parameter.
The key=value entry remains in MDC after method execution because of global = true; the other entries are restored or removed.
Under the hood, non-global entries are snapshotted before the call and restored in a finally block once the method returns, so they never leak beyond the method scope.
Entries added with global = true (and any value set through the imperative MDC.put, see below) stay in the Context for the remainder of the request/thread scope and are therefore visible to every subsequent log line.
Generated Value From Code¶
When the method is called, an MDC entry with key key is added, and the value is a random UUID.
For Java, a value in the ${...} format is inserted into the generated code as an expression.
Example log with MDC:
INFO [main] r.t.e.e.Example.test: > {data: {s: "testValue"}} key=some-uuid-value key1=value2 123=testValue
@Mdc is not supported for methods that return CompletionStage, Mono, or Flux.
For Kotlin, regular methods and suspend methods are supported, but global = true cannot be used in suspend methods.
Imperative MDC¶
Where an annotation does not fit — inside interceptors, filters, or plain service code — use the imperative ru.tinkoff.kora.logging.common.MDC API.
It is the programmatic counterpart of @Mdc: entries are bound to Kora's Context, so they propagate across async boundaries exactly like @Mdc(global = true) entries and appear in every log line emitted for the remainder of the current Context scope.
The static put method has overloads for String, Integer, Long, and Boolean values, plus a StructuredArgumentWriter overload for structured values.
remove(key) drops a single entry, and get().values() returns the current entries as an unmodifiable Map<String, StructuredArgumentWriter>.
import ru.tinkoff.kora.logging.common.MDC;
@Component
public final class OrderService {
public void process(String orderId) {
MDC.put("orderId", orderId); // String
MDC.put("attempt", 1); // Integer
MDC.put("bytes", 1024L); // Long
MDC.put("retryable", true); // Boolean
MDC.put("payload", gen -> gen.writeString(orderId)); // StructuredArgumentWriter
// ... business logic; every log line in this Context now carries the keys
MDC.remove("attempt"); // drop a single key
var current = MDC.get().values(); // read current entries
}
}
import ru.tinkoff.kora.logging.common.MDC
@Component
class OrderService {
fun process(orderId: String) {
MDC.put("orderId", orderId) // String
MDC.put("attempt", 1) // Integer
MDC.put("bytes", 1024L) // Long
MDC.put("retryable", true) // Boolean
MDC.put("payload") { gen -> gen.writeString(orderId) } // StructuredArgumentWriter
// ... business logic; every log line in this Context now carries the keys
MDC.remove("attempt") // drop a single key
val current = MDC.get().values() // read current entries
}
}
When you already hold a Context (for example inside an interceptor), address it explicitly through MDC.get(ctx) and MDC.put(ctx, key, writer) instead of the current-Context shortcuts.
Unlike @Mdc, the imperative API has no reactive/suspend restriction, because it writes directly to the Context rather than wrapping the method call.
Use Kora's MDC, not SLF4J's
Always import ru.tinkoff.kora.logging.common.MDC — never org.slf4j.MDC.
The SLF4J class writes to a separate ThreadLocal that is not tied to Kora's Context: values placed there will not appear in Kora structured logs and will not propagate across async boundaries (reactive operators, suspend functions, thread hand-offs).
Signatures¶
Method signatures supported for logging aspects:
The class must not be final so that aspects can create a subclass.
T means the return value type or Void.
T myMethod()Optional<T> myMethod()CompletionStage<T> myMethod()CompletionStage (only for@Log)Mono<T> myMethod()Project Reactor (only for@Log, requires dependency)Flux<T> myMethod()Project Reactor (only for@Log, requires dependency)
The class must be open so that aspects can create a subclass.
T means the return value type, T?, or Unit.
myMethod(): Tsuspend myMethod(): TKotlin Coroutines (requires dependency asimplementation)myMethod(): Flow<T>Kotlin Coroutines (requires dependency asimplementation)