sema-log
0.1.0Leveled, structured logging with pluggable appenders
sema-log
Leveled, structured logging with pluggable appenders. Modeled on Clojure's timbre: five ordered levels, a message plus an optional context map per call, and every log event dispatched as a plain data map to a registry of appender functions.
Why
log/info!and notlog/info? The bare nameslog/info,log/warn,log/error, andlog/debugare Sema builtins (simple printers) and stay reserved for them, so this package's level functions are!-suffixed, consistent withlog/log!.
Install
sema pkg add sema-log
Quick start
(import "sema-log")
(log/info! "server started" {:port 8080})
; stderr: 2026-07-07T12:00:00Z INFO server started port=8080
(log/set-level! :warn)
(log/info! "not shown") ; below minimum — cheap no-op
(log/error! "boom" {:code 500})
; stderr: 2026-07-07T12:00:01Z ERROR boom code=500
Levels
:trace < :debug < :info < :warn < :error. Calls below the global
minimum (default :info) build no event and touch no appender.
Events
Every accepted call builds an event map and hands it to each registered appender:
{:level :info
:message "server started"
:context {:port 8080}
:timestamp "2026-07-07T12:00:00Z"} ; ISO 8601, UTC
Structured context
Pass a context map with any call, or scope one over a block —
log/with-context merges its map into every event logged while the thunk
runs (nests, call-site keys win, restored afterwards even on error):
(log/with-context {:request-id "r-42"}
(fn ()
(log/info! "handling") ; context: {:request-id "r-42"}
(log/info! "saved" {:rows 3}))) ; context: {:request-id "r-42" :rows 3}
Appenders
An appender is any (fn (event) ...). The registry is a {name fn} map; the
default is a single :stderr appender printing
<timestamp> LEVEL message key=val key2=val2 with the level colored. Writing
your own means writing one function that receives the event map and does
whatever you want with it — log/format-line gives you the default plain-text
rendering for free:
(log/add-appender! :errors-only
(fn (event)
(when (= :error (get event :level))
(file/append "errors.log" (string-append (log/format-line event) "\n")))))
;; Built-in constructors:
(log/add-appender! :file (log/file-appender "app.log")) ; formatted lines
(log/add-appender! :json (log/json-appender "app.jsonl")) ; JSON Lines
(log/remove-appender! :stderr)
Both file constructors append (via file/append) — nothing is truncated or
rewritten.
Environment variables
| Variable | Effect |
|---|---|
SEMA_LOG_LEVEL |
Initial minimum level, read once at import (trace/debug/info/warn/error, case-insensitive, optional leading :). Unrecognized values fall back to :info. |
SEMA_LOG_NO_COLOR, NO_COLOR |
If set (to anything), the default stderr appender emits no ANSI colors. Checked per call. |
API
| Function | Description |
|---|---|
(log/trace! msg ctx?) … (log/error! msg ctx?) |
Log at that level with an optional context map |
(log/log! level msg ctx?) |
Log at an explicit level keyword |
(log/set-level! level) |
Set the global minimum level |
(log/level) |
Current minimum level keyword |
(log/with-context ctx thunk) |
Merge ctx into all events while thunk runs |
(log/add-appender! name fn) |
Register (or replace) an appender |
(log/remove-appender! name) |
Unregister an appender |
(log/set-appenders! map) |
Replace the whole registry |
(log/appenders) |
Current {name fn} registry |
(log/file-appender path) |
Appender fn: append formatted lines to a file |
(log/json-appender path) |
Appender fn: append JSON Lines to a file |
(log/format-line event) |
Default plain-text line for an event (no color) |
log/trace! log/debug! log/info! log/warn! log/error!
(log/info! "deploy finished")
(log/info! "deploy finished" {:region "eu" :took-ms 1200})
Message string plus optional context map. Below-minimum calls return nil
without building an event. All five return nil.
log/log!
(log/log! :warn "disk almost full" {:free-gb 2})
Same as the level functions but the level is an argument — useful when the level is computed. Raises on an unknown level keyword.
log/set-level! / log/level
(log/set-level! :debug) ; => :debug
(log/level) ; => :debug
log/set-level! raises on anything other than the five level keywords.
log/with-context
(log/with-context {:job-id 7}
(fn ()
(log/info! "step 1")
(log/with-context {:step 2}
(fn () (log/info! "nested"))))) ; context: {:job-id 7 :step 2}
Dynamic scoping via save/restore of module state: the previous context is restored when the thunk returns or throws (the error is re-raised). Returns the thunk's value. Note the scope is global module state, not per-async-task.
log/add-appender! / log/remove-appender! / log/set-appenders! / log/appenders
(log/add-appender! :json (log/json-appender "app.jsonl"))
(log/remove-appender! :stderr)
(log/appenders) ; => {:json <fn>}
(log/set-appenders! {}) ; silence everything
Names are keywords; adding under an existing name replaces it. Removing an
unknown name is a no-op. log/set-appenders! raises if not given a map.
log/file-appender
(log/add-appender! :file (log/file-appender "app.log"))
Returns an appender that appends one log/format-line line per event to
path. The file is created on first write.
log/json-appender
(log/add-appender! :audit (log/json-appender "audit.jsonl"))
;; each line: {"context":{...},"level":"info","message":"...","timestamp":"..."}
Returns an appender that appends (json/encode event) plus a newline per
event — JSON Lines, one decodable object per line.
log/format-line
(log/format-line {:level :info :message "hi" :context {:a 1}
:timestamp "2026-07-07T12:00:00Z"})
; => "2026-07-07T12:00:00Z INFO hi a=1"
The default plain-text rendering (no ANSI colors). Context pairs are appended
as key=val, sorted by key.
Testing
sema pkg add sema-test # once
sema tests.sema
License
MIT
| Version | Size | Published |
|---|---|---|
| 0.1.0 | 6 KB | 2026-07-07 21:28:07 |