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.
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 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()
)
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
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")
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)
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"
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.
| TOML table | Handler | Purpose |
|---|---|---|
[console] | ConsoleLogHandler | Readable stdout or stderr lines. |
[file] | FileLogHandler or RollingFileLogHandler | Text file, with optional nested rotation table. |
[rollingFile] | RollingFileLogHandler | Explicit size-based rolling file. |
[json] | JsonLogHandler | One structured JSON object per line. |
[database] | DatabaseLogHandler | Prepared inserts into an application-owned SQL table. |
[smtp] | SmtpLogHandler | High-severity e-mail alerts. |
MultiLogHandler can also be assembled directly to fan one event out to several handlers.
| Table | Supported 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 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"
}
}
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.
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.