lumber

A JSON logger and prettifier CLI


Keywords
json, logger, logging, log, lumber
License
MIT
Install
nimble install lumber

Documentation

lumber

CI License: MIT

A JSON logger for Nim with a built-in CLI prettifier.

lumber CLI output

Features

  • Structured JSON output - every log line is valid JSON with timestamp, level, name, filename, line number, and message
  • Structured messages - named key=value arguments become discrete JSON fields, queryable by log aggregators; extra positional arguments are appended, and Nim's std/strformat covers 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 Exception and 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 Stream simultaneously, 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 lumber binary for colored, human-readable output with level filtering, field filtering, timezone support, and customizable format/colors via TOML config

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.), use from lumber import nil. All examples below use plain import lumber for 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.

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 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)
Output(stream: newRollingFileStream("app.log"))

# Custom: 50MB max, keep 10 backups
Output(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)
Output(stream: newDailyFileStream("app.log"))

# Keep 7 days
Output(stream: newDailyFileStream("app.log", maxFiles = 7))

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 flushIntervalMs has 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.

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
    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()

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):

  1. Global: ~/.config/lumber/config.toml (respects $XDG_CONFIG_HOME)
  2. Project: .lumber.toml in 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 = @[
    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()

Output (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 - extra fields 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