Sign in

    moon-lander-logging

    Structured logging with a bring-your-own logger trait for the Moon-Lander framework

    Download zip
    Author
    Version
    0.7.0
    License
    Apache-2.0
    Last updated
    7 minutes ago
    Downloads
    201

    No dependencies

    #moonrockz/moon-lander-logging

    Structured logging for the Moon-Lander framework, in the style of .NET Microsoft.Extensions.Logging and Serilog. Library code writes events through one trait, Logger, and the application brings the backend that receives them. The module imports nothing except the MoonBit core library, so a library can depend on the logging abstraction and on nothing else. It has the same API on wasm, wasm-gc, js and native, and does no I/O.

    Import the root package with an explicit alias, because the module name has - in it:

    ///|
    import {
    "moonrockz/moon-lander-logging" @logging,
    }

    #The parts

    PartWhat it is
    LoggerThe trait that a backend implements. log gets each event; enabled filters levels and enables each level by default
    LevelTrace, Debug, Information, Warning, Error, Critical, from the lowest to the highest
    LogEventOne immutable event: level, category, template, properties and error
    MessageSegmentOne part of the message: literal Text or a bound Value with its JSON type
    Level methodstrace, debug, info, warn, error and critical on a &Logger
    MemoryLoggerA logger that keeps each event, for tests and examples

    #Bring your own logger

    A backend is one type with one function. This one prints each event with println. A real backend can write to standard error, a file or a service; the pure module leaves that I/O to the application or the host:

    ///|
    struct PrintLogger {}

    ///|
    impl @logging.Logger for PrintLogger with log(_self, event) {
    println(event.to_line())
    }

    ///|
    test "bring your own logger" {
    let logger : &@logging.Logger = PrintLogger::{ }
    logger.info("Build started for {Target}", ["wasm"])
    }

    This prints [INF] Build started for wasm. to_line gives the three-letter level code (TRC, DBG, INF, WRN, ERR or CRT), the category and a colon when the event has one, and the message text. An error goes on a second line as error: <text>, with the Show text of the error.

    Library code takes a &Logger and does not know the backend. A test gives it a MemoryLogger and reads the events back:

    ///|
    fn build(logger : &@logging.Logger) -> Unit {
    logger.info("Build started", [])
    logger.warn("Cache is empty", [])
    }

    ///|
    test "read the events of a library function" {
    let memory = @logging.MemoryLogger::new()
    build(memory)
    let levels = memory.events().map(event => event.level)
    assert_eq(levels, [Information, Warning])
    }

    #Message templates

    A message template names its values, as in Serilog. The arguments bind to the holes in order, and the event keeps each value as Json data, not as text. A backend can write the properties as fields, and message renders the text when a backend wants it:

    ///|
    test "a template keeps its values as data" {
    let memory = @logging.MemoryLogger::new()
    let logger : &@logging.Logger = memory
    let path = "src"
    logger.info("Loaded {Count} files from {Path}", [3, Json(path)])
    let event = memory.events()[0]
    assert_eq(event.template, "Loaded {Count} files from {Path}")
    assert_eq(event.properties(), [("Count", 3), ("Path", "src")])
    assert_eq(event.message(), "Loaded 3 files from src")
    }

    A literal is a Json value as it is; another value goes in Json(value). A type with derive(ToJson) becomes a JSON object, so its fields stay separate.

    Template partMeaning
    {Name}A hole. The name holds ASCII letters, digits and _
    {{ and }}A literal { and a literal }
    Any other braceLiteral text, for example an unclosed {Name or an empty {}

    In the message text, a JSON string shows without quotes, and each other value shows as compact JSON, for example {"id":1}. A mismatch never fails and never drops the event:

    CaseResult
    A hole has no argumentNo property; the message keeps the hole text, for example {Target}
    An argument has no holeThe argument is dropped
    One name in two holesEach hole takes its own argument; the message shows the first value in both

    #Message segments

    A renderer that shows values in a different style needs to know which part of the message is literal text and which part is a value. segments gives the message as ordered parts, from the same template parse as message:

    SegmentWhat it holds
    Text(text)Literal text. A hole with no argument stays here as its text, for example {Target}
    Value(name~, value~)A bound hole: its name and its JSON value

    The text of each segment, in order, is equal to the message. A repeated name shows the first value in each of its holes, as in message. The result is a copy, so a change to it does not change the event:

    ///|
    test "the segments of a message" {
    let memory = @logging.MemoryLogger::new()
    (memory : &@logging.Logger).info("Loaded {Count} files from {Path}", [
    3, "src",
    ])
    let event = memory.events()[0]
    let segments = event.segments()
    assert_eq(segments, [
    Text("Loaded "),
    Value(name="Count", value=3),
    Text(" files from "),
    Value(name="Path", value="src"),
    ])
    let text = segments.map(segment => segment.text()).join("")
    assert_eq(text, event.message())
    }

    The formatting package uses the segments to render console text, readable files and JSON Lines. See its guide in formatting/README.mbt.md.

    #Built-in loggers and combinators

    FunctionResult
    null_logger()Enables no level and drops each event
    MemoryLogger::new()Keeps each event; events() gives a copy
    min_level(logger, level)Passes only the events at level or higher that logger enables
    fan_out(loggers)Gives each event to each logger in the list that enables its level
    with_category(logger, name)Sets the category name on each event

    Each combinator takes the logger first, so a chain reads from the backend to the code that writes:

    ///|
    test "chain the combinators" {
    let memory = @logging.MemoryLogger::new()
    let logger = memory
    |> @logging.min_level(Information)
    |> @logging.with_category("morphir.build")
    logger.debug("Dropped", [])
    logger.info("Loaded {Count} files", [3])
    let events = memory.events()
    assert_eq(events.length(), 1)
    assert_eq(events[0].category, Some("morphir.build"))
    }

    fan_out sends one event to two backends, each with its own level:

    ///|
    test "two backends with their own levels" {
    let console = @logging.MemoryLogger::new()
    let errors = @logging.MemoryLogger::new()
    let logger = @logging.fan_out([console, @logging.min_level(errors, Error)])
    logger.warn("Disk {Name} is low", ["C"])
    assert_eq(console.events().length(), 1)
    assert_eq(errors.events().length(), 0)
    }

    #Levels and the level check

    Each level method asks the logger enabled before it builds an event. A backend that implements enabled pays only that one call for a level it does not want:

    ///|
    struct WarningsOnly {
    mut written : Int
    }

    ///|
    impl @logging.Logger for WarningsOnly with enabled(_self, level) {
    level >= Warning
    }

    ///|
    impl @logging.Logger for WarningsOnly with log(self, _event) {
    self.written += 1
    }

    ///|
    test "a disabled level builds no event" {
    let backend = WarningsOnly::{ written: 0 }
    let logger : &@logging.Logger = backend
    logger.debug("Not built", [])
    logger.error("Built", [])
    assert_eq(backend.written, 1)
    }

    A log call never fails the code that writes it: log has no error effect, and a backend that can fail handles its own failure.

    #Secrets

    The module writes only the values that a caller gives it. A configuration Secret has no ToJson, so it cannot become a property by accident, and its Show text is redacted. Do not give the text of Secret::expose to a log method: the event keeps it, and each backend can write it. An error goes into a line through its Show text, so the error type decides what the line holds.

    Logger

    pub(open) trait Logger {
    fn enabled(Self, Level) -> Bool = _
    fn log(Self, LogEvent) -> Unit
    }

    A receiver of log events: the bring-your-own seam of the logging module.

    An application or an adapter implements log to send events to any backend. Library code takes a &Logger value and does not know the backend. The level methods (info, warn and the others) ask enabled first, and build no event for a level that is not enabled. The default enabled enables each level.

    log has no error effect. A backend that can fail handles its own failure, so a log call never fails the code that writes it.

    Level

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

    The severity of a log event, from the lowest to the highest, with the names of Microsoft.Extensions.Logging. Levels compare in this order.
    impl Show for Level

    Level::compare

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

    Level::equal

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

    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::to_repr

    Level::to_string

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

    LogEvent

    pub struct LogEvent {
    level : Level
    category : String?
    template : String
    error : Error?
    // private fields
    }

    One immutable log event. Code outside this package reads the fields and the properties, but only the level methods of Logger and the combinators of this package make events.

    LogEvent::message

    fn LogEvent::message(self : LogEvent) -> String

    The message text: the template with each hole replaced by the text of its property value. A hole whose name has no property stays as its literal text. When a name occurs in more than one hole, each of them shows the first value.

    LogEvent::properties

    fn LogEvent::properties(self : LogEvent) -> Array[(String, Json)]

    The values of the template holes as structured data, in hole order. The result is a copy: a change to it, or to a JSON array or object in it, does not change the event.

    LogEvent::property

    fn LogEvent::property(self : LogEvent, name : String) -> Json?

    A copy of the value of the property name, or None when the event has no such property. When the name occurs more than one time, the first value is the result.

    LogEvent::segments

    fn LogEvent::segments(self : LogEvent) -> Array[MessageSegment]

    The message as ordered segments. Two literal parts next to each other join into one Text segment. The result is a copy: a change to it, or to a JSON array or object in it, does not change the event.

    LogEvent::to_line

    fn LogEvent::to_line(self : LogEvent) -> String

    The event as text: the level code in brackets, the category and a colon when the event has one, and the message text, for example [INF] morphir.build: Loaded 3 files from src. When the event has an error, a second line error: <text> gives the Show text of the error, so the error type decides what the text holds.

    MemoryLogger

    pub struct MemoryLogger {
    // private fields
    }

    A logger that keeps each event it gets, in the order it gets them. It is for tests and examples: it holds all events in memory and never drops one.

    MemoryLogger::enabled

    fn MemoryLogger::enabled(_self : MemoryLogger, _level : Level) -> Bool

    MemoryLogger::events

    fn MemoryLogger::events(self : MemoryLogger) -> Array[LogEvent]

    A copy of the kept events, the first event first.

    MemoryLogger::log

    fn MemoryLogger::log(self : MemoryLogger, event : LogEvent) -> Unit

    MemoryLogger::new

    A new in-memory logger with no events.

    MessageSegment

    pub(all) enum MessageSegment {
    Text(String)
    Value(name~ : String, value~ : Json)
    } derive(Eq,
    Debug
    )

    One ordered part of the message text of an event. A renderer uses the segments to show the literal text and the bound values in different styles. The text of all segments, in order, is equal to LogEvent::message.

    MessageSegment::equal

    MessageSegment::not_equal

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

    MessageSegment::text

    fn MessageSegment::text(self : MessageSegment) -> String

    The text of the segment in the message: the literal text, or the value as message shows it. A JSON string has no quotes, and each other value is compact JSON.

    fan_out

    fn fan_out(loggers : Array[&Logger]) -> &Logger

    A logger that sends each event to each of loggers, in list order. It enables a level when one of loggers enables it, and gives each event only to the loggers that enable its level. Later changes to the loggers array do not change the fan-out.

    min_level

    fn min_level(logger : &Logger, minimum : Level) -> &Logger

    Wrap logger with a minimum level. The wrapper enables a level only when the level is minimum or higher and logger enables it, and passes only the events of an enabled level to logger.

    null_logger

    fn null_logger() -> &Logger

    A logger that enables no level and drops each event.

    with_category

    fn with_category(logger : &Logger, name : String) -> &Logger

    Wrap logger with the category name, for example morphir.build. The wrapper sets that category on each event and does not change the other fields.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io