Kora облачно ориентированный серверный фреймворк написанный на Java для написания Java / Kotlin приложений с упором на производительность, эффективность, прозрачность сделанный выходцами из Т-Банк / Тинькофф

Kora is a cloud-oriented server-side Java framework for writing Java / Kotlin applications with a focus on performance, efficiency and transparency

Skip to content

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:

implementation "ru.tinkoff.kora:logging-common"

Module:

@KoraApp
public interface Application extends LoggingModule { }

Dependency build.gradle.kts:

implementation("ru.tinkoff.kora:logging-common")

Module:

@KoraApp
interface Application : LoggingModule

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: DEBUG for 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

@Log.in
public String doWork(@Log.off String strParam, int numParam) {
    return "testResult";
}
@Log.`in`
fun doWork(@Log.off strParam: String?, numParam: Int): String {
    return "testResult"
}
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

@Log.out
public String doWork(String strParam, int numParam) {
    return "testResult";
}
@Log.out
fun doWork(strParam: String, numParam: Int): String {
    return "testResult"
}
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

@Log
public String doWork(String strParam, int numParam) {
    return "testResult";
}
@Log
fun doWork(strParam: String, numParam: Int): String {
    return "testResult"
}
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

@Log.out
@Log.off
public String doWork(String strParam, int numParam) {
    return "testResult";
}
@Log.out
@Log.off
fun doWork(strParam: String, numParam: Int): String {
    return "testResult"
}
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:

@Log.in
public void doWork(@Log(Level.INFO) String id, @Log(Level.TRACE) String payload) { }
@Log.`in`
fun doWork(@Log(Level.INFO) id: String, @Log(Level.TRACE) payload: String) { }

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):

@Log.out
@Log.result(Level.INFO)
public String doWork() {
    return "testResult";
}
@Log.out
@Log.result(Level.INFO)
fun doWork(): String {
    return "testResult"
}

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";
}
data class Entity(val name: String, val code: String) : StructuredArgument {

    override fun writeTo(generator: JsonGenerator) = generator.writeString(name)

    override fun fieldName(): String = "name"
}

@Log.`in`
fun doWork(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: >

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.

// ad-hoc structured value fed into MDC
MDC.put("order", StructuredArgument.arg("orderId", orderId));

// or as an SLF4J marker on a single log line
log.info(StructuredArgument.marker("orderId", orderId), "order accepted");
// ad-hoc structured value fed into MDC
MDC.put("order", StructuredArgument.arg("orderId", orderId))

// or as an SLF4J marker on a single log line
log.info(StructuredArgument.marker("orderId", orderId), "order accepted")

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() - MDC entry key (default: "").
  • value() - MDC entry value (default: "").
  • global() - keep the value in MDC after 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

public String test(@Mdc String s) {
    return "1";
}
fun test(@Mdc s: String): String {
    return "1"
}

In this case, the MDC key matches the parameter name s, and the value is the parameter value.

Parameter Annotation With Key

public String test(@Mdc(key = "123") String s) {
    return "1";
}
fun test(@Mdc(key = "123") s: String): String {
    return "1"
}

Here, the MDC key is 123, and the value is the parameter value s.

Method Annotation

@Mdc(key = "key1", value = "value2")
public String test(String s) {
    return "1";
}
@Mdc(key = "key1", value = "value2")
fun test(s: String): String {
    return "1"
}

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

@Mdc(key = "key", value = "value", global = true)
@Mdc(key = "key1", value = "value2")
public String test(@Mdc(key = "123") String s) {
    return "1";
}
@Mdc(key = "key", value = "value", global = true)
@Mdc(key = "key1", value = "value2")
fun test(@Mdc(key = "123") s: String): String {
    return "1"
}

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

@Mdc(key = "key", value = "${java.util.UUID.randomUUID().toString()}")
public String test(String s) {
    return "1";
}
@Mdc(key = "key", value = "\${java.util.UUID.randomUUID().toString()}")
fun test(s: String): String {
    return "1";
}

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.

The class must be open so that aspects can create a subclass.

T means the return value type, T?, or Unit.