Inside
The rules this library follows are not arbitrary, and the ones that look arbitrary have a reason written down here. Every claim on this page is something a test in the repository checks.
The dispatch order
One call to log.info(…) becomes a record, and that record
is offered to places in a fixed order. Nothing later can undo what an
earlier step decided, and nothing earlier can see what a slot does.
Three details are worth stating because they are easy to get wrong:
- The hub's filter runs before the hub's chain, and the chain runs before any slot. A step at the hub therefore cannot widen what the filter refused.
- A slot's filter runs before its own chain. A slot that was never going to write does not pay for redaction.
- A step that changes nothing returns the record it was given. The first step that changes something returns a copy — so a slot that redacts cannot change what the slot after it sees.
The time model
There is one unit: nanoseconds since the epoch, in an Int64.
A record's time, a probe interval, a queue's wait, a
sampler's window and an alarm's reminder are all the same number, so a
duration is never converted twice and a negative one is never
accidentally a positive one.
| Quantity | Nanoseconds | Written as |
|---|---|---|
| probe | 5 000 000 000 | 5_000_000_000L |
| longest wait | 300 000 000 000 | five minutes, the cap |
| reminder | 3 600 000 000 000 | one hour |
| queue wait | 1 000 000 000 | one second |
| sampler window | 60 000 000 000 | one minute |
Local time is a shift, not a time zone database: an identity carries
offset : (Int64) -> Int, which answers the seconds east of
UTC at an instant. A program that knows its zone — or reads
TZ once at start-up — supplies it; a test supplies
fn(_) { 0 } and gets UTC. Day boundaries, DST and leap
seconds are the caller's business, which is the only place they can be
done correctly.
The health machine
Each slot owns a Breaker. It has three states and five
transitions, it reads no clock, and it does no I/O — which is what
makes it checkable.
| State | A record arriving | A write |
|---|---|---|
| up | written | failure moves to down |
| down, before the probe | counted as skipped, not written | — |
| down, at the probe | moves to probing, written | — |
| probing | nothing else is admitted | success: up, wait reset, resolved when told. Failure: down, wait doubled |
The wait doubles on each failed probe and stops at five minutes; after
a recovery it starts again from the probe interval. A failure that has
been told once is not told again until the alarm's reminder has passed,
and a failure that was folded into another sink's incident still
reports its own recovery. The eight cases in fail.json
pin exactly that, including the fractional-second one.
The alarm walk
An alarm answers three questions: which tiers of names to try, what to
do when none of them takes the record, and how long to stay quiet. The
walk itself is not the alarm's — it belongs to sound, so
every alarm gets the same protection against loops and storms.
let tried = [incident.sink] // the failing sink is never asked for tier in alarm.route(incident) { let names = tier .expand("*" -> desk.logs()) // the log route .without(tried) // asked already .without(desk.resting) // down, not yet due if names.is_empty() { continue } let heard = names.any { desk.deliver(it, incident.describe()) } tried += names if heard { return Heard } } return Unheard // then the alarm's fallback
Two rules keep this from feeding itself. A sink that fails while an alarm is being delivered rides in that incident instead of raising one of its own, and records made while a sink is running are suppressed and counted rather than dispatched.
How a line is made
A pattern compiles once into pieces: literal text and holes. Rendering walks the pieces; each hole writes into a scratch buffer, which is then truncated and padded — never the other way round, so a column is never cut in the middle of its padding.
{time:%H:%M:%S%.3f} {level:<5} {target:~24}: {msg}{fields}
// becomes
[ Time(Custom("%H:%M:%S%.3f"))
, Text(" ")
, Level(lower=false) fit(<, 5)
, Text(" ")
, Target(fit=24) fit() // ~ shortens before padding
, Text(": ")
, Msg(quoted=false) fit()
, Fields(quoted=false) fit()
]
Widths count characters, not bytes, so a target with non-ASCII in it lines up. Waiting on the level is a colour decision made at the slot, not in the pattern: the same pattern writes plain bytes to a file and escapes to a terminal.
What is pinned
Three files of cases say what this model does, step by step. They are reproduced as tests in this repository, so a change that alters one of them has to alter a test in the same commit.
| Cases | Pins | Here |
|---|---|---|
| render | patterns, presets, logfmt, pretty, the serlog document and nine other schemas, and the filter rules | 10 tests |
| fail | the health machine, step by step, with the phase each step owes | 9 tests |
| alarm | the walk: stars, resting sinks, repeats, and what an incident carries | 8 tests |
The rest of the suite covers what the cases do not: the queue and what a full one drops, the logger's bound fields, a sink of one's own, redaction by path, the token bucket, deduplication, a sink that logs into its own hub, and a hub whose sink is down while another reads the incident. Fifty-eight in all, on four backends.
Reading the code
| File | What lives there |
|---|---|
| src/level.mbt | the six levels, labels per profile, the three numberings |
| src/value.mbt | typed values, their text and JSON forms, quoting |
| src/record.mbt | the event, the caller, the identity, and the copy-on-change helpers |
| src/filter.mbt | directives, target matching, wildcards, the message expression |
| src/pattern.mbt | compiling a template, shortening a target, fitting a column |
| src/layout.mbt | the layout trait, logfmt, pretty |
| src/json.mbt | ten schemas, renamed keys, nested fields |
| src/processor.mbt | the step trait and the eight built-ins |
| src/sink.mbt | the sink trait, entries, and the three sinks that ship |
| src/breaker.mbt | the health machine |
| src/slot.mbt | a sink with its settings, queue and retries |
| src/alarm.mbt | incidents, tiers and the walk |
| src/hub.mbt | dispatch, registration, status |
| src/logger.mbt | the value a library is handed |
| src/time.mbt | civil dates, strftime, and the formats each schema wants |