AtlatestRenderedmarkdown
Readme

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:

unchangedchanged
paths, IPv4 and IPv6, UUIDs, 1200ms, #t, 3.5, 8080, symbols, ISO timestamps, [email protected], slate.local:8443, café, 日本語, -1any 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 module

A 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 / macroPurpose
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-fatalEmit 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-levelReturn the currently configured global level

Levels, low to high: trace, debug, info, warn, error, fatal.

Dependencies

  • sigil-stdlib
  • sigil-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 build

Testing

test/gate-source-under-test.sh     # tests THIS REPO'S SOURCE
sigil test                         # does NOT — see below

sigil 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.