Sign in

    #Log formatting

    The formatting package (@formatting) renders log events as text for a console, for a readable log file and for JSON Lines. It is pure: it does no I/O and reads no clock. It has the same API on wasm, wasm-gc, js and native, and imports only the MoonBit core library and the logging root. A sink or a host adapter writes the text that it gives.

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

    #The parts

    PartWhat it is
    LogTimestampA UTC time in milliseconds, from 1970-01-01T00:00:00.000Z to 9999-12-31T23:59:59.999Z
    LogRecordOne captured event and its time. It does not change after capture
    ThemeLiterate (ANSI colors) or Plain (no ANSI codes)
    format_consoleOne console event, for example [14:23:07.012Z INF] app: Started
    format_textOne event of a readable log file, with the full UTC date
    format_json_lineOne JSON Lines record of schema version 1
    format_json_object_lineOne JSON Lines record of schema version 2, with named properties
    utf8_lengthThe UTF-8 bytes of a rendered record, for size limits

    #Capture a record

    A session captures each event one time, with the time at which it got the event. capture reads the message, the segments, the properties and the Show text of the error now, so each sink renders the same values:

    ///|
    test "capture an event with its time" {
    let memory = @logging.MemoryLogger::new()
    @logging.with_category(memory, "workspace.loader").info(
    "Loaded {Count} files from {Path}",
    [3, "src"],
    )
    let time = @formatting.LogTimestamp::from_epoch_millis(1793110987024L)
    let record = @formatting.LogRecord::capture(memory.events()[0], time)
    assert_eq(record.message(), "Loaded 3 files from src")
    assert_eq(record.timestamp().to_iso8601(), "2026-10-27T14:23:07.024Z")
    assert_eq(record.timestamp().time_of_day(), "14:23:07.024Z")
    assert_eq(record.timestamp().compact_date(), "20261027")
    }

    LogTimestamp::from_epoch_millis raises TimestampOutOfRange for a time out of the range. The range gives each time a four-digit year, so the text forms have one length and sort in time order. UTC is the only zone.

    #Console and readable files

    The two text layouts have fixed parts. The category and its : are not there when the event has no category:

    LayoutExample
    Console[14:23:07.012Z INF] workspace.loader: Loaded 3 files from src
    Readable file2026-10-27T14:23:07.024Z [INF] workspace.loader: Loaded 3 files from src

    The level codes are TRC, DBG, INF, WRN, ERR and CRT, as in LogEvent::to_line. Each line of the error text, and each continuation line of a message with a line break, starts with four spaces. Each rendered event ends with one LF:

    ///|
    suberror BuildError {
    BuildError(String)
    }

    ///|
    impl Show for BuildError with output(self, logger) {
    let BuildError(text) = self
    logger.write_string(text)
    }

    ///|
    test "readable text with an error" {
    let memory = @logging.MemoryLogger::new()
    @logging.with_category(memory, "build.compiler").error(
    "Compilation failed for {File}",
    ["app.mbt"],
    error=BuildError(
    "CompilerError: Unknown identifier calculate_total\n at app.mbt:42",
    ),
    )
    let time = @formatting.LogTimestamp::from_epoch_millis(1793110987041L)
    let record = @formatting.LogRecord::capture(memory.events()[0], time)
    inspect(@formatting.format_text(record), content=(
    #|2026-10-27T14:23:07.041Z [ERR] build.compiler: Compilation failed for app.mbt
    #| CompilerError: Unknown identifier calculate_total
    #| at app.mbt:42
    #|
    ))
    inspect(@formatting.format_console(record, Plain), content=(
    #|[14:23:07.041Z ERR] build.compiler: Compilation failed for app.mbt
    #| CompilerError: Unknown identifier calculate_total
    #| at app.mbt:42
    #|
    ))
    }

    #Control characters

    Caller text can hold control characters. Before it adds a style, the renderer replaces each of these characters in the category, the message and the error text with the visible text \xNN, with two lower-case hex digits:

    • Each C0 control character (U+0000 to U+001F) except LF and TAB. For example ESC is \x1b and CR is \x0d.
    • DEL (U+007F).
    • Each C1 control character (U+0080 to U+009F), which includes the one-byte CSI.

    So a value cannot send a command to a terminal, and only the renderer writes ANSI codes. A CR LF line end shows \x0d before the line break. A literal backslash in caller text stays as it is, so the text \x1b can also come from the caller.

    ///|
    test "an escape character becomes visible text" {
    let memory = @logging.MemoryLogger::new()
    (memory : &@logging.Logger).warn("Input {Value}", ["\u{1b}[2Jcleared\r"])
    let record = @formatting.LogRecord::capture(
    memory.events()[0],
    @formatting.LogTimestamp::earliest(),
    )
    inspect(@formatting.format_console(record, Plain), content=(
    #|[00:00:00.000Z WRN] Input \x1b[2Jcleared\x0d
    #|
    ))
    }

    #Themes

    Plain writes no ESC character. Literate adds ANSI SGR styles and keeps the same text: when you remove the styles from Literate output, you get the Plain output. Each styled part ends with a reset (ESC [0m), so no style continues past a line end or past the event.

    PartLiterate style
    Time, brackets and categoryBright black (90)
    Literal message textWhite (37)
    Number valueBright magenta (95)
    String valueBright cyan (96)
    Boolean valueBright blue (94)
    Null valueBlue (34)
    Array or object valueBright green (92)
    TRC / DBG / INFBright black / white / bright white
    WRNBright yellow (93)
    ERR / CRTBright white on red (97;41); CRT is also bold
    Error textBright red (91)

    The style of a value comes from its JSON type, not from the words of the message. A string value has no quotes, as in LogEvent::message. The renderer does not decide if a stream supports colors. The console sink selects the theme from its color mode (Auto, Always or Never).

    ///|
    test "a literate number value has its own style" {
    let memory = @logging.MemoryLogger::new()
    (memory : &@logging.Logger).info("Loaded {Count} files", [3])
    let record = @formatting.LogRecord::capture(
    memory.events()[0],
    @formatting.LogTimestamp::earliest(),
    )
    let literate = @formatting.format_console(record, Literate)
    assert_true(literate.contains("\u{1b}[95m3\u{1b}[0m"))
    assert_true(literate.has_suffix("\u{1b}[0m\n"))
    assert_false(@formatting.format_console(record, Plain).contains("\u{1b}"))
    }

    #JSON Lines

    format_json_line gives one JSON object on one physical line, with a final LF. The output is UTF-8 text with no byte order mark and no ANSI codes. Control characters in strings, including ESC, CR and LF, are JSON escapes, so a reader can read one record for each line. The keys of schema version 1 are in this order:

    KeyValue
    schemaVersionThe number 1
    timestampThe captured UTC time, for example "2026-10-27T14:23:07.024Z"
    levelThe level name, for example "Information"
    categoryThe category, or null
    messageTemplateThe template as the caller wrote it
    messageThe message text
    propertiesAn array of {"name": ..., "value": ...} objects, in hole order
    errorThe Show text of the error, or null

    The properties are an array, not an object keyed by name, so a repeated name keeps each of its values. Each value keeps its JSON type. A number that is not finite (NaN or an infinity) becomes null, because JSON has no such numbers. This schema is not the JSON schema of Serilog (Compact JSON or CLEF); do not use a Serilog reader for it.

    ///|
    test "one JSON Lines record" {
    let memory = @logging.MemoryLogger::new()
    @logging.with_category(memory, "workspace.loader").info(
    "Loaded {Count} files from {Path}",
    [3, "src"],
    )
    let time = @formatting.LogTimestamp::from_epoch_millis(1793110987024L)
    let record = @formatting.LogRecord::capture(memory.events()[0], time)
    inspect(@formatting.format_json_line(record), content=(
    #|{"schemaVersion":1,"timestamp":"2026-10-27T14:23:07.024Z","level":"Information","category":"workspace.loader","messageTemplate":"Loaded {Count} files from {Path}","message":"Loaded 3 files from src","properties":[{"name":"Count","value":3},{"name":"Path","value":"src"}],"error":null}
    #|
    ))
    }

    #Named JSON properties

    format_json_object_line selects schema version 2. Record metadata stays the same. properties is an object, and the last occurrence of each name wins. For each repeated name, duplicateProperties[name] holds all values in original per-name order, including the winner. Names that occur once have no history. Without repeats, the record omits duplicateProperties entirely. An empty event has properties: {}. Each value keeps its JSON type. One array-valued property stays an array and does not imply repetition.

    ///|
    test "named JSON with complete duplicate history" {
    let memory = @logging.MemoryLogger::new()
    (memory : &@logging.Logger).info("{path} {path} {level}", [
    "/repo/morphir.toml", "/repo/app/morphir.toml", "event value",
    ])
    let record = @formatting.LogRecord::capture(memory.events()[0], @formatting.LogTimestamp::earliest())
    let line = @formatting.format_json_object_line(record)
    guard @json.parse(line) is { "schemaVersion": 2, "level": "Information", "properties": { "path": "/repo/app/morphir.toml", "level": "event value", .. }, "duplicateProperties": { "path": ["/repo/morphir.toml", "/repo/app/morphir.toml"], .. }, .. } else { fail(line) }
    }

    An event property named level, properties or duplicateProperties stays inside properties and cannot replace record metadata. The formatter reads the whole ordered property list. It does not change LogEvent::property, whose existing first-occurrence lookup remains compatible. format_json_line and the default JSON file sink continue to write schema version 1.

    #Bytes for size limits

    A file sink counts bytes, not characters. utf8_length gives the UTF-8 bytes of a rendered record, including its LF:

    ///|
    test "count the bytes of a record" {
    assert_eq(@formatting.utf8_length("ok\n"), 3L)
    assert_eq(@formatting.utf8_length("é✓😀\n"), 10L)
    }

    TimestampError

    pub suberror TimestampError {
    TimestampOutOfRange(millis~ : Int64)
    } derive(
    Debug
    )

    A time that is not in the range of LogTimestamp. Callers can match the case, but only LogTimestamp::from_epoch_millis makes it.

    TimestampError::message

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

    Text for people that describes the fault.

    JsonProperties

    pub(all) enum JsonProperties {
    OrderedArray
    NamedObject
    } derive(Eq,
    Debug
    )

    The JSON property layout of a file sink. OrderedArray keeps schema version
    1. NamedObject selects schema version 2 with last-wins values and history.

    JsonProperties::equal

    JsonProperties::not_equal

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

    LogRecord

    pub struct LogRecord {
    // private fields
    }

    One captured log event with its time. capture reads all parts of the event one time, including the Show text of its error, so the record cannot change after it is made. Each sink of one session renders the same record.

    LogRecord::capture

    Capture event at timestamp. The error text is the Show text of the error now, so a later change to a mutable error does not change the record.

    LogRecord::category

    fn LogRecord::category(self : LogRecord) -> String?

    The category of the event, or None when it has no category.

    LogRecord::error_text

    fn LogRecord::error_text(self : LogRecord) -> String?

    The Show text of the error at capture, or None when the event has no error.

    LogRecord::level

    The severity of the event.

    LogRecord::message

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

    The message text, as LogEvent::message gave it.

    LogRecord::properties

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

    A copy of the properties in hole order, with each repeated name. A change to the result, or to a JSON value in it, does not change the record.

    LogRecord::segments

    A copy of the message segments. A change to the result, or to a JSON value in it, does not change the record.

    LogRecord::template

    fn LogRecord::template(self : LogRecord) -> String

    The message template as the caller wrote it.

    LogRecord::timestamp

    fn LogRecord::timestamp(self : LogRecord) -> LogTimestamp

    The time at which the session got the event.

    LogTimestamp

    pub struct LogTimestamp {
    // private fields
    } derive(Compare, Eq, Hash,
    Debug
    )

    A UTC time of a log record, in whole milliseconds after the Unix epoch, from 1970-01-01T00:00:00.000Z to 9999-12-31T23:59:59.999Z. In this range each time has a four-digit year, so the text forms all have one length and sort in time order.

    LogTimestamp::compact_date

    fn LogTimestamp::compact_date(self : LogTimestamp) -> String

    The UTC date as eight digits, for example 20261027.

    LogTimestamp::compare

    LogTimestamp::earliest

    fn LogTimestamp::earliest() -> LogTimestamp

    The earliest timestamp: 1970-01-01T00:00:00.000Z.

    LogTimestamp::epoch_day

    fn LogTimestamp::epoch_day(self : LogTimestamp) -> Int64

    The whole UTC days after 1970-01-01, from 0 to 2932896 (9999-12-31).

    LogTimestamp::epoch_millis

    fn LogTimestamp::epoch_millis(self : LogTimestamp) -> Int64

    The milliseconds after 1970-01-01T00:00:00.000Z.

    LogTimestamp::equal

    LogTimestamp::from_epoch_millis

    fn LogTimestamp::from_epoch_millis(millis : Int64) -> LogTimestamp raise TimestampError

    Make a timestamp from millis milliseconds after 1970-01-01T00:00:00.000Z. The value must be from 0 to 253402300799999 (9999-12-31T23:59:59.999Z).

    LogTimestamp::hash

    fn LogTimestamp::hash(self : LogTimestamp) -> Int

    LogTimestamp::hash_combine

    fn LogTimestamp::hash_combine(LogTimestamp, Hasher) -> Unit

    LogTimestamp::latest

    fn LogTimestamp::latest() -> LogTimestamp

    The latest timestamp: 9999-12-31T23:59:59.999Z.

    LogTimestamp::not_equal

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

    LogTimestamp::op_ge

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

    LogTimestamp::op_gt

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

    LogTimestamp::op_le

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

    LogTimestamp::op_lt

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

    LogTimestamp::output

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

    LogTimestamp::time_of_day

    fn LogTimestamp::time_of_day(self : LogTimestamp) -> String

    The UTC time of day with milliseconds and the zone Z, for example 14:23:07.024Z.

    LogTimestamp::to_iso8601

    fn LogTimestamp::to_iso8601(self : LogTimestamp) -> String

    The timestamp in ISO 8601 form with milliseconds and the zone Z, for example 2026-10-27T14:23:07.024Z.

    LogTimestamp::to_string

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

    Theme

    pub(all) enum Theme {
    Literate
    Plain
    } derive(Eq,
    Debug
    )

    The style of console output.

    Theme::equal

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

    Theme::not_equal

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

    Theme::to_repr

    format_console

    fn format_console(record : LogRecord, theme : Theme) -> String

    Render record as one console event in theme, for example [14:23:07.012Z INF] workspace.loader: Loaded 3 files from src, with a final LF. The category and its : are not there when the record has no category. Each continuation line of the message, and each line of the error text, starts with four spaces.

    The renderer replaces each C0 control character except LF and TAB, DEL and each C1 control character (U+0080 to U+009F) in caller text with the visible text \xNN, before it adds a style. For example ESC is \x1b and the CR of a CR LF pair is \x0d. So only the renderer can write ANSI codes. With Literate, each styled part ends with a reset, so the event ends with no style before its LF. With Plain, the output has no ESC character.

    format_json_line

    fn format_json_line(record : LogRecord) -> String

    Render record as one JSON Lines record of schema version 1: one JSON object on one physical line, with a final LF, no byte order mark and no ANSI codes. The keys are in this order:

    KeyValue
    schemaVersionThe number 1
    timestampThe UTC time, for example "2026-10-27T14:23:07.024Z"
    levelThe level name, for example "Information"
    categoryThe category, or null
    messageTemplateThe template as the caller wrote it
    messageThe message text
    propertiesAn array of {"name": ..., "value": ...} objects in hole order, with each repeated name and the original JSON value
    errorThe Show text of the error, or null

    Control characters in strings are JSON escapes, for example \n and \u001b. A number that is not finite becomes null.

    format_json_object_line

    fn format_json_object_line(record : LogRecord) -> String

    Render schema version 2 with named values under properties. The last occurrence wins. For each repeated name, duplicateProperties holds all values in original per-name order, including the winner. Omit that object without repeats. JSON types, escaping and metadata isolation are preserved.

    format_text

    fn format_text(record : LogRecord) -> String

    Render record as one event of a readable log file, for example 2026-10-27T14:23:07.024Z [INF] workspace.loader: Loaded 3 files from src, with a final LF. The output has no ANSI codes. The category, the continuation lines and the escape of control characters are as in format_console.

    utf8_length

    fn utf8_length(text : String) -> Int64

    The number of bytes of text in UTF-8. A sink uses it to count the bytes of a rendered record. A lone surrogate counts as 3 bytes, the size of the replacement character that an encoder writes for it.