Kora AOP Logging — @Log, @Mdc, @Mask
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.
Compile-time method logging. The aspect is woven into a generated $<Class>__AopProxy subclass — no
reflection, no runtime proxy. Records are written through SLF4J as a > (entry) / < (exit) message
plus a structured data marker carrying arguments, result or error.
Scope of this skill: the three annotations and the aspect they generate.
The SLF4J/Logback backend that renders them — LogbackModule, KoraAsyncAppender,
ConsoleTextRecordEncoder, JSON output, StructuredArgument written by hand — belongs to
kora-telemetry-logging. Read that one for logback.xml.
Read this first when you need to:
- trace method entry/exit with arguments and return value (
@Log,@Log.in,@Log.out), - put contextual keys on every record produced inside a call (
@Mdc), - keep a secret out of the log line entirely (
@Log.off) or redact fields inside a logged object (@Mask), - work out why
@Logcompiles but nothing appears in the output.
Quick start
1. Dependencies
Versions come from io.koraframework:kora-bom; never pin a version on an individual
io.koraframework:* artifact. The processor is mandatory — without it the annotations are inert.
// gradle.properties: koraVersion=2.0.0.RC1
configurations {
koraBom
annotationProcessor.extendsFrom(koraBom)
compileOnly.extendsFrom(koraBom)
implementation.extendsFrom(koraBom)
}
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion")
annotationProcessor "io.koraframework:annotation-processors" // contains logging-annotation-processor
implementation "io.koraframework:logging-logback" // brings logging-common transitively
implementation "io.koraframework:json-common" // already transitive; declare it if you use @Json / @Mask
}
Kotlin uses KSP; the ksp configuration does not inherit the platform, so it carries the version:
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}") // contains logging-symbol-processor
implementation("io.koraframework:logging-logback")
}
The RC1 BOM manages exactly four logging artifacts: logging-common, logging-logback,
logging-annotation-processor, logging-symbol-processor.
declarative-logging-annotation-processor / declarative-logging-symbol-processor are Kora 1.x
leftovers on Maven Central. They are not in the 2.0 BOM — never add them.
2. Wire the graph
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends LogbackModule, /* ... */ { }
LogbackModule extends LoggingModule, so one extends clause gives you both the ILoggerFactory
the aspect needs and the LoggingLevelApplier that applies logging.levels. Extending only
LoggingModule fails the graph: loggingLevelRefresher is a @Root component and nothing else
supplies LoggingLevelApplier. Logging @Json or @Mask values additionally needs
io.koraframework.json.common.JsonModule in the same extends clause.
3. Annotate a method
import io.koraframework.logging.common.annotation.Log;
@Component
public class UserService { // must NOT be final
@Log
public User getUser(String id) { // must NOT be final
return repository.findById(id);
}
}
Kotlin needs open on both the class and the function — see Non-final / open.
4. Turn the level up so data appears
The > / < records are emitted at the annotation's level. Their payload — arguments and result
— is gated on a separate, more verbose level: whichever of DEBUG and the entry/exit level is the
more verbose (@Log.result / a parameter's own @Log replaces the DEBUG half). So a plain @Log
emits its markers at INFO and gates its payload on DEBUG: at INFO you get bare > / <.
Levels come from the logging.levels config map:
logging {
levels {
"ROOT": "WARN"
"io.koraframework": "INFO"
"com.example.UserService": "DEBUG" # now arguments and the result show up
}
}
Dotted keys must be quoted in HOCON, otherwise they nest into objects and never match a logger.
@Log family
Log and its four nested annotations live in io.koraframework.logging.common.annotation.
| Annotation | Target | Triggers the aspect? | Effect | Default value |
|---|---|---|---|---|
@Log |
method, parameter | yes | On a method: entry and exit. On a parameter: sets that argument's own level | Level.INFO |
@Log.in |
method | yes | Entry record only (>) |
Level.INFO |
@Log.out |
method | yes | Exit record only (<) |
Level.INFO |
@Log.result |
method | no | Level of the out payload inside the exit record |
Level.DEBUG |
@Log.off |
method, parameter | no | On a parameter: omit that argument. On a method: omit the result payload | — |
Two of these are modifiers, not triggers — the aspect only fires for @Log, @Log.in and
@Log.out (LogAspect.getSupportedAnnotationClassNames()):
@Log.resultalone logs nothing. It needs@Logor@Log.outon the same method.@Log.offon a method does not silence the method. With@Log.out @Log.offyou still get the<record — only the{"out": …}payload is dropped. To silence a method, remove its@Log*annotations.
The level is the annotation's value attribute, typed org.slf4j.event.Level. There is no
level = attribute and no Level.OFF — SLF4J has only TRACE, DEBUG, INFO, WARN, ERROR.
import org.slf4j.event.Level;
@Log(Level.DEBUG)
public User getUser(String id) { ... }
@Log cannot be placed on a class (@Target({METHOD, PARAMETER})) — there is no "log every method
of this type" switch.
Full level-resolution algorithm, output shape, per-argument mappers and Kotlin syntax: references/logging-aspect.md.
What the record looks like
The logger name is <fully-qualified class>.<methodName> — e.g. com.example.UserService.getUser,
a Logback child of com.example.UserService. Entry/exit are two records:
> {"data":{"id":"42"}}
< {"data":{"out":{"id":"42","name":"Ann"}}}
A thrown exception is logged at WARN with errorType / errorMessage, and the throwable itself
is attached only when DEBUG is enabled for that logger; the exception is always rethrown.
@Mdc
@Mdc puts typed key/value pairs into Kora's own MDC for the duration of the call and restores the
previous value in a finally block. It is @Repeatable.
| Attribute | Type | Default | Meaning |
|---|---|---|---|
key |
String |
"" |
MDC key. Mandatory on a method. On a parameter it falls back to value, then to the parameter name |
value |
String |
"" |
Mandatory on a method — a literal, or ${expression} inlined as Java/Kotlin code. Ignored as a value source on a parameter (the argument is the value) |
global |
boolean |
false |
true leaves the key in the MDC after the method returns instead of restoring the previous value |
@Log
@Mdc(key = "operation", value = "create-order")
@Mdc(key = "requestId", value = "${java.util.UUID.randomUUID().toString()}")
public Order create(@Mdc(key = "tenantId") String tenant, CreateOrderDto body) { ... }
The MDC is a ScopedValue, not a thread-local, and not the removed Context
io.koraframework.logging.common.MDC is held in ScopedValue<MDC> MDC.VALUE.
Kora's 1.x Context type is gone from the whole framework — any MDC pattern that stored values
in Context or copied a Context across a thread hop no longer compiles and has no replacement.
Kora entry points bind the scope for you: the Undertow request handler, the Kafka record handlers,
the JMS listener container, the gRPC transport filter and the JDK/Quartz schedulers each run your
code inside ScopedValue.where(MDC.VALUE, new MDC()). Outside such a scope MDC.get() throws
NoSuchElementException, so a @Mdc method called from main, from a bare unit test or from a
hand-started thread fails at runtime unless you bind the scope yourself.
Rules, global semantics, value typing, propagation and the imperative API:
references/logging-mdc.md.
Hiding sensitive values
Two different mechanisms — pick by what you want the record to contain.
| Goal | Mechanism |
|---|---|
| The argument must not appear at all | @Log.off on the parameter |
| The object is logged but some fields must be redacted | @Mask on the type's fields + @Mask on the logged parameter/method |
@Log
public Session authenticate(@Mdc(key = "user") String username, @Log.off String password) { ... }
@Mask (io.koraframework.logging.common.annotation.Mask) selects a MaskingStrategy
(MaskingFull → ***, MaskingKeepFirst, MaskingKeepLast, or your own @Component) and drives a
MaskingRules<T> that rewrites matching JSON fields as the value is written.
Java-specific gap in 2.0.0.RC1: the published logging-annotation-processor-2.0.0.RC1.jar
registers only its two AOP aspect factories — it has no
META-INF/services/javax.annotation.processing.Processor entry, so javac never runs the processor
that generates $<Type>_MaskingRulesModule. In Java you must declare the MaskingRules<T>
component yourself. Kotlin/KSP is unaffected: MaskingRulesSymbolProcessorProvider is registered.
Targets, strategies, path/wildcard rules, structured vs stringified output and the Java workaround: references/logging-masking.md.
Non-final / open
The aspect is a generated subclass, so the target must be extensible.
- Java: the class and the method must not be
final; the class needs a public, protected or package-private constructor. - Kotlin: the class and the function must both be
open; the class must not be abstract.
Kora 2.0 reports this as a compile error rather than silently skipping the aspect, e.g.
AOP aspect cannot be applied to class 'com.example.UserService' because the class is not open.
Fix: mark the class as open, or move the aspect annotation to an open member function.
Example: open class UserService
Because the aspect lives in a subclass, a call to an annotated method from inside the same
instance (this.doWork()) and any instance you build with new bypass it.
Three different "logging" switches — do not confuse them
| Setting | What it controls | Default |
|---|---|---|
logging.levels { "<logger>": "<LEVEL>" } |
SLF4J logger levels, including the <class>.<method> loggers the @Log aspect writes to |
empty map; the refresher forces ROOT to INFO and clears every other level at startup |
<component>.telemetry.logging.enabled |
Kora's built-in component telemetry (httpServer, httpClient, jdbc, kafka, …). Nothing to do with @Log |
false (TelemetryConfig.LoggingConfig.enabled()) |
@Log / @Log.in / @Log.out |
Whether a record is produced for your method at all | absent = no aspect |
The key is logging.levels (plural, a Map<String, String>). logging.level does not exist and is
silently ignored, leaving every logger at whatever the startup reset produced.
LoggingLevelRefresher wipes logback.xml levels at graph init: it sets ROOT to INFO, clears
every other logger's level, and then applies logging.levels. Per-logger <logger level="…">
elements in logback.xml therefore do not survive startup — configure levels in logging.levels.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
AOP aspect cannot be applied … not open / … is final |
Subclass proxy impossible | open (Kotlin) / drop final (Java) on both class and method |
| Annotation compiles, no record at all | Processor missing | annotationProcessor "io.koraframework:annotation-processors" / ksp "io.koraframework:symbol-processors" |
Only > and <, never the arguments |
Logger at INFO; arguments default to DEBUG |
Set that logger to DEBUG in logging.levels |
logging.level { … } has no effect |
Wrong key | logging.levels (plural) |
logback.xml <logger level="DEBUG"> ignored |
LoggingLevelRefresher.init() clears it |
Move the level into logging.levels |
Levels set under "com.example.Foo" unquoted do nothing |
HOCON nested the dotted key | Quote the whole logger name |
@Log.result added, still nothing logged |
It is a modifier, not a trigger | Add @Log or @Log.out |
@Log.off on a method still logs < |
It only drops the result payload | Remove the @Log* annotation to silence the method |
@Log(level = Level.DEBUG) does not compile |
Attribute is value |
@Log(Level.DEBUG) |
Looking for Level.OFF |
Not in org.slf4j.event.Level |
Use no annotation, or raise the logger level |
Kotlin @Log.in does not compile |
in is a Kotlin keyword |
@Log.`in` |
NoSuchElementException from MDC.get() |
@Mdc ran outside a bound scope |
Call it from an HTTP/Kafka/JMS/gRPC/scheduler entry point, or wrap in ScopedValue.where(MDC.VALUE, new MDC()) |
| MDC keys never render | org.slf4j.MDC imported instead of Kora's, or the Logback setup does not use KoraAsyncAppender + a Kora encoder |
Import io.koraframework.logging.common.MDC; see kora-telemetry-logging |
Graph build fails resolving MaskingRules<Foo> (Java only) |
RC1 does not register the Java masking processor, so $Foo_MaskingRulesModule is never generated |
Declare the MaskingRules<Foo> component by hand — see the masking reference |
| Aspect skipped for a method called internally | this.method() does not go through the proxy subclass |
Call it through an injected component |
@Mdc on a Mono/Flux/Future/CompletionStage method fails to compile |
Rejected by design; Kora 2.0 contracts are synchronous | Make the method synchronous |
References
- logging-aspect.md —
@Logfamily: level resolution, record shape, per-argument mappers, Java/Kotlin syntax, unsupported return types - logging-mdc.md —
@Mdcattributes, theScopedValueMDC model,global, imperativeMDC, propagation - logging-masking.md —
@Mask,MaskingStrategy,MaskingRules, structured vs stringified output, the Java RC1 workaround - logging-performance.md — cost model, controlling volume, batch loops, what to delegate to the backend skill
Assets
- assets/LoggedService.java.template — Java service with
@Log,@Mdc,@Log.off - assets/LoggedService.kt.template — the same service in Kotlin, with
openand the backticked`in`