AtlatestRepositorysigil-log
1
# sigil-log3
Structured logging for [Sigil](https://codeberg.org/sigil/sigil).5
Provides configurable log levels, text and JSON output formats, per-module6
filtering, and lazy macros that skip evaluation when the level is inactive.8
This package was extracted from the sigil monorepo — the in-tree history9
under `packages/sigil-log/` is preserved here as `master`.11
## Usage13
```scheme14
(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 level21
(log-configure! level: 'debug format: 'json)23
;; Lazy macros — the argument expressions are not evaluated if the24
;; current level suppresses the call site.25
(log-debug* "result" value: (expensive-computation))26
```28
## Text output, and untrusted values30
A text line is:32
```33
TIMESTAMP [LEVEL] message key=value key=value34
```36
**Field values are safe to fill with untrusted data.** A value containing a37
space, an `=`, a quote, a backslash or a control character is quoted and38
escaped, so it cannot forge extra `key=value` pairs or a second log line:40
```scheme41
(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
```45
Quoting is conditional, not unconditional. **A value is quoted if and only if it46
contains a space, an `=`, a `"`, a `\`, a control character or a Unicode47
separator, or is empty.** Everything else renders exactly as it always did.49
Be careful reading that as "ordinary values are unaffected" — several everyday50
ones are not. Measured across 27 realistic values, 15 render identically and 1251
change: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 |57
So a reader diffing logs across this boundary should expect the `=`-bearing and58
space-bearing fields to gain quotes, and nothing else to move.60
Non-ASCII text is **not** byte-escaped: `café au lait` renders as61
`"café au lait"`, quoted but intact. (An earlier version of this fix delegated62
quoting to `write`, which byte-escapes everything non-ASCII and produced63
`"caf\xC3\xA9 au lait"` — unreadable, and wrong for anyone whose data is not64
English.)66
**Keys are sanitised, not quoted.** A key is an identifier and must be one bare67
token, so any separator, `=`, quote or backslash in it becomes `_`. This is not68
belt-and-braces: `string->keyword` accepts any string and is used with runtime69
data across the ecosystem, and `(apply log-warn msg kwargs)` is the natural way70
to log a dict — so a key can carry attacker text, and a crafted one forged both71
a 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.74
Its control characters and every codepoint that some reader treats as a line75
break are escaped — `\n`, `\r`, vertical tab, form feed, and U+0085, U+2028,76
U+2029, which Python's `str.splitlines()` splits on. That makes "one log event77
is exactly one line" true for a text reader and for a Python one. It does **not**78
make the message unforgeable: a message containing something shaped like `k=v`79
still reads as a field. Never interpolate untrusted data into a message; pass it80
as a field. For fully machine-parseable output use `format: 'json`, which81
escapes everything including the message.83
## Per-module levels85
The global level is a single volume control for the whole process. Per-module86
levels let one subsystem be louder or quieter than the rest:88
```scheme89
(log-configure! level: 'info modules: '(("node/tty" . trace)))91
(log-trace* "byte in" module: "node/tty" n: 1) ; emitted — module is at trace92
(log-trace* "byte out" module: "node/net" n: 1) ; suppressed — falls back to info93
(log-debug* "cache hit" key: "user:42") ; suppressed — names no module94
```96
A call is matched to a module by its **`module:` field**. A call without one97
always uses the global level. Module names may be strings or symbols.99
The lazy macros work with this: a module raised above the global level still100
emits through `log-trace*`. The cost is that raising *any* module to level N101
makes the argument expressions of *every* level-N lazy call evaluate, even102
where the call is then suppressed. Compute first and log second if that matters103
at a given call site.105
## API107
### `(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 |119
Levels, low to high: `trace`, `debug`, `info`, `warn`, `error`, `fatal`.121
## Dependencies123
- `sigil-stdlib`124
- `sigil-json`126
Both are workspace siblings in the sigil monorepo and are consumed here127
as `from-git` refs pinned to `^0.16`.129
## Build131
```sh132
sigil deps install133
sigil build134
```136
## Testing138
```sh139
test/gate-source-under-test.sh # tests THIS REPO'S SOURCE140
sigil test # does NOT — see below141
```143
**`sigil test` in this repo does not read `src/sigil/log.sgl`.** The `sigil`144
binary embeds its own copy of `(sigil log)`, and that copy wins over the145
project's source, over its built `.sgb`, and over an explicit `from-path`146
redirect. Appending a syntax error to the library and re-running `sigil test`147
produces an identical result, which is how this was established.149
`test/gate-source-under-test.sh` routes around it: it generates a copy of the150
library renamed to a module name the binary does not embed, asserts the copy151
differs from the original by exactly that one line, and runs every suite —152
including the arms under `test/uut/` — against it. Those arms are deliberately153
named so `sigil test` does not discover them, because against the bundled copy154
they would be permanently and uninformatively red.156
The same shadowing means a released fix here does not reach any consumer until157
a `sigil` CLI is rebuilt carrying it.159
## License161
BSD-3-Clause.