sema-log

0.1.0

Leveled, structured logging with pluggable appenders

$ sema pkg install sema-log
Readme Versions 1 Dependencies 0

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 not log/info? The bare names log/info, log/warn, log/error, and log/debug are Sema builtins (simple printers) and stay reserved for them, so this package's level functions are !-suffixed, consistent with log/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

VersionSizePublished
0.1.0 6 KB 2026-07-07 21:28:07
No dependencies