sigil-log
Structured logging for Sigil.
Provides configurable log levels, text and JSON output formats, per-module filtering, and lazy macros that skip evaluation when the level is inactive.
This package was extracted from the sigil monorepo — the in-tree history under packages/sigil-log/ is preserved here as master.
Usage
(import (sigil log))
(log-info "Server started" port: 8080)
(log-warn "Slow query" duration-ms: 1200)
(log-error "Request failed" path: "/api" status: 500)
;; Switch output format, raise/lower level
(log-configure! level: 'debug format: 'json)
;; Lazy macros — the argument expressions are not evaluated if the
;; current level suppresses the call site.
(log-debug* "result" value: (expensive-computation))Text output, and untrusted values
A text line is:
TIMESTAMP [LEVEL] message key=value key=value
Field values are safe to fill with untrusted data. A value containing a space, an =, a quote, a backslash or a control character is quoted and escaped, so it cannot forge extra key=value pairs or a second log line:
(log-warn "refused" host-header: "evil.example status=200 reason=host-allowed")
;; 2026-08-25T03:10:03Z [WARN] refused host-header="evil.example status=200 reason=host-allowed"Quoting is conditional, not unconditional. A value is quoted if and only if it contains a space, an =, a ", a \, a control character or a Unicode separator, or is empty. Everything else renders exactly as it always did.
Be careful reading that as "ordinary values are unaffected" — several everyday ones are not. Measured across 27 realistic values, 15 render identically and 12 change:
| unchanged | changed |
|---|---|
paths, IPv4 and IPv6, UUIDs, 1200ms, #t, 3.5, 8080, symbols, ISO timestamps, [email protected], slate.local:8443, café, 日本語, -1 | any URL with a query string (?b=1), base64 with padding (aGVsbG8=), any Windows path, any User-Agent (spaces), JSON fragments, the empty string, character literals, and every list or compound value |
So a reader diffing logs across this boundary should expect the =-bearing and space-bearing fields to gain quotes, and nothing else to move.
Non-ASCII text is not byte-escaped: café au lait renders as "café au lait", quoted but intact. (An earlier version of this fix delegated quoting to write, which byte-escapes everything non-ASCII and produced "caf\xC3\xA9 au lait" — unreadable, and wrong for anyone whose data is not English.)
Keys are sanitised, not quoted. A key is an identifier and must be one bare token, so any separator, =, quote or backslash in it becomes _. This is not belt-and-braces: string->keyword accepts any string and is used with runtime data across the ecosystem, and (apply log-warn msg kwargs) is the natural way to log a dict — so a key can carry attacker text, and a crafted one forged both a field and an entire second log line before this was in place.
The message is not a field. It is prose, left unquoted so it stays readable. Its control characters and every codepoint that some reader treats as a line break are escaped — \n, \r, vertical tab, form feed, and U+0085, U+2028, U+2029, which Python's str.splitlines() splits on. That makes "one log event is exactly one line" true for a text reader and for a Python one. It does not make the message unforgeable: a message containing something shaped like k=v still reads as a field. Never interpolate untrusted data into a message; pass it as a field. For fully machine-parseable output use format: 'json, which escapes everything including the message.
Per-module levels
The global level is a single volume control for the whole process. Per-module levels let one subsystem be louder or quieter than the rest:
(log-configure! level: 'info modules: '(("node/tty" . trace)))
(log-trace* "byte in" module: "node/tty" n: 1) ; emitted — module is at trace
(log-trace* "byte out" module: "node/net" n: 1) ; suppressed — falls back to info
(log-debug* "cache hit" key: "user:42") ; suppressed — names no moduleA call is matched to a module by its module: field. A call without one always uses the global level. Module names may be strings or symbols.
The lazy macros work with this: a module raised above the global level still emits through log-trace*. The cost is that raising any module to level N makes the argument expressions of every level-N lazy call evaluate, even where the call is then suppressed. Compute first and log second if that matters at a given call site.
API
(sigil log)
| Procedure / macro | Purpose |
|---|---|
log-configure! | Set level, format, output target, per-module levels |
log-configure-from-args! | Parse --log and --log-level from a CLI argv |
log-trace / log-debug / log-info / log-warn / log-error / log-fatal | Emit at level |
log-trace* / log-debug* / log-info* / log-warn* / log-error* / log-fatal* | Lazy variants — skip argument evaluation when level inactive |
log-level-active? | Query whether a level would be emitted, optionally for a named module |
log-level-may-be-active? | The permissive pre-filter the lazy macros expand into; exported for that reason, not for application code |
current-log-level | Return the currently configured global level |
Levels, low to high: trace, debug, info, warn, error, fatal.
Dependencies
sigil-stdlibsigil-json
Both are workspace siblings in the sigil monorepo and are consumed here as from-git refs pinned to ^0.16.
Build
sigil deps install
sigil buildTesting
test/gate-source-under-test.sh # tests THIS REPO'S SOURCE
sigil test # does NOT — see belowsigil test in this repo does not read src/sigil/log.sgl. The sigil binary embeds its own copy of (sigil log), and that copy wins over the project's source, over its built .sgb, and over an explicit from-path redirect. Appending a syntax error to the library and re-running sigil test produces an identical result, which is how this was established.
test/gate-source-under-test.sh routes around it: it generates a copy of the library renamed to a module name the binary does not embed, asserts the copy differs from the original by exactly that one line, and runs every suite — including the arms under test/uut/ — against it. Those arms are deliberately named so sigil test does not discover them, because against the bundled copy they would be permanently and uninformatively red.
The same shadowing means a released fix here does not reach any consumer until a sigil CLI is rebuilt carrying it.
License
BSD-3-Clause.