≡ moonlog 0.1.1

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.

Logger target + fields Record instant + seq Hub filter level · target Hub chain may drop it for each slot, in the order it was added slot filter slot chain layout queue? sink dropped at any step: counted for that slot, and nothing is written for it written: the breaker records a pass · failed: it records a fault breaker: up · down incident opened alarm: walk tiers
The hub decides once; each slot then decides for itself. A record that no slot takes costs one pass over the table.

Three details are worth stating because they are easy to get wrong:

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.

QuantityNanosecondsWritten as
probe5 000 000 0005_000_000_000L
longest wait300 000 000 000five minutes, the cap
reminder3 600 000 000 000one hour
queue wait1 000 000 000one second
sampler window60 000 000 000one 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.

StateA record arrivingA write
upwrittenfailure moves to down
down, before the probecounted as skipped, not written—
down, at the probemoves to probing, written—
probingnothing else is admittedsuccess: 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.

the walk, in full
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.

the pattern, compiled
{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.

CasesPinsHere
renderpatterns, presets, logfmt, pretty, the serlog document and nine other schemas, and the filter rules10 tests
failthe health machine, step by step, with the phase each step owes9 tests
alarmthe walk: stars, resting sinks, repeats, and what an incident carries8 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

FileWhat lives there
src/level.mbtthe six levels, labels per profile, the three numberings
src/value.mbttyped values, their text and JSON forms, quoting
src/record.mbtthe event, the caller, the identity, and the copy-on-change helpers
src/filter.mbtdirectives, target matching, wildcards, the message expression
src/pattern.mbtcompiling a template, shortening a target, fitting a column
src/layout.mbtthe layout trait, logfmt, pretty
src/json.mbtten schemas, renamed keys, nested fields
src/processor.mbtthe step trait and the eight built-ins
src/sink.mbtthe sink trait, entries, and the three sinks that ship
src/breaker.mbtthe health machine
src/slot.mbta sink with its settings, queue and retries
src/alarm.mbtincidents, tiers and the walk
src/hub.mbtdispatch, registration, status
src/logger.mbtthe value a library is handed
src/time.mbtcivil dates, strftime, and the formats each schema wants