Advanced topics Structured events TOML configuration

Structured Logging

The klyn.logging package provides typed levels, structured fields, scoped context, timed spans, and composable destinations. A Logger produces an immutable LogEvent; a LogHandler processes it.

Quick Start

Prefer LogManager.getLogger<T>() for application classes. It derives the hierarchical logger name from the fully qualified type, follows package or type renames without duplicating a string, and returns the cached logger for that type. Keep it in a class constant so every instance shares the same logger. Without explicit configuration, Klyn writes events at INFO or above to standard output.

package myapp.services

import klyn.logging

class MyService:

    private const LOGGER = LogManager.getLogger<MyService>()

    public start() as Void:
        LOGGER.debug("Connecting to database")
        LOGGER.info("Server started")

    public stop() as Void:
        LOGGER.info("Service stopped")

Use LogManager.getLogger(name) when the logging category is deliberately independent from a class, such as a subsystem, protocol, tenant, or dynamically selected component. Names remain hierarchical, so configuration for myapp.audit also applies to its descendants.

auditLogger = LogManager.getLogger("myapp.audit")

auditLogger.info("Audit subsystem started")
Levels and Guards

Levels are ordered as TRACE, DEBUG, INFO, WARNING, ERROR, FATAL, then OFF. Runtime filters combine the most specific logger override with every handler filter. Use a guard when producing a field is expensive; normal scalar fields need no guard.

LogManager.level = LogLevel.INFO

serviceLogger = LogManager.getLogger<MyService>()
serviceLogger.level = LogLevel.DEBUG
if logger.debugEnabled:
    logger.debug(
        "Result calculated",
        result = calculateResult()
    )
Structured Fields and Exceptions

Named variadic arguments become typed field values, not text interpolated into the message. Handlers can therefore render the same event as readable text, JSON, SQL columns, or an e-mail. The reserved exception field is promoted to LogEvent.exception.

logger.info(
    "User connected",
    userId = user.id,
    ip = request.remoteAddress
)

try:
    database.connect()
catch error as IOException:
    logger.error(
        "Database connection failed",
        exception = error
    )

A console handler can render the first event as:

2026/08/07 10:21:14.338 INFO    User connected userId=152 ip=192.168.1.42
Scoped Context

Logs.context() adds fields to every event produced in its scope. Nested contexts inherit outer fields; a field passed directly to the logger wins over a context field with the same name. Contexts are associated with the current Klyn thread and must be closed on that same thread.

try context = Logs.context(
    requestId = request.id,
    userId = user.id
):
    logger.info("Request received")
    service.execute()
    logger.info("Request completed")
Timed Spans

A span records wall-clock start/end instants and a monotonic duration. Closing it once emits a DEBUG event with durationMs, traceId, spanId, and parentSpanId when nested. This metadata provides a stable path toward trace exporters without forcing tracing on applications that only need logs.

try span = logger.span(
    "Load customer",
    customerId = id
):
    customer = repository.find(id)
TOML Configuration

Load configuration explicitly during application startup. The root level applies by default; entries in [loggers] use longest-prefix matching. Each remaining top-level TOML table names its handler factory. Enabled handlers are automatically combined with a MultiLogHandler. TOML is the logging configuration format; unknown tables and options are rejected so configuration mistakes cannot silently disable observability.

import klyn.logging

Logs.configure("logging.toml")

class CustomerService:

    private const LOGGER = LogManager.getLogger<CustomerService>()

    public start() as Void:
        LOGGER.info("Application configured")
level = "INFO"

[loggers]
"klyn.databases" = "WARNING"
"myapp.services" = "DEBUG"

[console]
enabled = true

[file]
enabled = true
path = "logs/application.log"
level = "INFO"

[file.rotation]
size = "100MB"
files = 10

[json]
enabled = true
path = "logs/application.jsonl"
level = "DEBUG"
How a table selects a handler

The table name is the LogHandlerFactory.name. For example, [json] selects the built-in JSON factory. Register a custom factory with LogManager.registerHandlerFactory(); its name immediately becomes a valid TOML table. Unknown names fail at configuration time instead of being ignored.

Standard Handlers
TOML tableHandlerPurpose
[console]ConsoleLogHandlerReadable stdout or stderr lines.
[file]FileLogHandler or RollingFileLogHandlerText file, with optional nested rotation table.
[rollingFile]RollingFileLogHandlerExplicit size-based rolling file.
[json]JsonLogHandlerOne structured JSON object per line.
[database]DatabaseLogHandlerPrepared inserts into an application-owned SQL table.
[smtp]SmtpLogHandlerHigh-severity e-mail alerts.

MultiLogHandler can also be assembled directly to fan one event out to several handlers.

TableSupported options
[console]enabled, level, standardError
[file]enabled, level, path, append, rotation
[file.rotation]size, files
[rollingFile]enabled, level, path, size, files
[json]enabled, level, path, append
[database]enabled, level, url, login, password, table
[smtp]enabled, level, host, port, security, login, password, from, to, subjectPrefix
JSON Event Shape

JSON output preserves field types and adds source metadata without constructing an exception stack. File, line, function, and type are captured only for events that pass runtime filters.

{
  "time": "2026-08-07T10:21:14.338+02:00",
  "level": "INFO",
  "logger": "myapp.AuthService",
  "message": "User connected",
  "userId": 152,
  "ip": "192.168.1.42",
  "source": {
    "file": "src/myapp/AuthService.kn",
    "line": 42,
    "column": 9,
    "function": "connect",
    "type": "myapp.AuthService"
  }
}
Compile-Time Elimination

Runtime filtering remains the default. Release builds can instead remove calls below a fixed level, including their argument evaluation. The chosen threshold is recorded in each object artifact so the cache is rebuilt only when a logging-relevant compile unit needs a different threshold.

klyn --log-min-level=INFO -x src/myapp/Main.kn

Accepted values are TRACE, DEBUG, INFO, WARNING, ERROR, FATAL, and OFF. The dynamic logger.log(level, ...) method is never removed because its level is not fixed at the call site.

Flush and Shutdown

Call Logs.flush() before a checkpoint that requires durable output. Call Logs.shutdown() during orderly application termination to close owned files or database connections. Logging failures are reported directly to stderr and never recursively sent through the logging graph.