Skip to content

Logging

Neton provides one structured logging API. Every module and every handler writes through the Logger interface, output is JSON, and asynchronous writes, multi-sink routing and request trace context all come for free.

Principles

  • Structure first — business data belongs in fields; msg is only an event name
  • No println — everything goes through Logger
  • Compile-time injection — annotate with @Log and take a Logger constructor parameter; KSP wires it
  • Automatic context — trace and span IDs are injected by the framework, not passed by hand

The Logger API

kotlin
interface Logger {
    fun trace(msg: String, fields: Fields = emptyFields())
    fun debug(msg: String, fields: Fields = emptyFields())
    fun info(msg: String, fields: Fields = emptyFields())
    fun warn(msg: String, fields: Fields = emptyFields(), cause: Throwable? = null)
    fun error(msg: String, fields: Fields = emptyFields(), cause: Throwable? = null)
}

Fields is an alias:

kotlin
typealias Fields = Map<String, Any?>

Levels

LevelUse forcause
traceThe most detailed tracing, usually development onlynot accepted
debugDiagnostic detail while investigatingnot accepted
infoNotable events during normal operationnot accepted
warnSomething looks wrong but the request continuesoptional
errorA failure that needs attentionstrongly recommended

Obtaining a Logger

Annotate the class with @Log and declare a Logger constructor parameter. KSP generates the injection:

kotlin
import neton.logging.Logger
import neton.logging.Log
import neton.core.annotations.*

@Controller("/api/users")
@Log
class UserController(private val log: Logger) {

    @Get("/{id}")
    suspend fun get(id: Long): User? {
        log.info("user.get", mapOf("userId" to id))
        return UserTable.get(id)
    }

    @Post
    suspend fun create(@Body user: User): User {
        log.info("user.create", mapOf("name" to user.name, "email" to user.email))
        return UserTable.insert(user)
    }
}

The parameter may be named log or logger. KSP injects an instance created through LoggerFactory.get("<fully qualified class name>").

Do not call LoggerFactory.get() from application code. Use @Log with constructor injection.

Writing structured logs

msg names the event

Keep msg a short dotted identifier. Never interpolate data into it:

kotlin
// good — msg identifies the event, data lives in fields
log.info("user.get", mapOf("userId" to id))
log.info("order.created", mapOf("orderId" to order.id, "amount" to order.total))
log.error("payment.failed", mapOf("orderId" to orderId, "reason" to "insufficient balance"), cause = ex)

// bad — data interpolated into msg
log.info("Getting user $id")
log.info("Order ${order.id} created")

Interpolated messages cannot be grouped or counted, which is the main reason to log structurally at all.

Fields carry the data

kotlin
log.info("http.request", mapOf(
    "method" to "GET",
    "path" to "/api/users/1",
    "status" to 200,
    "duration" to 15
))

log.warn("cache.miss", mapOf(
    "key" to cacheKey,
    "region" to "user-profile"
))

log.error("db.query.failed", mapOf(
    "table" to "users",
    "operation" to "select",
    "sql" to query
), cause = exception)

Always pass cause on error

kotlin
try {
    // work
} catch (e: Exception) {
    log.error("user.update.failed", mapOf(
        "userId" to userId,
        "operation" to "update"
    ), cause = e)
}

Without it the stack trace is lost, and an error record without a stack trace rarely tells you enough.

Configuration

Logging is configured under [logging] in config/application.conf:

toml
[logging]
level = "INFO"

[logging.async]
enabled = true
queueSize = 8192
flushEveryMs = 200
flushBatchSize = 64
shutdownFlushTimeoutMs = 2000

[[logging.sinks]]
name = "access"
file = "logs/access.log"
levels = "INFO"
route = "http.access"

[[logging.sinks]]
name = "error"
file = "logs/error.log"
levels = "ERROR,WARN"

[[logging.sinks]]
name = "all"
file = "logs/all.log"
levels = "ALL"

Global level

levelRecords emitted
"TRACE"TRACE, DEBUG, INFO, WARN, ERROR
"DEBUG"DEBUG, INFO, WARN, ERROR
"INFO"INFO, WARN, ERROR
"WARN"WARN, ERROR
"ERROR"ERROR

Asynchronous writes

Enable these in production so that I/O does not block request handling:

OptionMeaning
enabledTurn on asynchronous mode
queueSizeQueue capacity; records are dropped with a warning when it is full
flushEveryMsFlush on this interval even when a batch is not full
flushBatchSizeFlush as soon as this many records are queued
shutdownFlushTimeoutMsHow long shutdown waits for the queue to drain

Sink routing

Each sink is one output rule:

  • name — the sink's name
  • file — output path
  • levels — comma-separated levels ("ERROR,WARN") or "ALL"
  • route — optional message prefix match; "http.access" captures only access logs

One record can match several sinks. With the configuration above, an ERROR record is written to both error.log and all.log.

Trace context

The logger injects request-scoped tracing automatically:

kotlin
data class LogContext(
    val traceId: String,       // trace identifier
    val spanId: String?,       // span identifier
    val requestId: String?,    // request identifier
    val userId: String?        // the current user
)

LogContext is populated when an HTTP request arrives, and every record written during that request carries these fields — which is what lets you reconstruct one request across modules.

kotlin
@Get("/{id}")
suspend fun get(id: Long): User? {
    // traceId and spanId are added automatically
    log.info("user.get", mapOf("userId" to id))
    return UserTable.get(id)
}

JSON output

Records are single-line JSON, ready for a collector:

json
{
  "ts": "2026-02-14T08:30:00.123Z",
  "level": "INFO",
  "msg": "user.get",
  "traceId": "abc123def456",
  "spanId": "span-001",
  "requestId": "req-789",
  "userId": "admin-user",
  "userId_field": 42
}
FieldSourceMeaning
tsgeneratedUTC timestamp, ISO 8601
levelthe callTRACE / DEBUG / INFO / WARN / ERROR
msgfirst argumentThe event name
traceIdLogContextTrace identifier, injected
spanIdLogContextSpan identifier, injected
requestIdLogContextRequest identifier, injected
userIdLogContextCurrent user, injected
everything elseFieldsYour data, flattened into the top level
errorcauseException message (warn and error only)
stackTracecauseException stack trace (warn and error only)

Redaction

Fields whose keys look sensitive are replaced with [REDACTED], so passwords and tokens do not reach the log files.

Rules at a glance

RuleDetail
No printlnEverything goes through Logger
No data in msglog.info("user.get", ...), not log.info("Getting user $id")
Data in fieldsmapOf("userId" to id, "name" to name)
cause on errorlog.error("xxx", fields, cause = ex)
@Log for injectionNever call LoggerFactory.get() directly
Dotted event names"user.get", "order.created", "http.access"

A complete example

kotlin
import neton.core.annotations.*
import neton.core.http.*
import neton.logging.Logger
import neton.logging.Log
import neton.database.dsl.*

@Controller("/api/orders")
@Log
class OrderController(private val log: Logger) {

    @Get
    suspend fun list(
        @QueryParam("status") status: Int?
    ): List<Order> {
        log.info("order.list", mapOf("status" to status))
        return if (status != null) {
            OrderTable.query { where { Order::status eq status } }.list()
        } else {
            OrderTable.findAll()
        }
    }

    @Post
    suspend fun create(@Body order: Order): Order {
        log.info("order.create", mapOf(
            "customerId" to order.customerId,
            "amount" to order.amount
        ))
        return try {
            OrderTable.insert(order)
        } catch (e: Exception) {
            log.error("order.create.failed", mapOf(
                "customerId" to order.customerId,
                "amount" to order.amount
            ), cause = e)
            throw e
        }
    }

    @Delete("/{id}")
    suspend fun cancel(id: Long) {
        log.warn("order.cancel", mapOf("orderId" to id))
        OrderTable.destroy(id)
    }
}

Neton Framework Documentation