std/log
Leveled, structured logging to a redirectable sink.
from "std/log" import { Log, LogFormat, LogLevel, Logger }Log.setLevel(LogLevel.Debug)
Log.info("server starting")
Log.str("path", p).int("bytes", n).warn("upload retried")
let http = Logger.new("http")
http.int("status", 503).error("upstream refused")2026-08-04T05:11:00.792Z INFO server starting
2026-08-04T05:11:00.792Z WARN upload retried path=/var/tmp/a.bin bytes=4096
2026-08-04T05:11:00.793Z ERROR [http] upstream refused status=503Levels
LogLevel is ordered Debug < Info < Warn < Error < Off. Records below the threshold are discarded. The default threshold is Info, so debug records need Log.setLevel(LogLevel.Debug) to appear. LogLevel.Off is a threshold only — it silences everything and is never a record level.
| Function | Meaning |
|---|---|
Log.setLevel(level) | Set the process-wide threshold |
Log.level() | Read the threshold |
Log.isEnabled(level) | Would a record at level be emitted? |
Log.debug/info/warn/error check the threshold themselves, but only after their arguments are evaluated. Guard an expensive message with Log.isEnabled first.
Fields
A record is a message plus ordered key/value pairs. str, int, float, and bool start a record on Log or a Logger and chain; a level method consumes it and emits.
Log.str("path", path).int("bytes", n).bool("cached", hit).float("secs", t).info("served")Text output leaves values bare unless they would be ambiguous (empty, or containing a space, quote, backslash, =, or a control byte), in which case they are quoted and escaped. Integers and booleans are always bare. A NaN or infinite float is quoted, because JSON has no literal for it.
Named loggers
Logger.new(name) tags every record with a subsystem name — [name] in text, "logger" in JSON. Filtering stays process-wide: a Logger is a label, not an independently configurable channel.
Formats
Log.setFormat(LogFormat.Json) emits one JSON object per line:
{"time":"2026-08-04T05:11:00.792Z","level":"WARN","logger":"http","msg":"slow","ms":1200}Log.setTimestamps(false) drops the timestamp — for a sink that stamps lines itself (journald, a container runtime) or when output must be reproducible.
Sinks
| Function | Effect |
|---|---|
Log.setSinkFd(fd) | Write to an already-open descriptor. It stays yours to close, and must outlive the last record. Default is 2 (stderr) |
Log.setSinkPath(path) | Open path for append and own it until the sink is replaced. Returns Result<Unit, IoError> |
Records go straight at the descriptor, but stdio is flushed first, so a sink on fd 1 interleaves correctly with print.
Concurrency
Milo has no mutex, and std/log does not serialize writers. What it guarantees instead is that a record renders to one complete line and reaches the sink through exactly one write(2). POSIX makes a single write to a pipe or terminal atomic up to PIPE_BUF (4096 bytes), and a single write to an O_APPEND file atomic at any size. Records from concurrent tasks therefore interleave whole, in some order; only a record longer than PIPE_BUF on a pipe can tear.
Configuration is process-wide mutable state meant to be set once at startup. Reconfiguring while other tasks log cannot corrupt anything — each setting is a single machine word — but concurrent readers may observe the old value for an unbounded time, and a setSinkPath that closes the previous owned descriptor can race a write already in flight. Configure before you spawn.