sysl

Module

sysl.log

Structured logging: a level, a message, a few named fields, and somewhere for them to go.

Import import sysl.log

Every server wants one and every package writes one, which is the argument for it being here rather than in a package: a program that depends on three libraries should not link three loggers and configure them separately. Go, Python and Rust all ship the interface in the standard library for the same reason, and leave the interesting sinks outside it.

The shape

A Record is what happened: when, how bad, what about, and a handful of named strings. A Sink is where it goes – one method, taking the record by pointer. text and json are two renderings, each writing into a Writer, and a sink is what chooses between them; message_text is the third, for a destination that stamps its own time and level. That is the whole of the module; everything else people want from a logger is a sink somebody writes.

info("listening", [("port", "8080"), ("tls", "on")])

What it costs, and where

A record holds strings the caller rendered, which is the one decision here that shows up at every call site. The alternative – a list of &Display – would put a trait object in every record and an allocation behind every field. What a caller writes instead is ("port", str(port)), which does allocate, and the cost is paid where the value is actually wanted.

A filtered call still evaluates its arguments, and that is the one thing to know before putting a debug in a loop. sysl evaluates a call’s arguments before the call, so the str above runs whether or not the threshold admits the record – log compares the level and returns without building a Record, and it never sees the work that was done to reach it. enabled is the guard for that case and is why it exists:

if enabled(Level.Debug) then debug("state", [("queue", str(queue.len()))])

A call whose fields are literals, or one with no fields at all, costs nothing when filtered.

The time is an Instant, which is a count of microseconds and nothing else. Rendering it as a date is the calendar’s job and text is the only thing here that asks for one, so a program whose sink calls json links no calendar code at all – the millisecond count is arithmetic.

log_at takes the instant and log reads the clock, which is what makes the module usable on a board with no clock at all: sysl.time.now reaches a seam the application supplies, and a program that has nothing to supply calls log_at with whatever its own counter says.

What is deliberately not here

Child loggers, contexts, rotation, colours, asynchronous delivery. Each is a sink, and each is a policy somebody disagrees with. A sink is one method; writing the one you want is a dozen lines and does not require this module to have guessed.

A formatting language. The message is a string and the fields are strings; a caller wanting interpolation has f"..." and knows what it costs.

Index

clear_sink debug enabled error info json json_sink level log log_at message_text set_level set_sink stderr_sink text warn writer_sink Level Record WriterSink Field Sink Sink for WriterSink

Functions

clear_sink

clear_sink()

Records go back to where they went before anything was installed – standard error on a hosted target, and nowhere at all on one with no operating system.

debug

debug(message: string, fields: []const Field)

What a program is doing, in detail nobody wants by default.

enabled

enabled(l: Level) -> bool

Whether a record at l would be written, which is the guard a caller in a hot path puts in front of the work of rendering its fields.

if enabled(Level.Debug) then debug("state", [("queue", str(queue.len()))])

error

error(message: string, fields: []const Field)

Something that did not work.

info

info(message: string, fields: []const Field)

Something worth knowing that is not a problem.

json

json(r: *Record, out: *Writer)

One record, one JSON object, meant for a machine.

{"time":1757000551000,"level":"info","message":"listening","fields":{"port":"8080"}}

The time is epoch milliseconds, which is what every log pipeline reads and is arithmetic rather than a calendar – so this rendering pulls in no date code at all. The fields are an object rather than an array of pairs, because that is what a query language expects; a repeated key is therefore the caller’s problem, exactly as it is in every other logger.

The level is lowercase, which is the convention every JSON logger follows.

json_sink

json_sink(out: *Writer) -> WriterSink

A sink writing the JSON rendering into out, which is what a program feeding a log pipeline installs.

level

level() -> Level

What the threshold is now.

log

log(l: Level, message: string, fields: []const Field)

A record at l, timestamped now.

log_at

log_at(t: Instant, l: Level, message: string, fields: []const Field)

A record at l, timestamped t, delivered if the threshold admits it.

This is the one to call where there is no clock, and log is this with sysl.time.now() in front of it. A freestanding program that has not supplied the clock seam links this and nothing from sysl.time but the Instant type.

message_text

message_text(r: *Record, out: *Writer)

The message and its fields alone, quoted exactly as text quotes them, with no time, no level and no newline.

underrun frames=512

This is the rendering for a destination that stamps its own time and level – Android’s logcat, syslog, the systemd journal – where text would write both a second time inside an entry that already shows them. Such a destination frames each entry itself, which is why there is no newline either.

Nothing here reaches the calendar, so a sink that calls only this links no date code. An empty message with fields starts at the first field rather than at a space.

set_level

set_level(l: Level)

Records below this level are discarded before anything is built. Level.Info to begin with, which is what a program that has said nothing about it wants.

The threshold is an atomic rather than something the lock protects, which is the one place this module cares about the difference. A filtered call reads it and returns, so putting it under the lock would make a debug in a hot loop – the exact call the threshold exists to make cheap – cost an uncontended lock and, worse, serialize against every other thread doing the same. A relaxed load is one instruction and the only thing a program gives up is knowing whether a call already in flight when set_level ran used the old value or the new one, which nothing can usefully depend on.

It is held as the level’s position rather than as a Level, since that is what an atomic operates on and the ordering of the enum is the ordering being compared.

set_sink

set_sink(s: *Sink)

Where records go from now on.

The pointer is borrowed and the caller keeps the sink alive. That is a raw pointer rather than a counted box on purpose: a box needs an allocator, and this module is meant to work where there is none. The sink a program installs is nearly always module storage or something that lives as long as main, which is exactly the case where a count would be paid for nothing.

Passing a pointer to something that then goes out of scope is the one way to misuse this, and it is the same shape as any other borrowed pointer in the language.

stderr_sink

stderr_sink() -> WriterSink

The default, as a sink a program can install explicitly – which is what it wants after having installed something else and wanting the default back with a different rendering.

clear_sink reaches the same output without going through a sink at all, so a program that only wants the default never names this.

text

text(r: *Record, out: *Writer)

One record, one line, meant for a person: the time, the level, the message, then the fields.

2026-09-04 11:02:31 Z INFO  listening port=8080 tls=on

This is the rendering that reaches the calendar, through sysl.time‘s own Display for an Instant. A sink that only ever calls json links none of it.

A value with a space in it is quoted, and one without is not – which keeps the common line readable and keeps a value with a space in it parseable, which is the whole of what a logfmt-like format promises.

warn

warn(message: string, fields: []const Field)

Something that will be a problem if it keeps happening.

writer_sink

writer_sink(out: *Writer) -> WriterSink

A sink writing the human rendering into out.

Types

Level

enum Level
    Debug
    Info
    Warn
    Error

How bad it is. The order is the severity order, which is what the threshold compares on – so a level added between two of these would change what an existing threshold admits.

Record

struct Record
    time: Instant
    level: Level
    message: string
    fields: []const Field

Something that happened, as the sink is handed it.

The fields are a view rather than a copy, so a caller may build them in an array on its own stack and the record borrows them for the length of the call. A sink that wants to keep a record has to copy what it needs, which is the same promise Writer.write makes about its bytes and for the same reason.

WriterSink

struct WriterSink
    private out: *Writer
    private as_json: bool

A sink over any Writer, rendering each record with one of the two renderings.

This is the sink nearly every program wants, and it is here because it is the one that composes with everything else the library already has: a file is a Writer, standard error is a Writer, a buffer is a Writer, and a UART on a board is a Writer the moment somebody has written one. What is left for a program to write itself is a sink that does something other than render a record and hand it to a stream – batching, filtering, sending it somewhere.

It holds a borrowed pointer, so whatever it writes into has to outlive it, exactly as set_sink says of the sink itself.

Aliases

Field

type Field = (string, string)

One named string on a record. A pair rather than a struct, because that is all it is and a tuple of two strings already compares, orders, hashes and renders.

Traits

Sink

trait Sink
    write(*self, r: *Record)

Where a record goes.

One method, taking the record by pointer so that nothing is copied and a sink may be written under no alloc. A UART sink is a dozen lines:

struct Uart
end Uart

impl Sink for Uart
    write(*self, r: *Record) = json(r, uart_writer())
MemberSignatureDescription
writewrite(*self, r: *Record)

Implementations

Sink for WriterSink

impl Sink for WriterSink

Search

Esc
↑↓ to navigate ↵ to open Esc to close