A JSON logger for Nim with a built-in CLI prettifier.
- Structured JSON output - every log line is valid JSON with timestamp, level, name, filename, line number, and message
-
Structured messages - named
key=valuearguments become discrete JSON fields, queryable by log aggregators; extra positional arguments are appended, and Nim'sstd/strformatcovers interpolation - Compile-time level filtering - log calls below the threshold are eliminated from the binary entirely, with zero runtime cost; per-logger runtime levels handle the rest
-
Exception logging - pass any
ref Exceptionand lumber extracts the message, type, and stack trace automatically -
Contextual logging - attach fields per logger, inherit them through child loggers, or scope them to a call stack with thread-local
withLogContext - Middleware - enrich, transform, or suppress log records at runtime; rate limiter, sampler, and redaction included
-
Flexible outputs - write to stdout, files, or any custom
Streamsimultaneously, with built-in size/time rotation, buffering, a background-thread async writer, and automatic flush on exit - Thread-safe - safe for concurrent use from multiple threads
- Minimal dependencies - the Nim standard library plus a single pure-Nim package (regex), compiled in statically; no C libraries or runtime dependencies
-
CLI prettifier - pipe JSON logs through the
lumberbinary for colored, human-readable output with level filtering, field filtering, timezone support, and customizable format/colors via TOML config
nimble install lumber
This installs both the library and the lumber CLI prettifier (into nimble's bin directory, typically ~/.nimble/bin).
import lumber
# One module-level logger serves the whole module; its name defaults
# to the module's filename
let logger = newLogger()
proc greet() =
logger.info("Hello, world!")
greet()A single module-level logger is the idiomatic setup: every proc in the module shares it, and request- or task-scoped context comes from child loggers or withLogContext rather than new loggers.
Tip: If you prefer namespaced access (
lumber.outputs,lumber.newLogger, etc.), usefrom lumber import nil. All examples below use plainimport lumberfor brevity.
{"timestamp":"2026-07-08T03:55:22.324Z","level":"INFO","name":"mymodule","filename":"mymodule.nim","line":8,"message":"Hello, world!"}2026-07-07T20:55:22.324-07:00 PDT [INFO ] (mymodule.nim:8) mymodule: Hello, world!
Set the minimum log level at compile time with -d:lumberLevel. Calls below this level produce no code in the binary.
nim c -d:lumberLevel=WARN myapp.nimWith this flag, logger.trace(), logger.debug(), and logger.info() are completely eliminated -- arguments are type-checked but never evaluated at runtime.
Available levels (in order): TRACE, DEBUG, INFO, WARN, ERROR, FATAL
The default is TRACE (all levels enabled).
# Name defaults to the calling module's filename
var logger = newLogger()
# Named logger with extra context (JsonNode)
var logger = newLogger(name = "api", extra = %* {"service": "my-app"})
# Extra also accepts Nim objects; fields are serialized automatically
type AppContext = object
service: string
version: string
var logger = newLogger(name = "api", extra = AppContext(service: "my-app", version: "1.2.0"))logger.trace("Detailed tracing info")
logger.debug("Debug information")
logger.info("General information")
logger.warn("Warning")
logger.error("Error occurred")
logger.fatal("Fatal error"){"timestamp":"2026-07-06T20:44:46.592Z","level":"TRACE","name":"app","filename":"app.nim","line":4,"message":"Detailed tracing info"}
{"timestamp":"2026-07-06T20:44:46.592Z","level":"DEBUG","name":"app","filename":"app.nim","line":5,"message":"Debug information"}
{"timestamp":"2026-07-06T20:44:46.592Z","level":"INFO","name":"app","filename":"app.nim","line":6,"message":"General information"}
{"timestamp":"2026-07-06T20:44:46.592Z","level":"WARN","name":"app","filename":"app.nim","line":7,"message":"Warning"}
{"timestamp":"2026-07-06T20:44:46.592Z","level":"ERROR","name":"app","filename":"app.nim","line":8,"message":"Error occurred"}
{"timestamp":"2026-07-06T20:44:46.592Z","level":"FATAL","name":"app","filename":"app.nim","line":9,"message":"Fatal error"}2026-07-06T13:44:46.592-07:00 PDT [TRACE] (app.nim:4) app: Detailed tracing info
2026-07-06T13:44:46.592-07:00 PDT [DEBUG] (app.nim:5) app: Debug information
2026-07-06T13:44:46.592-07:00 PDT [INFO ] (app.nim:6) app: General information
2026-07-06T13:44:46.592-07:00 PDT [WARN ] (app.nim:7) app: Warning
2026-07-06T13:44:46.592-07:00 PDT [ERROR] (app.nim:8) app: Error occurred
2026-07-06T13:44:46.592-07:00 PDT [FATAL] (app.nim:9) app: Fatal error
Each logger has a level field that short-circuits before building the log record, running middleware, or serializing JSON.
var logger = newLogger(name = "api")
logger.level = LogLevel.WARN # only WARN+ will be processed
logger.info("skipped") # no work done
logger.error("processed") # goes through normally{"timestamp":"2026-07-06T20:44:47.409Z","level":"ERROR","name":"api","filename":"app.nim","line":6,"message":"processed"}2026-07-06T13:44:47.409-07:00 PDT [ERROR] (app.nim:6) api: processed
Child loggers inherit the parent's level.
Build messages with Nim's std/strformat: named, compile-checked, and evaluated lazily (a call filtered by level never runs the formatting). Extra positional arguments are appended, space-separated; any type with a $ operator works, and objects are prefixed with their type name. lumber never interprets braces in messages, so there is no placeholder syntax to escape and no way for data to inject one.
import std/strformat
let user = "alice"
let ip = "10.0.0.1"
logger.info(&"User {user} logged in from {ip}")
logger.info("Values:", 1, 2, 3)
type User = object
name: string
age: int
logger.info("Found", User(name: "Dude", age: 40)){"timestamp":"2026-07-08T03:27:03.687Z","level":"INFO","name":"api","filename":"app.nim","line":7,"message":"User alice logged in from 10.0.0.1"}
{"timestamp":"2026-07-08T03:27:03.687Z","level":"INFO","name":"api","filename":"app.nim","line":8,"message":"Values: 1 2 3"}
{"timestamp":"2026-07-08T03:27:03.687Z","level":"INFO","name":"api","filename":"app.nim","line":14,"message":"Found User(name: \"Dude\", age: 40)"}2026-07-07T20:27:03.687-07:00 PDT [INFO ] (app.nim:7) api: User alice logged in from 10.0.0.1
2026-07-07T20:27:03.687-07:00 PDT [INFO ] (app.nim:8) api: Values: 1 2 3
2026-07-07T20:27:03.687-07:00 PDT [INFO ] (app.nim:14) api: Found User(name: "Dude", age: 40)
Named arguments become discrete fields in the extra JSON object, keeping them queryable by log aggregators rather than buried in a text string.
import std/strformat
let reqId = "req-abc"
logger.info("User logged in", user="alice", ip="10.0.0.1")
# Mix strformat interpolation with named fields
logger.info(&"Request {reqId} completed", status=200, latency=42){"timestamp":"2026-07-08T03:27:04.532Z","level":"INFO","name":"api","filename":"app.nim","line":6,"message":"User logged in","extra":{"user":"alice","ip":"10.0.0.1"}}
{"timestamp":"2026-07-08T03:27:04.532Z","level":"INFO","name":"api","filename":"app.nim","line":7,"message":"Request req-abc completed","extra":{"status":200,"latency":42}}2026-07-07T20:27:04.532-07:00 PDT [INFO ] (app.nim:6) api: User logged in
user: "alice"
ip: "10.0.0.1"
2026-07-07T20:27:04.532-07:00 PDT [INFO ] (app.nim:7) api: Request req-abc completed
status: 200
latency: 42
Message-level fields override logger extra on key collision:
var logger = newLogger(extra = %* {"user": "system"})
logger.info("login", user="alice"){"timestamp":"2026-07-06T20:44:49.908Z","level":"INFO","name":"app","filename":"app.nim","line":4,"message":"login","extra":{"user":"alice"}}2026-07-06T13:44:49.908-07:00 PDT [INFO ] (app.nim:4) app: login
user: "alice"
Pass any ref Exception as an argument, and lumber automatically extracts the message, type name, and stack trace into structured fields. The stackTrace field appears only for exceptions that were actually raised (traces are captured at raise time) and in builds with stack traces enabled (debug builds, or --stacktrace:on with -d:release).
proc loadConfig() =
raise newException(IOError, "file not found: config.toml")
proc initApp() =
loadConfig()
try:
initApp()
except IOError as e:
logger.error("Failed to load config", e){"timestamp":"2026-07-08T04:50:14.324Z","level":"ERROR","name":"api","filename":"app.nim","line":14,"message":"Failed to load config","extra":{"error":"file not found: config.toml","errorType":"IOError","stackTrace":"app.nim(12) app\napp.nim(9) initApp\napp.nim(6) loadConfig\n"}}2026-07-07T21:50:14.324-07:00 PDT [ERROR] (app.nim:14) api: Failed to load config
error: "file not found: config.toml"
errorType: "IOError"
stackTrace:
app.nim(12) app
app.nim(9) initApp
app.nim(6) loadConfig
The exception can be passed positionally (as above), as a keyword argument (error=e; the key is ignored), or mixed with other fields (logger.error("Failed", e, retries=3)). Multiple exceptions are stored as an array:
var e1, e2: ref Exception
try:
validate()
except ValueError as e:
e1 = e
try:
writeState()
except IOError as e:
e2 = e
logger.error("Multiple failures", e1, e2){"timestamp":"2026-07-08T04:50:14.324Z","level":"ERROR","name":"api","filename":"app.nim","line":31,"message":"Multiple failures","extra":{"errors":[{"error":"bad input","errorType":"ValueError","stackTrace":"app.nim(24) app\napp.nim(17) validate\n"},{"error":"disk full","errorType":"IOError","stackTrace":"app.nim(28) app\napp.nim(20) writeState\n"}]}}2026-07-07T21:50:14.324-07:00 PDT [ERROR] (app.nim:31) api: Multiple failures
exception 1:
error: "bad input"
errorType: "ValueError"
stackTrace:
app.nim(24) app
app.nim(17) validate
exception 2:
error: "disk full"
errorType: "IOError"
stackTrace:
app.nim(28) app
app.nim(20) writeState
Measure the duration of a block and log it automatically with duration_ms in extra:
# Default: logs at INFO level
logger.time("db query"):
db.exec("SELECT * FROM users")
# Custom level
logger.time(LogLevel.DEBUG, "template render"):
renderPage(){"timestamp":"2026-07-06T20:46:29.020Z","level":"INFO","name":"db","filename":"app.nim","line":5,"message":"db query","extra":{"duration_ms":137.24900000000002}}
{"timestamp":"2026-07-06T20:46:29.041Z","level":"DEBUG","name":"db","filename":"app.nim","line":11,"message":"template render","extra":{"duration_ms":21.38100000000001}}2026-07-06T13:46:29.020-07:00 PDT [INFO ] (app.nim:5) db: db query (137ms)
2026-07-06T13:46:29.041-07:00 PDT [DEBUG] (app.nim:11) db: template render (21ms)
Create child loggers that inherit the parent's name, level, and extra fields. Child extra fields are merged on top of the parent's.
var logger = newLogger(name = "api", extra = %* {"service": "my-app"})
var reqLogger = logger.child(extra = %* {"requestId": "abc-123"})
reqLogger.info("Handling request")
var dbLogger = reqLogger.child(name = "db", extra = %* {"query": "SELECT ..."})
dbLogger.error("Connection timeout"){"timestamp":"2026-07-06T20:44:52.516Z","level":"INFO","name":"api","filename":"app.nim","line":6,"message":"Handling request","extra":{"service":"my-app","requestId":"abc-123"}}
{"timestamp":"2026-07-06T20:44:52.516Z","level":"ERROR","name":"db","filename":"app.nim","line":9,"message":"Connection timeout","extra":{"service":"my-app","requestId":"abc-123","query":"SELECT ..."}}2026-07-06T13:44:52.516-07:00 PDT [INFO ] (app.nim:6) api: Handling request
service: "my-app"
requestId: "abc-123"
2026-07-06T13:44:52.516-07:00 PDT [ERROR] (app.nim:9) db: Connection timeout
service: "my-app"
requestId: "abc-123"
query: "SELECT ..."
Child extra also accepts Nim objects:
type DbContext = object
host: string
port: int
var dbLogger = reqLogger.child(name = "db", extra = DbContext(host: "db.local", port: 5432))Use withLogContext to attach ambient fields that any logger on the current thread will pick up, without passing the logger through function calls.
var logger = newLogger(name = "api")
withLogContext(%* {"requestId": "abc-123", "userId": 42}):
logger.info("handling request")
# Nesting adds fields, restores on exit
withLogContext(%* {"orderId": "ord-789"}):
logger.info("processing payment")
logger.info("done"){"timestamp":"2026-07-06T20:44:53.346Z","level":"INFO","name":"api","filename":"app.nim","line":6,"message":"handling request","extra":{"requestId":"abc-123","userId":42}}
{"timestamp":"2026-07-06T20:44:53.346Z","level":"INFO","name":"api","filename":"app.nim","line":9,"message":"processing payment","extra":{"requestId":"abc-123","userId":42,"orderId":"ord-789"}}
{"timestamp":"2026-07-06T20:44:53.346Z","level":"INFO","name":"api","filename":"app.nim","line":11,"message":"done","extra":{"requestId":"abc-123","userId":42}}2026-07-06T13:44:53.346-07:00 PDT [INFO ] (app.nim:6) api: handling request
requestId: "abc-123"
userId: 42
2026-07-06T13:44:53.346-07:00 PDT [INFO ] (app.nim:9) api: processing payment
requestId: "abc-123"
userId: 42
orderId: "ord-789"
2026-07-06T13:44:53.346-07:00 PDT [INFO ] (app.nim:11) api: done
requestId: "abc-123"
userId: 42
Priority order (highest wins): message fields > logger extra > thread-local context.
Middleware functions receive a mutable LogRecord and return true to continue the chain or false to suppress the record.
configureLogging(cfg):
# Enrich every log line
cfg.middleware.add proc(record: var LogRecord): bool =
record.extra["env"] = %"production"
true
# Suppress debug logs at runtime
cfg.middleware.add proc(record: var LogRecord): bool =
record.level != "DEBUG"
var logger = newLogger(name = "api")
logger.info("request served")
logger.debug("cache miss") # suppressed by the second middleware{"timestamp":"2026-07-07T05:05:04.856Z","level":"INFO","name":"api","filename":"app.nim","line":14,"message":"request served","extra":{"env":"production"}}2026-07-06T22:05:04.856-07:00 PDT [INFO ] (app.nim:14) api: request served
env: "production"
The LogRecord type:
type LogRecord* = object
timestamp*: string
level*: string
name*: string
filename*: string
line*: int
message*: string
extra*: JsonNoderecord.extra is never nil while middleware runs: records without fields receive an empty JSON object, so middleware can add fields without a nil check. Records whose object is still empty after the chain are serialized without an extra key.
Middleware is configured together with outputs in a configureLogging block via cfg.middleware; any seq operation works (append, remove, reorder, or replace the whole chain). See Outputs and Routing for the commit semantics.
Import lumber/middleware for ready-made middleware:
import lumber
import lumber/middleware
import std/[os, strformat]
configureLogging(cfg):
# Rate limiter: allow max 5 messages per second from the same source location
cfg.middleware.add newRateLimiter(window = 1.0, maxBurst = 5)
var logger = newLogger(name = "api")
for i in 1 .. 13:
if i == 13:
sleep(1100) # let the rate-limit window expire
logger.info(&"Event {i}")Events 6-12 are dropped. When the window expires, the next emitted message from that source location includes a suppressed field with the count of dropped messages:
{"timestamp":"2026-07-08T03:27:05.585Z","level":"INFO","name":"api","filename":"app.nim","line":13,"message":"Event 1"}
{"timestamp":"2026-07-08T03:27:05.586Z","level":"INFO","name":"api","filename":"app.nim","line":13,"message":"Event 2"}
{"timestamp":"2026-07-08T03:27:05.586Z","level":"INFO","name":"api","filename":"app.nim","line":13,"message":"Event 3"}
{"timestamp":"2026-07-08T03:27:05.586Z","level":"INFO","name":"api","filename":"app.nim","line":13,"message":"Event 4"}
{"timestamp":"2026-07-08T03:27:05.586Z","level":"INFO","name":"api","filename":"app.nim","line":13,"message":"Event 5"}
{"timestamp":"2026-07-08T03:27:06.691Z","level":"INFO","name":"api","filename":"app.nim","line":13,"message":"Event 13","extra":{"suppressed":7}}2026-07-07T20:27:05.585-07:00 PDT [INFO ] (app.nim:13) api: Event 1
2026-07-07T20:27:05.586-07:00 PDT [INFO ] (app.nim:13) api: Event 2
2026-07-07T20:27:05.586-07:00 PDT [INFO ] (app.nim:13) api: Event 3
2026-07-07T20:27:05.586-07:00 PDT [INFO ] (app.nim:13) api: Event 4
2026-07-07T20:27:05.586-07:00 PDT [INFO ] (app.nim:13) api: Event 5
2026-07-07T20:27:06.691-07:00 PDT [INFO ] (app.nim:13) api: Event 13
suppressed: 7
The remaining built-in middleware, added inside a configureLogging block (the pattern redactor takes a compiled regex, so import regex alongside it):
# Sampler: log 1 in every 100 messages
cfg.middleware.add newSampler(rate = 100)
# Level sampler: sample DEBUG/TRACE at 1-in-50, always pass WARN+
cfg.middleware.add newLevelSampler(level = LogLevel.DEBUG, rate = 50)
# Redact sensitive fields using built-in defaults
cfg.middleware.add newRedactor()
# Override with a custom key list (replaces defaults entirely)
cfg.middleware.add newRedactor(@["password", "token", "ssn"])
# Redact values matching a regex pattern (e.g. credit card numbers)
cfg.middleware.add newPatternRedactor(re2"\d{4}-\d{4}-\d{4}-\d{4}")
# Custom placeholder
cfg.middleware.add newRedactor(@["apiKey"], placeholder = "***")Default redacted keys: api_key, api_secret, apiKey, apiSecret, authorization, card_number, cardNumber, cookie, credit_card, creditCard, cvv, passwd, password, pin, secret, ssn, token.
Each output has a stream, an optional level filter, and an optional names filter. By default, logs write to stdout at all levels.
Reconfigure logging with configureLogging. The first argument names the variable that holds a snapshot of the current configuration (outputs and middleware) inside the block; changes are committed atomically when the block finishes, so loggers on other threads never observe a half-applied configuration. If the block raises, nothing is committed. Outputs dropped by the new configuration are flushed (but not closed).
Concurrent configureLogging calls serialize, each seeing the previous one's committed state, and logging is never blocked while a configuration block runs. Two rules: a block must not call configureLogging itself (this raises a Defect, since nested commits would silently lose updates), and reconfiguration should happen from long-lived threads, typically the main thread. The second rule comes from Nim's default memory management (ORC), which registers reference bookkeeping in thread-local state: a short-lived thread that replaces outputs or middleware drops the old references on its own heap and can corrupt cycle collection after the thread exits. Compiling with --mm:atomicArc removes this constraint entirely (atomic reference counts, no thread-local cycle bookkeeping); CI runs the test suite under orc, arc, and atomicArc.
import std/streams
configureLogging(cfg):
cfg.outputs = @[
# Console: all levels, all loggers
Output(stream: newFileStream(stdout)),
# File: only ERROR and above
Output(stream: newFileStream("error.log", fmAppend), level: LogLevel.ERROR),
# File: only logs from the "db" logger
Output(stream: newFileStream("db.log", fmAppend), names: @["db"]),
]The Output type:
type Output* = object
stream*: Stream
level*: LogLevel = LogLevel.TRACE # default: accept all levels
names*: seq[string] = @[] # default: accept all logger namesRotates when the file exceeds a size limit. Keeps numbered backups (app.log.1, app.log.2, etc.).
# 10MB max, keep 5 backup files (default)
Output(stream: newRollingFileStream("app.log"))
# Custom: 50MB max, keep 10 backups
Output(stream: newRollingFileStream("app.log", maxBytes = 50_000_000, maxFiles = 10))Rotates at midnight UTC. Keeps dated backups (app.2026-07-02.log, app.2026-07-01.log, etc.).
# Keep 30 days of logs (default)
Output(stream: newDailyFileStream("app.log"))
# Keep 7 days
Output(stream: newDailyFileStream("app.log", maxFiles = 7))Wrap any stream with newBufferedStream for high-throughput logging. Uses a hybrid flush strategy inspired by Go's zap logger:
-
Flush on buffer full - when accumulated data exceeds
maxSize(default: 4096 bytes) -
Flush on timer - when
flushIntervalMshas elapsed since last flush (default: 1000ms) -
Flush on level - immediately on ERROR or FATAL (configurable via
flushLevel) - Flush on close - always flushes remaining data
# Default settings (4KB buffer, flush every 1s or on ERROR+)
Output(stream: newBufferedStream(newFileStream(stdout)))
# Custom: 8KB buffer, flush every 500ms, immediate flush on WARN+
Output(stream: newBufferedStream(
newFileStream("app.log", fmAppend),
maxSize = 8192,
flushIntervalMs = 500,
flushLevel = LogLevel.WARN
))
# Combine with rotating files
Output(stream: newBufferedStream(newRollingFileStream("app.log")))In benchmarks, buffered streams are ~1.5-2.3x faster than unbuffered, with the gap widening with more outputs.
Wrap any stream with newAsyncStream for non-blocking I/O. Log calls push data onto a channel and return immediately; a background thread handles the writes. Writes are batched while the writer has backlog, and the thread flushes the wrapped stream whenever its queue drains, so an idle logger never leaves data sitting in a buffer. close flushes everything and joins the thread.
configureLogging(cfg):
cfg.outputs = @[
# Async console output
Output(stream: newAsyncStream(newFileStream(stdout))),
# Async rotating file
Output(stream: newAsyncStream(newRollingFileStream("app.log"))),
]
# Close to flush and join the writer thread
for o in outputs:
o.stream.close()The lumber binary reads JSON log lines from stdin and prints colored, formatted output.
myapp | lumberOutput format (extra fields render inline by default; use --pretty to indent them on separate lines):
2026-07-06T13:44:49.081-07:00 PDT [INFO ] (app.nim:5) api: User logged in {"user":"alice","ip":"10.0.0.1"}
Levels are color-coded: TRACE (blue), DEBUG (light blue), INFO (white), WARN (yellow), ERROR (red), FATAL (magenta).
--level <level> Minimum log level to display
--filter <expr> Filter logs by field value (can be repeated)
--highlight <regex> Highlight lines matching regex (can be repeated, alias: --hl)
--tz <timezone> Timezone for timestamps (IANA name or abbreviation)
--format <template> Output format template
--time-format <fmt> Timestamp format using strftime specifiers
--pretty Indent extra fields on separate lines
--no-color Disable colored output (also respects NO_COLOR env var)
--config <path> Path to config file
--init Create default config file at ~/.config/lumber/config.toml
--help, -h Show help
--version, -v Show version
Filter logs by field values using expressions. Filters match against top-level fields (timestamp, level, name, message) and extra fields. Multiple filters are ANDed together.
# Exact match
myapp | lumber --filter userId=1234
# Not equal
myapp | lumber --filter "env!=production"
# Numeric comparison
myapp | lumber --filter "latency>500"
myapp | lumber --filter "status>=400"
# Regex match
myapp | lumber --filter "path~^/api"
myapp | lumber --filter "message~timeout|refused"
# Timestamp filtering (supports UTC and offset formats)
myapp | lumber --filter "timestamp>2026-07-03T12:00:00Z"
myapp | lumber --filter "timestamp>2026-07-03T15:00:00-07:00"
# Combine multiple filters
myapp | lumber --filter userId=1234 --filter "latency>500"Highlight lines where any field value matches a regex. Unlike --filter, non-matching lines are still shown: matching lines get a background tint, and the matched text itself gets a brighter highlight. Matching is case-insensitive.
# Highlight a request ID across interleaved logs
myapp | lumber --highlight "req-7f3a"
# Short alias
myapp | lumber --hl "timeout|refused"
# Multiple patterns
myapp | lumber --hl "alice" --hl "error"The background extends to the terminal's right edge. Highlight colors are configurable in the config file via colors.highlight_line (whole line background) and colors.highlight_match (matched text background). Both accept 256-color codes or named colors.
Timestamps are displayed in local time by default. Use --tz with an IANA timezone name or common abbreviation:
myapp | lumber --tz=UTC
myapp | lumber --tz=PST
myapp | lumber --tz=America/New_York
myapp | lumber --tz=JSTThe displayed timestamp includes the UTC offset and abbreviated timezone name for clarity:
2026-07-03T15:27:17-07:00 PDT [INFO ] ...
2026-07-03T18:27:17-04:00 EDT [INFO ] ...
Non-JSON lines pass through unchanged.
Customize the output layout with --format. Available placeholders: {timestamp}, {level}, {filename}, {line}, {name}, {message}, {duration}, {extra}.
# Minimal output
myapp | lumber --format "{level} {message}"
# Without filename/line
myapp | lumber --format "{timestamp} [{level}] {name}: {message}{extra}"Customize how timestamps are displayed with --time-format using strftime specifiers:
# Time only
myapp | lumber --time-format "%H:%M:%S"
# Short date
myapp | lumber --time-format "%b %d %H:%M"The default format is %Y-%m-%dT%H:%M:%S. The UTC offset and timezone abbreviation are always appended after the formatted time.
Lumber looks for configuration in two places (later sources override earlier ones):
-
Global:
~/.config/lumber/config.toml(respects$XDG_CONFIG_HOME) -
Project:
.lumber.tomlin the current or any parent directory (walks up like.git)
Generate a default config file with --init:
lumber --initExample config:
version = 1
[format]
template = "{timestamp} [{level}] ({filename}:{line}) {name}: {message}{duration}{extra}"
time_format = "%Y-%m-%dT%H:%M:%S"
[colors]
timestamp = "gray"
filename = "light_gray"
name = "cyan"
message = ""
duration = "gray"
extra_key = "cyan"
extra_value = ""
highlight_line = "236"
highlight_match = "240"
[colors.level]
trace = "blue"
debug = "light_blue"
info = "white"
warn = "yellow"
error = "red"
fatal = "magenta"
[options]
pretty = false
tz = "local"
level = "trace"Available colors: black, blue, cyan, gray, green, light_blue, light_cyan, light_gray, light_green, light_magenta, light_red, light_yellow, magenta, red, white, yellow. Highlight colors also accept 256-color codes (e.g. "236", "240").
CLI flags always take precedence over config file values.
import lumber
import std/[json, streams]
type
User = object
name: string
age: int
# Async console + rotating file + error-only file,
# plus request context via middleware
configureLogging(cfg):
cfg.outputs = @[
Output(stream: newAsyncStream(newFileStream(stdout))),
Output(stream: newRollingFileStream("app.log", maxBytes = 1_000_000, maxFiles = 3)),
Output(stream: newFileStream("error.log", fmAppend), level: LogLevel.ERROR),
]
cfg.middleware.add proc(record: var LogRecord): bool =
record.extra["env"] = %"production"
true
var logger = newLogger(extra = %* {"service": "demo-api"})
var admin = User(name: "Admin", age: 35)
logger.info("Starting up")
logger.debug("Loading config for", admin)
var reqLogger = logger.child(extra = %* {"requestId": "req-7f3a", "userId": 42})
reqLogger.info("Server listening on port 8080")
reqLogger.warn("Disk usage at 92%")
# Structured message fields
reqLogger.info("Request handled", status=200, latency=42, path="/api/users")
var dbLogger = reqLogger.child(name = "db", extra = %* {"host": "db.local", "port": 5432})
dbLogger.error("Failed to connect to database")
logger.fatal("Shutting down")
for o in outputs:
o.stream.close(){"timestamp":"2026-07-08T03:27:07.540Z","level":"INFO","name":"demo","filename":"demo.nim","line":24,"message":"Starting up","extra":{"service":"demo-api","env":"production"}}
{"timestamp":"2026-07-08T03:27:07.540Z","level":"DEBUG","name":"demo","filename":"demo.nim","line":25,"message":"Loading config for User(name: \"Admin\", age: 35)","extra":{"service":"demo-api","env":"production"}}
{"timestamp":"2026-07-08T03:27:07.540Z","level":"INFO","name":"demo","filename":"demo.nim","line":28,"message":"Server listening on port 8080","extra":{"service":"demo-api","requestId":"req-7f3a","userId":42,"env":"production"}}
{"timestamp":"2026-07-08T03:27:07.540Z","level":"WARN","name":"demo","filename":"demo.nim","line":29,"message":"Disk usage at 92%","extra":{"service":"demo-api","requestId":"req-7f3a","userId":42,"env":"production"}}
{"timestamp":"2026-07-08T03:27:07.540Z","level":"INFO","name":"demo","filename":"demo.nim","line":32,"message":"Request handled","extra":{"service":"demo-api","requestId":"req-7f3a","userId":42,"status":200,"latency":42,"path":"/api/users","env":"production"}}
{"timestamp":"2026-07-08T03:27:07.541Z","level":"ERROR","name":"db","filename":"demo.nim","line":35,"message":"Failed to connect to database","extra":{"service":"demo-api","requestId":"req-7f3a","userId":42,"host":"db.local","port":5432,"env":"production"}}
{"timestamp":"2026-07-08T03:27:07.541Z","level":"FATAL","name":"demo","filename":"demo.nim","line":37,"message":"Shutting down","extra":{"service":"demo-api","env":"production"}}2026-07-07T20:27:07.540-07:00 PDT [INFO ] (demo.nim:24) demo: Starting up
service: "demo-api"
env: "production"
2026-07-07T20:27:07.540-07:00 PDT [DEBUG] (demo.nim:25) demo: Loading config for User(name: "Admin", age: 35)
service: "demo-api"
env: "production"
2026-07-07T20:27:07.540-07:00 PDT [INFO ] (demo.nim:28) demo: Server listening on port 8080
service: "demo-api"
requestId: "req-7f3a"
userId: 42
env: "production"
2026-07-07T20:27:07.540-07:00 PDT [WARN ] (demo.nim:29) demo: Disk usage at 92%
service: "demo-api"
requestId: "req-7f3a"
userId: 42
env: "production"
2026-07-07T20:27:07.540-07:00 PDT [INFO ] (demo.nim:32) demo: Request handled
service: "demo-api"
requestId: "req-7f3a"
userId: 42
status: 200
latency: 42
path: "/api/users"
env: "production"
2026-07-07T20:27:07.541-07:00 PDT [ERROR] (demo.nim:35) db: Failed to connect to database
service: "demo-api"
requestId: "req-7f3a"
userId: 42
host: "db.local"
port: 5432
env: "production"
2026-07-07T20:27:07.541-07:00 PDT [FATAL] (demo.nim:37) demo: Shutting down
service: "demo-api"
env: "production"
lumber borrows its best ideas from projects that proved them first:
- pino - JSON lines, the CLI prettifier
-
python logging -
extrafields and the rotating file handlers -
zap - the hybrid flush strategy behind
BufferedStream -
chronicles - the scoped log context behind
withLogContext - Express - the middleware chain
MIT
