moonlog

    moonlog — structured logging for MoonBit: six levels, fields carried as values rather than text, and a sink seam. No clock, no global state, no output of its own.

    log
    logging
    structured
    moonbit
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    6 days ago
    Downloads
    137

    #moonlog

    Structured logging for MoonBit: a level, a message, fields carried as values, and a seam to write through. It writes nowhere by itself.

    let log = @moonlog.Logger::new(@moonlog.Lines::new(line => println(line)))

    log.info("listening", fields=[("port", Json::number(8000))])
    log.warn("slow response", fields=[("path", Json::string("/report"))])
    // INFO listening port=8000
    // WARNING slow response path="/report"

    // One JSON object per line, for a reader that is a machine.
    @moonlog.Lines::new(line => println(line), format=@moonlog.json_line)
    // {"level":"INFO","msg":"listening","port":8000}

    Run moon run examples/tour for the whole surface in one go.

    #What it does not do

    No clock. A record has no timestamp. Reading a clock is a side effect, and a logger that stamps the time cannot be tested for what it wrote; a sink that wants timestamps is handed a clock when it is built. The same reasoning as mooncred taking the instant it verifies against.

    No global logger. There is no process-wide state to configure and no implicit default to inherit. A logger is a value: it is passed in, and a library that was handed none logs nothing.

    No output of its own. Lines::new(write) calls write and that is all. It prints when the function prints, fills an array when the function fills an array, and reaches an embedder's own logging when the function does that.

    No formatting. A message is a String, built at the call site with string interpolation. A library that formats needs a format language, an escape for it, and a way to be wrong about the argument count.

    #Levels

    Six, ordered from most verbose to least: Trace, Debug, Info, Warning, Error, Fatal. A message is written when its level is at least the logger's.

    They are the union of what the libraries here already had, named as they named them, so migrating is not also a rename. Critical and Fatal are one level under two names and Fatal is the one kept; Panic, which etcd's logger also aborts on, is not a level but something the caller does after logging at Fatal. Level::parse accepts the other spellings — warn, err, critical — so a configuration written for another library keeps working, and answers None for a name it does not know rather than falling back to a verbosity nobody asked for.

    Silence is not a level. Logger::silent() answers false from enabled at every level, so a caller that only builds an expensive message when it will be used builds none at all. A logger at Fatal would still say yes to Fatal.

    #Configuration

    SettingDefaultWhy that one
    levelInfoWhat every library here defaulted to, and what Python's logging, Go's slog and go-zero settle on
    format on LineslineLEVEL message key=value, which is what a person reads. json_line is the one a machine reads
    level_key / message_keylevel / msgWhat go-zero, zap and slog name them, so a line drops into a pipeline built for one of those. object(record, level~, message~) renames them for a pipeline that wants severity

    Fields are (String, Json). Json is a builtin, so carrying values rather than pre-formatted text costs no dependency and lets the sink decide how they read — the text sink writes each as its JSON, so a field with a space in it cannot be mistaken for two fields.

    A field named level or msg replaces the one the object writes rather than appearing twice: an object with a key twice is a document readers disagree about, and the caller naming it meant it.

    #What is checked

    Every level against the filter in both directions; that at answers a new logger rather than turning down the one everyone else is holding; that silence answers no at all six levels and that the same logger with somewhere to write would have written; the exact bytes of both formats, including a field that replaces the message; every level's name parsing back, in every capitalisation, and a typo answering None; and a sink of one's own receiving the record itself.

    #Install

    moon add moonbitstack/moonlog

    #Licence

    Apache-2.0.

    Sink

    pub(open) trait Sink {
    fn write(Self, Record) -> Unit
    }

    Where records go.

    A trait rather than a function so a sink can hold state — a buffer, a file, a clock, a counter — and so an embedder can route lines into its own logging without this library knowing anything about it.

    Discard

    pub(all) struct Discard {
    }

    A sink that drops everything.
    impl Sink for Discard

    Discard::write

    fn Discard::write(_self : Discard, _record : Record) -> Unit

    Level

    pub(all) enum Level {
    Trace
    Debug
    Info
    Warning
    Error
    Fatal
    } derive(Compare, Eq,
    Debug
    )

    How severe a message is, ordered from most verbose to least.

    Six levels, which is the union of what the libraries here already had: a server's Trace through Critical and a consensus core's Debug through Fatal. Critical and Fatal are the same level under two names, so the one kept is Fatal; Panic, which etcd's logger also aborts on, is not a level but a thing the caller does after logging at Fatal.
    impl Show for Level

    Level::compare

    fn Level::compare(Level, Level) -> Int

    Level::equal

    fn Level::equal(Level, Level) -> Bool

    Level::name

    fn Level::name(self : Level) -> String

    The level's name as it appears in a line, upper case.

    Level::not_equal

    fn Level::not_equal(x : Level, y : Level) -> Bool

    Level::op_ge

    fn Level::op_ge(x : Level, y : Level) -> Bool

    Level::op_gt

    fn Level::op_gt(x : Level, y : Level) -> Bool

    Level::op_le

    fn Level::op_le(x : Level, y : Level) -> Bool

    Level::op_lt

    fn Level::op_lt(x : Level, y : Level) -> Bool

    Level::output

    fn Level::output(self : Level, logger : &Logger) -> Unit

    Level::parse

    fn Level::parse(name : StringView) -> Level?

    A level by name, however it was capitalised.

    It answers None rather than falling back to a level of its own choosing: a configuration file with a typo in it should say so, not run at a verbosity nobody asked for. The names other libraries use for the same level are accepted — warn, critical, fatal, err — so a configuration written for one of them keeps working.

    Level::rank

    fn Level::rank(self : Level) -> Int

    The level's rank, low to high. A message is written when its level is at least the logger's, which is the arrangement every logging library uses.

    Level::to_repr

    Level::to_string

    fn Level::to_string(self : Level) -> String

    Lines

    pub struct Lines {
    write : (String) -> Unit
    format : (Record) -> String
    }

    A sink that hands each line to a function.

    This is the seam: Lines::new(line => println(line)) prints, and Lines::new(line => held.push(line)) is how a test reads what was written.
    impl Sink for Lines

    Lines::new

    fn Lines::new(write : (String) -> Unit, format? : (Record) -> String) -> Lines

    A sink writing one line per record through write.

    format turns a record into that line; [line] is the default and writes LEVEL message key=value, which is what a person reads. For something a machine reads, [json_line] writes one JSON object instead.

    Lines::write

    fn Lines::write(self : Lines, record : Record) -> Unit

    Logger

    pub struct Logger {
    filter : Level?
    sink : &Sink
    }

    A logger: a level to filter by and somewhere to write.

    The filter is a Level? rather than a Level because "write nothing at all" is not a level — a message is never of severity "off" — and a silent logger has to be able to say so to [enabled], so a caller skips building a message it would only throw away.

    Logger::at

    fn Logger::at(self : Logger, level : Level) -> Logger

    The same logger at another level.

    Logger::debug

    fn Logger::debug(self : Logger, message : String, fields? : Array[(String, Json)]) -> Unit

    Write at Debug.

    Logger::enabled

    fn Logger::enabled(self : Logger, level : Level) -> Bool

    Whether a message at level would be written.

    Worth asking before assembling a message that costs something: a logger cannot skip work its caller has already done.

    Logger::error

    fn Logger::error(self : Logger, message : String, fields? : Array[(String, Json)]) -> Unit

    Write at Error.

    Logger::fatal

    fn Logger::fatal(self : Logger, message : String, fields? : Array[(String, Json)]) -> Unit

    Write at Fatal. It does not abort — what to do after the last message is the caller's to decide, and a library that ends the process takes that decision away.

    Logger::info

    fn Logger::info(self : Logger, message : String, fields? : Array[(String, Json)]) -> Unit

    Write at Info.

    Logger::level

    fn Logger::level(self : Logger) -> Level?

    The level this logger writes at, or None if it writes nothing.

    Logger::log

    fn Logger::log(self : Logger, level : Level, message : String, fields? : Array[(String, Json)]) -> Unit

    Write a message, with fields if there are any.

    Logger::new

    fn Logger::new(sink : &Sink, level? : Level) -> Logger

    A logger writing to sink at level.

    Logger::silent

    fn Logger::silent() -> Logger

    A logger that writes nothing, for an embedder that does its own reporting and for a test that would rather not have output.

    It is not a logger at level Fatal: [enabled] answers false at every level, so a caller that builds an expensive message only when it will be used builds none at all.

    Logger::trace

    fn Logger::trace(self : Logger, message : String, fields? : Array[(String, Json)]) -> Unit

    Write at Trace.

    Logger::warn

    fn Logger::warn(self : Logger, message : String, fields? : Array[(String, Json)]) -> Unit

    Write at Warning.

    Record

    pub(all) struct Record {
    level : Level
    message : String
    fields : Array[(String, Json)]
    }

    One message on its way out.

    There is no timestamp. A library has no clock — reading one is a side effect and a logger that stamps the time cannot be tested for what it wrote — so the instant, when it is wanted, comes from the sink that was handed a clock. The same reasoning as mooncred's verification taking its now.

    json_line

    fn json_line(record : Record) -> String

    One record as a single JSON object, for a reader that is a machine.

    The level and the message come first, then the fields in the order they were given. A field named level or msg replaces the one this writes rather than appearing twice: an object with a key twice is a document readers disagree about, and the caller naming it meant it.

    level

    let level : Level

    The level a logger runs at when nothing says otherwise.

    Info is what every library here defaults to, and what Python's logging, Go's slog and go-zero all settle on: a deployment wants to hear about what happened, not about every step taken to get there.

    level_key

    let level_key : String

    The key the level is written under.

    level and msg are what go-zero, zap and Go's slog all name them, so a line from here drops into a log pipeline already built for one of those.

    line

    fn line(record : Record) -> String

    A record as LEVEL message key=value key=value.

    Values are written as their JSON, so a string keeps its quotes and a field containing a space cannot be mistaken for two fields.

    message_key

    let message_key : String

    The key the message is written under.

    object

    fn object(record : Record, level? : String, message? : String) -> String

    The same, with the two keys named.

    A pipeline that expects severity and message rather than level and msg is common enough — Google Cloud Logging is one — that renaming them is a parameter rather than a reason to write your own formatter.

    Source Files