Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
Added¶
Changed¶
Fixed¶
1.0.0 - 2026-07-26¶
The first stable release.
cl-log-kit is a dependency-free, SBCL-only structured logging toolkit
modelled on Go's log/slog: immutable log records are separated from
handlers that serialize them, and handle-log-record is the single method
through which a record reaches output. Tagging 1.0.0 is a commitment that
the 101 exported symbols this entry describes are a stable surface —
anything that changes their shape or behavior from here on gets a major
version and a documented migration path.
Added¶
Levels and filtering¶
- Integer level ranks
+level-debug+(0),+level-info+(10),+level-warn+(20),+level-error+(30), and+level-fatal+(40), withlevel-name,level<, andlevel<=. Custom integer levels are accepted and render numerically when they match no standard rank. log,log-debug,log-info,log-warn,log-error, andlog-fataltake an explicit logger as their first argument;log-default,log-default-debug, and its siblings go through*default-logger*. The two families are deliberately distinct spellings rather than one macro that guesses which one you meant from its argument shape.- Every logging macro tests the level before evaluating its message and field forms, so a filtered call performs no record construction, field allocation, clock read, or handler work. The logger expression itself is evaluated exactly once.
log-enabled-pexposes the same predicate the macros use, andemit-logis the ordinary-function entry point, returning the emittedlog-recordornilwhen the level is filtered. Being a function, it evaluates its arguments unconditionally — which is exactly why the macros exist.*default-logger*,set-default-logger(process-wide), andwith-default-logger(dynamically scoped).
Records and defensive snapshotting¶
log-record: an immutable value carrying a level, message, timestamp, field alist, and logger name.make-log-recordbuilds one directly;log-record-level,-message,-timestamp,-fields, and-logger-nameread it back.- Construction deep-copies strings, conses, arrays, hash tables, and the explicit JSON containers, and every public accessor returns a fresh copy. A caller that mutates a value it passed in — or one it read back out — cannot change what a record or logger holds.
- Cyclic values signal
invalid-log-fieldsrather than recursing forever: the snapshot walk marks each container it is currently copying in aneqtable. A value reachable twice without being circular is a DAG and snapshots normally. - The list-shape checks that run before the walk (plist properness, JSON object member and array element lists) use Floyd's tortoise-and-hare, so a circular list is rejected in bounded time without being traversed.
- Fixed resource limits — nesting depth 64, 65,536 visited nodes, 1,048,576
characters per string, 16,384 elements per collection — each signalling
log-resource-limit-exceededwithlog-resource-limit-resource,-limit, and-actualreaders. A logging call can never become the vector for an unbounded-memory or non-terminating bug, even on attacker-influenced field values. - A list's length is charged to the collection-element bound and the node budget, not to nesting depth: only genuine nesting descends a level. A flat 100-element list field behaves like the equivalent vector or JSON array rather than being rejected as "100 levels deep."
Fields¶
- Logger and call-site fields are property lists with symbol or string keys.
Keys are canonicalized case-insensitively, so
:PORTand"port"in the same plist collide and signalinvalid-log-fields. plist-to-alistis the public spelling of that validation and canonicalization.- Precedence is fixed and documented: event (call-site) fields override dynamic context fields, which override logger fields.
The explicit JSON value model¶
json-objectandjson-arraywrappers for nested structure, plus+json-null+and+json-false+sentinels distinct from Lispnil, withjson-object-p,json-array-p,json-null-p,json-false-p,json-object-members, andjson-array-elements.- Nesting is explicit by design: an arbitrary list or vector is never
silently inferred to be a JSON array. Unsupported objects and non-finite
floats signal
unsupported-json-valueinstead of being stringified.
Loggers, derivation, and context¶
loggerandmake-logger, with:name,:handler,:level,:fields, and an injectable zero-argument:clock(Unix seconds by default) for deterministic tests.derive-logger(replace any subset of slots, merging fields),logger-child(dot-separated child names, shape-validated), andlogger-with(the fields-only shortcut). All three return a new logger; none mutates.with-log-contextattaches dynamically scoped fields to every log call made anywhere in its body, including from functions further down the stack.flush-loggerflushes a logger's handler and returns the logger.
Log spans¶
with-log-spanemits paired:startand:endrecords around a body, with:span-id,:span-event,:span-duration, a:span-outcomeof:success/:error/:nonlocal-exit, and:parent-span-idfor nested spans. Records logged inside the body inherit the span id through the logging context.- Span lifecycle keys are reserved: a caller's own
:fieldscannot overwrite them, whether spelled as a keyword or as a string. - Default span ids are handed out by an atomic counter, so they stay
distinct across concurrent threads.
:clockand:id-sourceare injectable for deterministic tests, and neither they nor the span's name and fields are evaluated when the span's level is filtered.
Cross-thread context propagation¶
sb-thread:make-threaddoes not inherit dynamic bindings, so a worker thread starts with no logging context.capture-log-context,with-captured-log-context,call-with-captured-log-context, and the opaquelog-context-snapshottype move the active context fields and span id across that boundary explicitly.
Stream handlers¶
text-handlerwrites one physical line per record asts=… level=… logger="…" msg="…" field."k"="v". Newlines, returns, tabs, control characters, DEL, U+2028/U+2029, and bidirectional or invisible spoofing controls are escaped in messages, keys, and values, so user data cannot inject or visually disguise another log line. Arbitrary objects render as the placeholder#<object>without invoking a user-defined printer.json-handlerwrites one strict RFC 8259 object per line, withtime,level,logger,message, andfieldsas the reserved top-level keys. User fields always live insidefieldsand never at the top level. The whole record is validated before a single byte is written, so a partial object can never reach the stream.make-text-handler/make-json-handler, and both classes, accept:stream,:auto-flush, and:owns-stream.- Handlers sharing one stream share a single reentrant write lock, so their
lines cannot interleave; each write and its automatic flush execute as one
operation.
close-handleris idempotent, closes the stream only under:owns-stream t, and defers physical close if an owned stream is closed reentrantly from a stream callback during a write, rather than deadlocking against itself.
Composition handlers¶
make-multi-handlerfans a record out to each child in order, under an:error-policyof:signal(the default: stop and re-signal),:continue, or:callback.close-handleris the deliberate exception: every child is given a chance to close before the first error is re-signaled, and a failed close can be retried.make-filter-handler(predicate gate),make-function-handler(adapt a closure, with optional flush and close callbacks), andmake-null-handler.make-processor-handlerruns a chain of enrichment functions over every record before forwarding it. Contributed fields merge under whatever the record already carried, so a processor can enrich but never clobber real call-site or logger data; between processors, later wins.make-rotating-file-handlerderives its filename from a base pathname and a zero-argument clock (today's local date by default), opens a fresh file when the clock's value changes, and prunes rotated files beyond an optional:max-filesretention count, oldest first. Base pathnames with and without a file type both rotate and prune.make-buffered-handlerholds records back until one at or above:trigger-levelarrives, then releases the whole held-back run in order.:buffer-sizebounds how many are held while waiting (oldest dropped first) and:stop-bufferingcontrols whether it stays activated afterwards. Flush and close never force an unreleased buffer out.- Composition handlers admit each handle or flush operation atomically. An
operation admitted before close finishes before close reaches any child;
once close starts, later operations signal
handler-lifecycle-errorbefore invoking a predicate, child, or callback. A callback that tries to synchronously close the handler it is running inside gets the same condition instead of a deadlock. handler-open-pis an extensible generic function;with-handlercloses its handler on every exit.
Condition logging¶
condition-fieldsrenders a condition into:condition-typeand:condition-messagefields, with an optional supplied or captured:backtrace.log-conditionemits them and, like the other logging macros, evaluates nothing when the level is filtered.- By default no user-defined
reportmethod is executed — a safe type-derived message is emitted instead.:render-report topts in to running it exactly once, under a bounded output stream that unwinds the report rather than over-allocating. Messages default to 2,048 characters and backtraces to 8,192;:message-limitand:backtrace-limitoverride both. - The condition keys are reserved and cannot be replaced through
:fields.
Errors¶
- A single
log-kit-errorroot withinvalid-log-fields,unsupported-json-value,log-resource-limit-exceeded, andhandler-lifecycle-errorbeneath it. Every one exposes its details through readers, so no caller has to parse a report string.
Extension¶
- Subclass
handlerand specializehandle-log-record; stateful handlers should also specializehandler-open-p,flush-handler, andclose-handler. Every exported symbol carries a docstring, sodocumentationanswers at the REPL for the whole public surface.
Performance¶
handle-log-recordallocates zero bytes per call on every benchmarked wire format and payload. Figures from onebenchmark/run.lisprun on SBCL 2.6.0 / aarch64-darwin (minimum wall-clock time and bytes consed per call over several full-GC'd repetitions):
| Benchmark | ns/call | bytes/call |
|---|---|---|
text-handler, ASCII message + 3 fields |
392 | 0 |
json-handler, ASCII message + 3 fields |
832 | 0 |
text-handler, 256-char field value |
686 | 0 |
json-handler, 256-char field value |
1787 | 0 |
json-handler, double-float-heavy fields |
1227 | 0 |
The timings are machine-specific and will move; the 0 bytes/call column
is the property the suite actually asserts, in
t/performance-test.lisp's allocation-bound specs.
- What buys that: interned downcased keyword key names behind a
copy-on-write cache, allocation-free base-10 integer and float encoding, a
simple-string/schar fast path in both encoders, a printable-ASCII
fast path in the escape scan, stack-allocated writer closures, a
conditional rather than unconditional waitqueue broadcast, and the removal
of JSON's redundant write-time re-validation.
- benchmark/competitors.lisp runs the same methodology against
log4cl, both writing an
equivalent message and fields to a discarding stream. On the same run as
the table above, cl-log-kit came in ~1.3x slower (477 ns/call vs
370 ns/call, both 0 bytes/call), and the honest framing is that the gap is
the genuine cost of work the comparable log4cl path does not do at all:
escaping and anti-spoofing every token, writing a structured per-field
record, and guaranteeing on every call the deep cycle-checked snapshot,
canonical-key deduplication, reentrant thread-safe stream serialization,
and structured resource limits. This library does not claim to be the
fastest Common Lisp logger in an unqualified sense; matching that would
mean dropping the guarantees it exists to provide.
Quality gates¶
- 179 examples run through
cl-weave, including property-based and fuzz specs, inline snapshots, mutation testing, allocation-bound assertions, and real multi-threaded specs for the stream and composition lifecycles (not single-threaded approximations of them). run-coverage.lispfails the build on any coverage regression below 93.85% expression / 98.7% branch; the suite currently reaches 93.95% / 98.77%. 100% is not the target and is not reachable here: every remaining uncovered span is a declarative form with no runtime execution model (defconstant,defclass/defstructslot lists,defpackageexport lists, docstrings) or adefmacrobody, which runs at macroexpansion time and is invisible tosb-cover's runtime instrumentation by construction.nix flake checkruns the same suite a contributor runs locally, with timeout enforcement viacl-process-kitrather than a shelltimeouta caller has to remember. Every CI job carries atimeout-minutesbound.- The shipped
cl-log-kitsystem depends on nothing but ASDF ≥ 3.3.1 and SBCL.cl-weave,cl-json-kit, andcl-process-kitare test- and dev-only, and the flake pins each to the tag matching the.asd's own version floor. - A full MkDocs (Material) documentation site, built
--strictinside the Nix sandbox so a broken link or unlisted page fails the build.