≡ moonlog 0.1.1
MoonBit · Apache-2.0 · no clock, no global

Every line is
a record.

moonlog keeps a log entry typed until it is written: a level, a target, a message and ordered fields, filtered once, shaped by a layout, and handed to a sink that is allowed to fail.

$ moon add moonbitstack/moonlog
enclosure gateway · prod · g-3
040INFO22:13:20 gateway::http GET /mirror took_ms=18
041DEBUG22:13:20 mirror::sync scanned 412 files files=412
042WARN22:13:21 mirror::sync slow upstream took_ms=1200
043INFO22:13:21 gateway::health probe n=17
044ERROR22:13:22 mirror::sync disk 91% full free_gb=1.5
045DEBUG22:13:22 gateway::pool acquired conn=c-31
046INFO22:13:23 gateway::http PUT /mirror bytes=1M
047TRACE22:13:23 mirror::sync hashing part="Packages"
048INFO22:13:24 mirror::sync ok mirrored=debian took_ms=842
01

One record, four ways

one entry, written for a terminal, a shell pipeline or a document
Pattern
// {time:%H:%M:%S%.3f} {level:<5} {target}: {msg}{fields}
22:13:20.412 WARN  mirror::sync: disk 91% full mirror=debian free_gb=1.5
Logfmt
time=2023-11-14T22:13:20Z level=warn target=mirror::sync msg="disk 91% full" mirror=debian free_gb=1.5
Pretty
2023-11-14 22:13:20.412 WARN  mirror::sync disk 91% full
    mirror  = debian
    free_gb = 1.5
JSON · serlog
{
  "time": "2023-11-14T22:13:20.412Z",
  "level": "warn",
  "target": "mirror::sync",
  "msg": "disk 91% full",
  "mirror": "debian",
  "free_gb": 1.5,
  "app": "gateway", "env": "prod"
}
02

The parts

twelve names, each doing one thing, each a value you pass in

01Level

A number, a name and a colour. Six are built in; a seventh is a value you construct.

Level::parse · ::label · ::at_least

02Value

Fields keep their type — null, bool, int, float, string, list, map, error — until a layout writes them.

Value::of_int · ::of_map · ::encode

03Record

One event: level, target, message, ordered fields, tags, caller, thread, instant, sequence number.

Record::of · ::set · ::stamped

04Filter

warn,app=debug,web:*=error. The most specific directive wins; a /regex narrows by message.

Filter::parse · ::allows · ::lowest

05Processor

Eight steps: redact, sanitise, fields, match, exclude, sample, limit, dedupe. A step may drop the record.

Processor::process(record, env)

06Layout

A pattern, logfmt, a pretty block, or one of ten JSON schemas with keys renamed and fields nested.

Pattern · Logfmt · Pretty · JsonLine

07Sink

One method: take an entry, answer Ok or say what went wrong. Nothing else is required.

Sink::write(entry) → Result

08Slot

One sink with its filter, steps, layout, colour, queue, retries, probe and failure policy around it.

Slot::new(sink) · ::at · ::queue

09Breaker

Up, down or probing. It counts what it skipped, remembers what failed and waits before trying again.

Breaker::admit · ::fault · ::pass

10Alarm

Tiers of sinks to try when one fails. * is every sink on the route; one success ends the walk.

Tiers::from([[…], […]])

11Hub

The filter, the global chain, the slot table and the clock. Built by the application, held by nobody else.

Hub::new(clock~) · ::add · ::logger

12Logger

A target and the fields bound to it. Passing one is how a library logs without knowing where it goes.

Logger::info · ::bound · ::enabled
03

No clock, no global

two absences that decide the shape of everything else

Time is a parameter

The hub asks the clock it was given. A record carries the instant it was told, in nanoseconds; the health machine and the steps that sample, limit or dedupe take it as an argument. A test says which instant it means, and the same code runs at a different speed without changing.

Hub::new(clock=fn() { 1_700_000_000_000_000_000L })

Logging is a value

There is no process-wide hub to configure and no default logger to inherit. A module takes a Logger as a parameter and writes through it; handed none, it writes nothing. Two hubs can write the same file in two formats and never meet.

fn handle(log : @moonlog.Logger, req : Request)
main.mbt
let sink = @moonlog.Memory::new()
let hub = @moonlog.Hub::new(
  identity=@moonlog.Identity::of(app="gateway", env="prod"),
  clock=fn() { 1_700_000_000_000_000_000L },
)
let _ = hub.add(
  @moonlog.Slot::new(sink, name="console")
  .layout(@moonlog.Pattern::parse("{time:%H:%M:%S%.3f} {level:<5} {target}: {msg}{fields}")),
)

let log = hub.logger("mirror::sync")
log.info("starting", fields=[("workers", @moonlog.Value::of_int(4))])
// 22:13:20.000 INFO  mirror::sync: starting workers=4
04

Failure is a state

a sink that is down is skipped, not retried in the caller's face

A write that fails moves its slot's breaker. While the breaker says down, records for that slot are counted and dropped until its next probe; a probe is one write deciding. Ten failures in a row back off to five minutes, and a success puts everything back.

up writes through down skips, counts probing a single write write failed failed again probe due · gap doubles, up to 5 min write went through · resolved
Up → down → probing → up. The machine reads no clock: every transition takes the instant it happens at, so a failure lasting an hour is a loop in a test, not a wait.

What the caller sees

Nothing. The record is written to the sinks that are up, and the one that is down costs a count instead of a failed request.

What a person hears

The first failure opens an incident and an alarm walks its tiers: the other sinks, then the fallback. A reminder follows while it is still down; a resolution follows when it is not.

What a pipeline reads

The incident is a record like any other — level, target, message, fields — so it reaches the same dashboards without a second alerting system.

05

Written for the tools you already run

seven presets and ten schemas, each pinned by a test
SchemaForShape
serlogthe defaulttime, level, target, msg, file, line, fields, app, env, instance
pinopinolevel by number, Unix ms, pid, hostname, fields, msg
bunyanbunyanname, hostname, pid, level, fields, msg, time, v
zapzap's production encoderlevel, ts, logger, caller, msg, fields
slogGo's JSONHandlertime local with nanoseconds, slog's level labels
structlogstructlog's JSON rendererevent, fields, level, logger, timestamp
logstashlogstash-logback-encoder@timestamp, @version, level, level_value, fields
ecsElastic Common Schemadotted keys: log.level, service.name, process.pid
gelfGraylogshort_message, syslog severity, fields prefixed _
otelOpenTelemetry logsseverity_number, body, attributes, resource
06

Out to the world

six subpackages for the parts that touch a console, a disk or a socket

01stdio

One console on stdout, one on stderr, or one that picks per record. Colour is decided once, from FORCE_COLOR, NO_COLOR and the terminal.

Console::new · ::slot · ::target

02file

A file that opens on its first line, cuts itself by size or by age, packs a segment as it goes, and keeps what a policy allows.

File::new · Rotation · Limits

03loki

Entries queue in the log call and ship from the async loop you already run: one stream per level, fields as structured metadata.

Loki::new · ::push · ::body

04mail

A burst becomes one letter: a level floor, a window, a ceiling on records, a text part and a table in HTML.

Mail::new · ::send · ::draft

05config

YAML, JSON and TOML into one tree, every key checked against what its sink reads, a typo refused with its path.

Config::parse · ::load · ::dump

06global

The one process-wide hub, for applications only: SERLOG_FILTER, a zero-config console, and a flush before the process goes.

start · logger · flush · drain