# Kora Aop Logging

> Kora 2.0 declarative method logging — @Log (+ @Log.in / @Log.out / @Log.result / @Log.off), @Mdc and @Mask from io.koraframework.logging.common.annotation, generated by logging-annotation-processor (Java) / logging-symbol-processor (KSP). Covers entry/exit records, structured `data` markers, argument and result levels, the Kora MDC built on ScopedValue (the 1.x Kora Context is gone), MaskingRules / MaskingStrategy redaction, and the logging.levels config map. Use when adding method tracing, MDC keys, or redacting sensitive arguments in a Kora service, or when @Log produces nothing because the class is final / not open.

- Skill: `kora-projects/kora-aop-logging-2` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add kora-projects/kora-aop-logging-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kora-projects/kora-aop-logging-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: Apache-2.0
- Author: kora-projects (https://skillmd.com/u/kora-projects)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kora-projects/kora-aop-logging-2

---


# Kora AOP Logging — `@Log`, `@Mdc`, `@Mask`

> **Kora sub-skill — obey the [kora-v2 meta rules](../../SKILL.md) on every task:** **R0** ground the workspace on Kora 2.0 refs before starting (framework source at tag `2.0.0.RC1` + `kora-examples` at `migration/2.0`; `kora-docs` is 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](../kora-telemetry-logging/SKILL.md). 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 `@Log` compiles 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.

```groovy
// 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:

```kotlin
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

```java
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

```java
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](#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:

```hocon
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.result` alone logs nothing.** It needs `@Log` or `@Log.out` on the same method.
- **`@Log.off` on a method does not silence the method.** With `@Log.out @Log.off` you 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`.

```java
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](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 |

```java
@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](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 |

```java
@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](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](../kora-telemetry-logging/SKILL.md) |
| 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](references/logging-aspect.md) — `@Log` family: level resolution, record shape, per-argument mappers, Java/Kotlin syntax, unsupported return types
- [logging-mdc.md](references/logging-mdc.md) — `@Mdc` attributes, the `ScopedValue` MDC model, `global`, imperative `MDC`, propagation
- [logging-masking.md](references/logging-masking.md) — `@Mask`, `MaskingStrategy`, `MaskingRules`, structured vs stringified output, the Java RC1 workaround
- [logging-performance.md](references/logging-performance.md) — cost model, controlling volume, batch loops, what to delegate to the backend skill

## Assets

- [assets/LoggedService.java.template](assets/LoggedService.java.template) — Java service with `@Log`, `@Mdc`, `@Log.off`
- [assets/LoggedService.kt.template](assets/LoggedService.kt.template) — the same service in Kotlin, with `open` and the backticked `` `in` ``

