AtlatestRepositorysigil-log
1# sigil-log
2
3Structured logging for [Sigil](https://codeberg.org/sigil/sigil).
4
5Provides configurable log levels, text and JSON output formats, per-module
6filtering, and lazy macros that skip evaluation when the level is inactive.
7
8This package was extracted from the sigil monorepo — the in-tree history
9under `packages/sigil-log/` is preserved here as `master`.
11## Usage
13```scheme
14(import (sigil log))
16(log-info "Server started" port: 8080)
17(log-warn "Slow query" duration-ms: 1200)
18(log-error "Request failed" path: "/api" status: 500)
20;; Switch output format, raise/lower level
21(log-configure! level: 'debug format: 'json)
23;; Lazy macros — the argument expressions are not evaluated if the
24;; current level suppresses the call site.
25(log-debug* "result" value: (expensive-computation))
26```
28## Text output, and untrusted values
30A text line is:
32```
33TIMESTAMP [LEVEL] message key=value key=value
34```
36**Field values are safe to fill with untrusted data.** A value containing a
37space, an `=`, a quote, a backslash or a control character is quoted and
38escaped, so it cannot forge extra `key=value` pairs or a second log line:
40```scheme
41(log-warn "refused" host-header: "evil.example status=200 reason=host-allowed")
42;; 2026-08-25T03:10:03Z [WARN] refused host-header="evil.example status=200 reason=host-allowed"
43```
45Quoting is conditional, not unconditional. **A value is quoted if and only if it
46contains a space, an `=`, a `"`, a `\`, a control character or a Unicode
47separator, or is empty.** Everything else renders exactly as it always did.
49Be careful reading that as "ordinary values are unaffected" — several everyday
50ones are not. Measured across 27 realistic values, 15 render identically and 12
51change:
53| unchanged | changed |
54|---|---|
55| 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 |
57So a reader diffing logs across this boundary should expect the `=`-bearing and
58space-bearing fields to gain quotes, and nothing else to move.
60Non-ASCII text is **not** byte-escaped: `café au lait` renders as
61`"café au lait"`, quoted but intact. (An earlier version of this fix delegated
62quoting to `write`, which byte-escapes everything non-ASCII and produced
63`"caf\xC3\xA9 au lait"` — unreadable, and wrong for anyone whose data is not
64English.)
66**Keys are sanitised, not quoted.** A key is an identifier and must be one bare
67token, so any separator, `=`, quote or backslash in it becomes `_`. This is not
68belt-and-braces: `string->keyword` accepts any string and is used with runtime
69data across the ecosystem, and `(apply log-warn msg kwargs)` is the natural way
70to log a dict — so a key can carry attacker text, and a crafted one forged both
71a field and an entire second log line before this was in place.
73**The message is not a field.** It is prose, left unquoted so it stays readable.
74Its control characters and every codepoint that some reader treats as a line
75break are escaped — `\n`, `\r`, vertical tab, form feed, and U+0085, U+2028,
76U+2029, which Python's `str.splitlines()` splits on. That makes "one log event
77is exactly one line" true for a text reader and for a Python one. It does **not**
78make the message unforgeable: a message containing something shaped like `k=v`
79still reads as a field. Never interpolate untrusted data into a message; pass it
80as a field. For fully machine-parseable output use `format: 'json`, which
81escapes everything including the message.
83## Per-module levels
85The global level is a single volume control for the whole process. Per-module
86levels let one subsystem be louder or quieter than the rest:
88```scheme
89(log-configure! level: 'info modules: '(("node/tty" . trace)))
91(log-trace* "byte in" module: "node/tty" n: 1) ; emitted — module is at trace
92(log-trace* "byte out" module: "node/net" n: 1) ; suppressed — falls back to info
93(log-debug* "cache hit" key: "user:42") ; suppressed — names no module
94```
96A call is matched to a module by its **`module:` field**. A call without one
97always uses the global level. Module names may be strings or symbols.
99The lazy macros work with this: a module raised above the global level still
100emits through `log-trace*`. The cost is that raising *any* module to level N
101makes the argument expressions of *every* level-N lazy call evaluate, even
102where the call is then suppressed. Compute first and log second if that matters
103at a given call site.
105## API
107### `(sigil log)`
109| Procedure / macro | Purpose |
110|-------------------|---------|
111| `log-configure!` | Set level, format, output target, per-module levels |
112| `log-configure-from-args!` | Parse `--log` and `--log-level` from a CLI argv |
113| `log-trace` / `log-debug` / `log-info` / `log-warn` / `log-error` / `log-fatal` | Emit at level |
114| `log-trace*` / `log-debug*` / `log-info*` / `log-warn*` / `log-error*` / `log-fatal*` | Lazy variants — skip argument evaluation when level inactive |
115| `log-level-active?` | Query whether a level would be emitted, optionally for a named module |
116| `log-level-may-be-active?` | The permissive pre-filter the lazy macros expand into; exported for that reason, not for application code |
117| `current-log-level` | Return the currently configured global level |
119Levels, low to high: `trace`, `debug`, `info`, `warn`, `error`, `fatal`.
121## Dependencies
123- `sigil-stdlib`
124- `sigil-json`
126Both are workspace siblings in the sigil monorepo and are consumed here
127as `from-git` refs pinned to `^0.16`.
129## Build
131```sh
132sigil deps install
133sigil build
134```
136## Testing
138```sh
139test/gate-source-under-test.sh # tests THIS REPO'S SOURCE
140sigil test # does NOT — see below
141```
143**`sigil test` in this repo does not read `src/sigil/log.sgl`.** The `sigil`
144binary embeds its own copy of `(sigil log)`, and that copy wins over the
145project's source, over its built `.sgb`, and over an explicit `from-path`
146redirect. Appending a syntax error to the library and re-running `sigil test`
147produces an identical result, which is how this was established.
149`test/gate-source-under-test.sh` routes around it: it generates a copy of the
150library renamed to a module name the binary does not embed, asserts the copy
151differs from the original by exactly that one line, and runs every suite —
152including the arms under `test/uut/` — against it. Those arms are deliberately
153named so `sigil test` does not discover them, because against the bundled copy
154they would be permanently and uninformatively red.
156The same shadowing means a released fix here does not reach any consumer until
157a `sigil` CLI is rebuilt carrying it.
159## License
161BSD-3-Clause.