Handlers¶
A handler's job is to serialize a log-record to some destination.
handle-log-record is the only place a handler is allowed to write
output — the structural fix for a class of bug where two unrelated code
paths in an ad-hoc logger printed the same message twice. Every built-in
handler's handle-log-record method performs exactly one write call.
Text¶
text-handler writes one physical line per record:
Newlines, carriage returns, tabs, other control characters, DEL, U+2028,
U+2029, and bidirectional or invisible spoofing control characters in
messages, keys, or values are escaped so untrusted user data embedded in a
field can never inject a fake log line or disguise itself as one. Numbers,
characters, and symbols have bounded built-in renderings; arbitrary objects
render as the safe placeholder #<object> without ever invoking a
user-defined print method.
JSON¶
json-handler writes one strict RFC 8259
JSON object per line. The reserved top-level keys are time, level,
logger, and message; every user field is nested under a fields object
and can never collide with or shadow a top-level key.
Supported field values:
| Lisp value | JSON encoding |
|---|---|
nil or +json-null+ |
null |
+json-false+ |
false |
t |
true |
| string | string |
| integer | number |
| finite float | number |
| symbol | string (keywords are downcased) |
(json-object alist) |
object |
(json-array list-or-vector) |
array |
json-null-p, json-false-p, json-object-p, and json-array-p identify
these explicit values; json-object-members and json-array-elements
return defensive snapshots of their contents. Unsupported objects and
non-finite floats (NaN, infinities) signal unsupported-json-value rather
than being silently stringified — output either round-trips through a JSON
parser or the write never happens. The whole record is validated before any
character reaches the stream, so a rejected value can never leave a
partially written JSON object behind.
Constructors and stream options¶
Both built-in handlers accept :stream, :auto-flush, and :owns-stream
initargs (also exposed as make-text-handler / make-json-handler
functions with the same keyword defaults):
(make-instance 'json-handler :stream my-stream :auto-flush t :owns-stream nil)
;; equivalently:
(make-json-handler :stream my-stream :auto-flush t :owns-stream nil)
| Initarg | Default | Meaning |
|---|---|---|
:stream |
*standard-output* |
destination output stream |
:auto-flush |
t |
call finish-output after every write |
:owns-stream |
nil |
whether close-handler closes the stream |
Handlers that target the same stream object share one reentrant
stream-write lock, so their lines can never interleave, even across
multiple handler instances or threads. Each write and its automatic flush
run inside one shared operation; flush-handler uses the same
serialization when explicitly finishing buffered output.
close-handler is idempotent. It closes the underlying stream only when
:owns-stream t; after closing, that handler rejects further writes and
flushes with a stream-error. If an owned stream is closed reentrantly by a
stream callback while a write or flush is in progress, the physical close is
deferred until the outermost shared operation on that stream exits — this
avoids a self-deadlock where a callback triggered by finish-output tries
to close the very stream finish-output is still writing to.
Composition and lifecycle¶
multi-handler¶
Sends each operation — handle, flush, or close — to every child handler in the order supplied. The child list must be a proper list with no duplicate handlers; a repeated handler would receive the same record twice, so it is rejected at construction time:
(make-multi-handler (list (make-text-handler) (make-json-handler :stream log-file))
:error-policy :signal)
For handle and flush, the default :error-policy :signal stops the
pass at the first child that signals an error. Close always attempts
every child, regardless of policy, and only signals the first error (if any)
after every child has had a chance to close — so one misbehaving child can
never prevent the others from closing, and a failed close can be retried.
:error-policy |
Handle / flush | Close |
|---|---|---|
:signal (default) |
abort the pass at the first error | try every child, then re-signal the first error |
:continue |
skip the failing child, keep going | try every child, then re-signal the first error |
:callback |
invoke :error-callback, then keep going |
try every child, invoke :error-callback, then re-signal the first error |
:callback requires an :error-callback function, invoked with the
operation (:handle, :flush, or :close), the target child handler, and
the signaled condition.
filter-handler¶
Forwards a record to its target only when a predicate accepts it; flush and close are always forwarded unconditionally:
(make-filter-handler (make-text-handler)
(lambda (record) (level<= +level-warn+ (log-record-level record))))
function-handler¶
Adapts a plain function as a handler — a required record callback plus optional zero-argument flush and close callbacks:
(make-function-handler
(lambda (record) (push record *captured*))
:flush-function (lambda () ...)
:close-function (lambda () ...))
null-handler¶
Discards every record. Always reports handler-open-p true — it has no
close protocol of its own, so it never needs to.
processor-handler¶
Runs a record through a chain of enrichment functions before forwarding it to a target handler — for data every record should carry (host name, process id, and the like) without every call site passing it explicitly:
(make-processor-handler (make-json-handler)
:processors (list (lambda (record) (declare (ignore record))
(list :host (machine-instance)))
(lambda (record) (declare (ignore record))
(list :pid (sb-posix:getpid)))))
Each processor is a function of one argument (the record as enriched by
every earlier processor) returning a fields plist to merge in, or nil to
contribute nothing. Processors run in order, and a later one observes an
earlier one's contribution. Contributed fields are always merged under
whatever the record already carried before any processor ran — from the
original call site or its logger — so a processor can enrich a record but
never silently override real application data; among the processors
themselves, a later one overrides an earlier one at the same canonical key.
rotating-file-handler¶
Writes to a file whose name is derived from a base pathname and a zero-argument clock, opening a fresh file whenever the clock's value changes:
(make-rotating-file-handler #p"/var/log/app.log" :max-files 14)
;; writes to app-2026-07-25.log today, rotating to a new dated file
;; tomorrow and keeping at most the 14 most recent.
The default clock returns today's local date as YYYY-MM-DD, so the file
rotates once per day by default; inject any zero-argument function
returning a string that sorts lexicographically in chronological order (a
custom clock is checked on every write) for a different rotation cadence.
:wire-format selects :text (the default) or :json, and :auto-flush
(default t) is passed through to whichever stream handler backs the
current file. :max-files bounds how many rotated files — including the
current one — are kept, oldest deleted first; 0 (the default) keeps every
one. The target directory must already exist.
buffered-handler¶
Holds records back until one at or above a trigger level arrives, then
releases everything held — including the triggering record — to a target
handler in order. Stays quiet through routine DEBUG/INFO traffic, but
reveals the context leading up to an error once one actually happens:
:buffer-size bounds how many records are held while waiting for a
trigger, oldest dropped first; 0 (the default) keeps every one seen since
the last release. When :stop-buffering is true (the default), the handler
stays activated and forwards every later record directly once triggered;
when nil, it goes back to buffering after each release. flush-handler
and close-handler only affect the target's own buffered output — they
never force an unreleased buffer out, so records held back when the handler
is closed without ever triggering are discarded, not silently emitted.
Atomicity and lifecycle errors¶
Every handler in this section except null-handler admits each handle or
flush operation atomically with respect to close: an operation admitted
before close starts is guaranteed to finish before close invokes any child
handler or callback. Once close has started, later handle and flush
operations signal handler-lifecycle-error before invoking a predicate,
processor, child handler, or callback — so a call arriving during shutdown
never partially executes.
handler-lifecycle-error-handler, handler-lifecycle-error-operation, and
handler-lifecycle-error-state expose the failure without parsing a report
string.
A callback must not synchronously try to close the same handler while it is
being handled or flushed — that self-deadlocking request also signals
handler-lifecycle-error (with state :active-operation) rather than
hanging.
handler-open-p¶
An extensible generic function that reports whether a handler currently
accepts operations. The base handler method always returns true; stream
handlers and lifecycle-managed composite handlers override it to reflect
their actual state.