lumber
A JSON logger and prettifier CLI
Summary
| Latest Version | 0.5.0 |
|---|---|
| License | MIT |
| CI Status | Failing |
| Downloads | 0 |
| Last Indexed | 2026-09-05 07:27 |
Tags
Authors
- Craig Younker
Installation
nimble install lumber
choosenim install lumber
git clone https://github.com/cryo2010/nim-lumber
OS Compatibility
| Platform | Linux | macOS | Windows | FreeBSD | OpenBSD | NetBSD | Android | iOS | WASM | Embedded |
|---|---|---|---|---|---|---|---|---|---|---|
| lumber | ✓ | ✓ | ✓ | - | - | - | - | - | - | - |
Dependencies
| Package | Version | Optional |
|---|---|---|
| nim >= | 2.2.10 | No |
| regex >= | 0.25.0 | No |
| zippy >= | 0.10.16 | No |
Source
| Repository | https://github.com/cryo2010/nim-lumber |
|---|---|
| Homepage | https://github.com/cryo2010/nim-lumber |
| Registry Source | nimble_official |
README
lumber
A JSON logger for Nim with a built-in CLI prettifier.

Features
- 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 - 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, and an async writer - Log compression - opt-in compression for rotating and daily log files
- Thread-safe - safe for concurrent use from multiple threads
- Cross-platform - supported on macOS, Linux and Windows
- CLI prettifier - pipe JSON logs through the lumber binary for colorful, human-readable output with field filters (
--filter "latency>500"), regex highlighting, timezone conversion and more
Contents
- Installation
- Quick Start
- Compile-Time Level Filtering
- API
- Creating a Logger
- Log Levels
- Runtime Level Filtering
- Building Messages
- Structured Messages
- Exception Logging
- Timing Blocks
- Child Loggers
- Thread-Local Context
- Middleware
- Built-in Middleware
- Outputs and Routing
- Rotating File Streams
- Buffered Streams
- Async Streams
- CLI Prettifier
- Options
- Field Filtering
- Highlighting
- Timezone Support
- Output Format
- Timestamp Format
- Configuration File
- Full Example
- Thanks
- License
Installation
nimble install lumber
This installs both the library and the lumber CLI prettifier (into nimble's bin directory, typically ~/.nimble/bin).
Quick Start
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.
Output:
{"timestamp":"2026-07-08T03:55:22.324Z","level":"INFO","name":"mymodule","filename":"mymodule.nim","line":8,"message":"Hello, world!"}
Piped through the lumber CLI prettifier:
2026-07-07T20:55:22.324-07:00 PDT [INFO ] (mymodule.nim:8) mymodule: Hello, world!
Compile-Time Level Filtering
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.nim
With 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).
API
Creating a Logger
# 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"))
Log Levels
logger.trace("Detailed tracing info")
logger.debug("Debug information")
logger.info("General information")
logger.warn("Warning")
logger.error("Error occurred")
logger.fatal("Fatal error")
Output:
{"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"}
Piped through lumber:
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
Runtime Level Filtering
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
Output (only the ERROR line is emitted):
{"timestamp":"2026-07-06T20:44:47.409Z","level":"ERROR","name":"api","filename":"app.nim","line":6,"message":"processed"}
Piped through lumber:
2026-07-06T13:44:47.409-07:00 PDT [ERROR] (app.nim:6) api: processed
Child loggers inherit the parent's level.
Building Messages
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))
Output:
{"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)"}
Piped through lumber:
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)
Structured Messages
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)
Output:
{"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}}
Piped through lumber --pretty:
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")
Output (user is "alice", not "system"):
{"timestamp":"2026-07-06T20:44:49.908Z","level":"INFO","name":"app","filename":"app.nim","line":4,"message":"login","extra":{"user":"alice"}}
Piped through lumber --pretty:
2026-07-06T13:44:49.908-07:00 PDT [INFO ] (app.nim:4) app: login
user: "alice"
Exception Logging
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)
Output:
{"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"}}
Piped through lumber (stack traces are rendered on separate lines automatically):
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)
Output:
{"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"}]}}
Piped through lumber:
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
Timing Blocks
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()
Output:
{"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}}
Piped through lumber (the duration is displayed inline after the message):
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)
Child Loggers
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")
Output:
{"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 ..."}}
Piped through lumber --pretty:
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))
Thread-Local Context
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")
Output (note orderId appears only inside the nested block):
{"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}}
Piped through lumber --pretty:
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
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
Output (the INFO line is enriched with env; the DEBUG line is suppressed):
{"timestamp":"2026-07-07T05:05:04.856Z","level":"INFO","name":"api","filename":"app.nim","line":14,"message":"request served","extra":{"env":"production"}}
Piped through lumber --pretty:
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*: JsonNode
record.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.
Built-in Middleware
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}}
Piped through lumber --pretty:
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.
Outputs and Routing
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.
Logging itself has no such constraint: records are assembled and written while holding the internal write lock, which also covers the reference-count traffic on a shared logger's extra, so sharing loggers across threads is safe under all three memory managers. Read the current outputs with outputs() (a snapshot); to change them, always go through configureLogging, and shut down with shutdownLogs() rather than closing streams directly, so closing cannot race an in-flight write on another thread.
import std/streams
configureLogging(cfg):
cfg.outputs = @[
# Console: all levels, all loggers
LogOutput(stream: newFileStream(stdout)),
# File: only ERROR and above
LogOutput(stream: newFileStream("error.log", fmAppend), level: LogLevel.ERROR),
# File: only logs from the "db" logger
LogOutput(stream: newFileStream("db.log", fmAppend), names: @["db"]),
]
The LogOutput type:
type LogOutput* = object
stream*: Stream
level*: LogLevel = LogLevel.TRACE # default: accept all levels
names*: seq[string] = @[] # default: accept all logger names
Rotating File Streams
Size-based rotation
Rotates when the file exceeds a size limit. Keeps numbered backups (app.log.1, app.log.2, etc.).
# 10MB max, keep 5 backup files (default)
LogOutput(stream: newRollingFileStream("app.log"))
# Custom: 50MB max, keep 10 backups
LogOutput(stream: newRollingFileStream("app.log", maxBytes = 50_000_000, maxFiles = 10))
Time-based rotation
Rotates at midnight UTC. Keeps dated backups (app.2026-07-02.log, app.2026-07-01.log, etc.).
# Keep 30 days of logs (default)
LogOutput(stream: newDailyFileStream("app.log"))
# Keep 7 days
LogOutput(stream: newDailyFileStream("app.log", maxFiles = 7))
Backup compression
Both rotating streams accept compress = true to gzip rotated backups. JSON logs typically compress 10-20x. Compression runs on a background thread, so rotation inside the logging path stays cheap; close (or shutdownLogs) waits for queued compressions to finish. If a compression fails, the backup is simply left uncompressed.
# Dated backups become app.2026-07-02.log.gz
LogOutput(stream: newDailyFileStream("app.log", compress = true))
# Size-based backups get timestamped names (app.log.20260805T101530-001.gz)
# instead of the numbered chain: renumbering would rename files the
# compressor may still hold open, which Windows refuses
LogOutput(stream: newRollingFileStream("app.log", compress = true))
maxFiles counts compressed and not-yet-compressed backups alike. If rotation ever outpaces the compressor, backups are left uncompressed rather than making the logger wait.
Buffered Streams
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+)
LogOutput(stream: newBufferedStream(newFileStream(stdout)))
# Custom: 8KB buffer, flush every 500ms, immediate flush on WARN+
LogOutput(stream: newBufferedStream(
newFileStream("app.log", fmAppend),
maxSize = 8192,
flushIntervalMs = 500,
flushLevel = LogLevel.WARN
))
# Combine with rotating files
LogOutput(stream: newBufferedStream(newRollingFileStream("app.log")))
In benchmarks, buffered streams are ~1.5-2.3x faster than unbuffered, with the gap widening with more outputs.
Async Streams
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
LogOutput(stream: newAsyncStream(newFileStream(stdout))),
# Async rotating file
LogOutput(stream: newAsyncStream(newRollingFileStream("app.log"))),
]
# Close to flush and join the writer threads
shutdownLogs()
CLI Prettifier
The lumber binary reads JSON log lines from stdin and prints colored, formatted output.
myapp | lumber
Output 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).
Options
--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
Field Filtering
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"
Highlighting
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.
Timezone Support
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=JST
The 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.
Output Format
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}"
Timestamp Format
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.
Configuration File
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 --init
Example 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.
Full Example
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 = @[
LogOutput(stream: newAsyncStream(newFileStream(stdout))),
LogOutput(stream: newRollingFileStream("app.log", maxBytes = 1_000_000, maxFiles = 3)),
LogOutput(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")
shutdownLogs()
Output
The below messages are also written to app.log; ERROR and FATAL additionally go to error.log.
{"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"}}
Piped through lumber --pretty:
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"
Thanks
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
License
MIT