moontrace

Structured tracing for MoonBit with spans, structured fields, pluggable subscribers.

tracing
logging
spans
structured-logging
observability
moon add brickfrog/moontrace@0.13.1
Download zip
Author
Version
0.13.1
License
Apache-2.0
Last updated
14 days ago
Downloads
147
README

#brickfrog/moontrace

Structured tracing for MoonBit. Spans, structured fields, pluggable subscribers.

Inspired by Rust's tracing crate and loguru's developer experience.

#Install

moon add brickfrog/moontrace

#Quick Start

fn main {
// One-liner setup with colored console output
@console.initialize()

// Structured events
@moontrace.info("server started", fields=[@moontrace.field("port", 8080)])
@moontrace.warn("slow query", fields=[@moontrace.field("ms", 250)])

// Spans with duration tracking
@moontrace.with_span("handle_request", fn() {
@moontrace.info("processing")
})
}

Output:

14:31:43.903 | INFO | myapp — server started port=8080 14:31:43.904 | WARN | myapp — slow query ms=250 14:31:43.904 | TRACE | moontrace — span.enter handle_request 14:31:43.904 | INFO | myapp — processing 14:31:43.904 | TRACE | moontrace — span.exit handle_request duration_ns=12345

#Events

Fire-and-forget structured log entries at five levels:

@moontrace.trace("verbose detail")
@moontrace.debug("debugging info")
@moontrace.info("normal operation")
@moontrace.warn("something unexpected")
@moontrace.error("something broke")

Attach structured fields to any event:

@moontrace.info("request handled",
fields=[
@moontrace.field("method", "GET"),
@moontrace.field("path", "/api/users"),
@moontrace.field("status", 200),
])

The field() function uses MoonBit's ToJson trait dispatch — any type implementing ToJson works. For raw Json values, use fields() instead.

Every event automatically captures its source package via SourceLoc, available as event.source.

#Global Context

Set fields once, automatically included in every event:

@moontrace.set_global_field("service", "my-app")
@moontrace.set_global_field("version", "1.2.0")

@moontrace.info("started") // automatically includes service and version fields

Explicit fields override global context on key collision.

@moontrace.remove_global_field("version") // remove a single field
@moontrace.clear_global_fields() // remove all

#Per-Module Filtering

Filter log levels by package:

@moontrace.set_module_filter("my/db/package", @moontrace.Debug)
@moontrace.set_module_filter("my/http/package", @moontrace.Warn)

Module names are extracted automatically from SourceLoc at each call site. No manual tagging needed.

#Spans

Spans track operations with enter/exit lifecycle, duration, and distributed tracing IDs:

let s = @moontrace.span("db_query")
.with_field("table", "users")
.with_field("limit", 100)
s.enter()
// ... do work ...
s.record("rows", 42) // add fields after creation
s.exit()
// s.duration() returns elapsed nanoseconds

Every span gets auto-generated trace_id (32 hex chars) and span_id (16 hex chars).

#Error Handling on Spans

@moontrace.with_span_ctx("db_query", fn(s) {
match run_query() {
Ok(result) => s.set_status(@moontrace.Ok)
Err(e) => s.record_error(e.to_string()) // sets SpanError + records error field
}
})

record_error sets the span status to SpanError, records the error message as a field, and includes both in the span's exit event and JSON output.

#Convenience Wrappers

// Auto enter/exit
@moontrace.with_span("operation", fn() {
@moontrace.info("inside span")
})

// Access the span inside the closure
@moontrace.with_span_ctx("operation", fn(span) {
span.record("result", "ok")
})

// Nested spans with parent linking
@moontrace.with_span_ctx("parent_op", fn(parent) {
@moontrace.with_child_span(parent, "child_op", fn(_child) {
@moontrace.info("in child span")
})
})

with_child_span propagates the parent's trace_id and sets parent_span_id, linking spans in a trace.

#Cross-Process Trace Correlation

For distributed tracing across process boundaries, inject an existing trace ID:

let s = @moontrace.span_with_trace("handle_request", external_trace_id)

#Async Span Propagation

MoonBit has no task-local storage, so spans must be passed explicitly across async boundaries:

@moontrace.with_span_ctx("request", fn(parent) {
async_work(parent)
})

pub async fn async_work(parent : @moontrace.Span) -> Unit {
@moontrace.with_child_span(parent, "async_step", fn(_s) {
// child inherits parent's trace_id
})
}

#Subscribers

Subscribers receive events. Set one globally:

@moontrace.set_subscriber(fn(event) {
println(event.format())
})

#Console Subscriber

Human-readable colored output with timestamps, source, and pipe separators:

@console.initialize()
// or with options:
@moontrace.set_subscriber(@console.subscriber(
min_level=@moontrace.Info,
color=true,
))

Output:

14:31:43.903 | INFO | server — UDS server listening path=".choir/server.sock" 14:31:44.012 | WARN | poller — retry attempt=3 max=5 14:31:44.500 | ERROR | handler — delivery failed target="leaf-1" exit_code=2

#JSON Subscriber

Machine-readable JSON output:

@json.initialize()
// or with options:
@moontrace.set_subscriber(@json.subscriber(min_level=@moontrace.Warn))

#OTLP Export

Convert events and spans to OpenTelemetry-compatible JSON:

let exp = @otlp.exporter(
log_output=fn(json) { send_to_collector(json) },
span_output=fn(json) { send_to_collector(json) },
capacity=100,
)
@moontrace.set_subscriber(exp.subscriber())

// Spans must be added manually
let s = @moontrace.span("operation")
s.enter()
// ... work ...
s.exit()
exp.add_span(@otlp.span_to_otlp(s))
exp.flush() // send remaining batched data

The OTLP package handles format conversion (events to log records, spans to OTLP spans with proper severity codes and attributes). You provide the transport.

#Subscriber Composition

Route events to multiple subscribers:

@moontrace.set_subscriber(@moontrace.compose([
@console.subscriber(min_level=@moontrace.Info),
@json.subscriber(min_level=@moontrace.Debug),
]))

Filter events for any subscriber:

@moontrace.set_subscriber(
@moontrace.with_filter(@json.subscriber(), @moontrace.Warn)
)

#Utility Subscribers

// No-op (for benchmarks/testing)
@moontrace.set_subscriber(@moontrace.noop())

// Intercept (run a side-effect before the main subscriber)
@moontrace.set_subscriber(@moontrace.intercept(
main_subscriber,
fn(e) { metrics.increment(e.level.to_string()) },
))

#Buffered Subscriber

Batch events and flush on demand or at capacity:

let buf = @moontrace.buffer(@json.subscriber(), capacity=100)
@moontrace.set_subscriber(buf.subscriber())
// ... events are batched ...
buf.flush() // send all buffered events

#Performance

Set a global minimum level to skip event allocation entirely:

@moontrace.set_min_level(@moontrace.Info)
// trace() and debug() calls now short-circuit before allocating Event

Global context fields use a fast path — zero allocation overhead when no context is set.

#Serialization

Events, spans, and fields all support JSON serialization:

let event_json = event.to_json().stringify()
let span_json = span.to_json().stringify()

Span JSON includes trace_id, span_id, parent_span_id, status, and duration.

Human-readable formatting with the new pipe-separated style:

let plain = event.format() // no color
let colored = event.format(color=true) // ANSI colored

All types implement Show for println and string interpolation:

println(event) // human-readable output
let s = "\{span}" // string interpolation works

#Architecture

@moontrace # core — what libraries depend on @moontrace/json # JSON subscriber (structured output) @moontrace/console # console subscriber (colored, human-readable) @moontrace/otlp # OpenTelemetry JSON format conversion

Libraries instrument with @moontrace. Applications choose subscribers.

#Contributing

Contributions welcome! Please:

  1. moon fmt before committing
  2. moon test --target native must pass
  3. Run moon info && moon fmt if you change public APIs (updates .mbti files)
  4. Add tests for new features

The pre-commit hook runs moon fmt and moon check automatically.

#Development

git clone https://github.com/brickfrog/moontrace cd moontrace git config core.hooksPath .githooks moon test --target native

#License

Apache-2.0

#
Event

pub(all) struct Event {
level : Level
message : String
fields : Array[Field]
timestamp : UInt64
source : String
}

impl Show for Event

#
Event::format

fn Event::format(self : Event, color? : Bool) -> String

#
Event::to_json

fn Event::to_json(self : Event) -> Json

#
EventBuffer

pub(all) struct EventBuffer {
events : Array[Event]
capacity : Int
inner : (Event) -> Unit
}

#
EventBuffer::flush

fn EventBuffer::flush(self : EventBuffer) -> Unit

#
EventBuffer::len

fn EventBuffer::len(self : EventBuffer) -> Int

#
EventBuffer::subscriber

fn EventBuffer::subscriber(self : EventBuffer) -> ((Event) -> Unit)

#
Field

pub(all) struct Field {
key : String
value : Json
}

impl Show for Field

#
Field::to_json

fn Field::to_json(self : Field) -> Json

#
Level

pub(all) enum Level {
Trace
Debug
Info
Warn
Error_
} derive(Compare, Eq)

impl Show for Level

#
Level::from_string

fn Level::from_string(s : String) -> Level?

#
Level::to_json

fn Level::to_json(self : Level) -> Json

#
Level::to_string

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

#
Span

pub(all) struct Span {
name : String
fields : Array[Field]
trace_id : String
span_id : String
parent_span_id : String
kind : SpanKind
start_time : UInt64
end_time : UInt64
active : Bool
status : SpanStatus
status_message : String
}

impl Show for Span

#
Span::duration

fn Span::duration(self : Span) -> UInt64

#
Span::enter

fn Span::enter(self : Span) -> Unit

#
Span::exit

fn Span::exit(self : Span) -> Unit

#
Span::is_active

fn Span::is_active(self : Span) -> Bool

#
Span::record

fn[T : ToJson] Span::record(self : Span, key : String, value : T) -> Unit

#
Span::record_error

fn Span::record_error(self : Span, msg : String) -> Unit

#
Span::set_status

fn Span::set_status(self : Span, status : SpanStatus, message? : String?) -> Unit

#
Span::to_json

fn Span::to_json(self : Span) -> Json

#
Span::with_field

fn[T : ToJson] Span::with_field(self : Span, key : String, value : T) -> Span

#
Span::with_kind

fn Span::with_kind(self : Span, kind : SpanKind) -> Span

#
SpanKind

pub(all) enum SpanKind {
Internal
Server
Client
Producer
Consumer
} derive(Compare, Eq)

impl Show for SpanKind

#
SpanKind::to_int

fn SpanKind::to_int(self : SpanKind) -> Int

#
SpanKind::to_json

fn SpanKind::to_json(self : SpanKind) -> Json

#
SpanKind::to_string

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

#
SpanStatus

pub(all) enum SpanStatus {
Unset
Ok
SpanError
} derive(Compare, Eq)

impl Show for SpanStatus

#
SpanStatus::to_int

fn SpanStatus::to_int(self : SpanStatus) -> Int

#
SpanStatus::to_json

fn SpanStatus::to_json(self : SpanStatus) -> Json

#
SpanStatus::to_string

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

#
buffer

fn buffer(inner : (Event) -> Unit, capacity? : Int) -> EventBuffer

#
clear_global_fields

fn clear_global_fields() -> Unit

#
clear_module_filters

fn clear_module_filters() -> Unit

Clear all module-specific filters

#
clear_subscriber

fn clear_subscriber() -> Unit

#
compose

fn compose(subscribers : Array[(Event) -> Unit]) -> ((Event) -> Unit)

#
debug

#callsite(autofill(loc))
fn debug(msg : String, fields? : Array[Field], loc~ : SourceLoc) -> Unit

#
error

#callsite(autofill(loc))
fn error(msg : String, fields? : Array[Field], loc~ : SourceLoc) -> Unit

#
field

fn[T : ToJson] field(key : String, value : T) -> Field

#
fields

fn fields(pairs : Array[(String, Json)]) -> Array[Field]

#
fields_to_json

fn fields_to_json(fields : Array[Field]) -> Json

#
format_event

fn format_event(event : Event, color : Bool, format_timestamp : (UInt64) -> String) -> String

#
format_timestamp_hms

#deprecated("Use `format_timestamp_utc` instead")
fn format_timestamp_hms(timestamp : UInt64) -> String

Deprecated: use format_timestamp_utc instead.

#
format_timestamp_utc

fn format_timestamp_utc(timestamp : UInt64) -> String

Format a millisecond timestamp as HH:MM:SS.mmm in UTC.

#
get_global_field

fn get_global_field(key : String) -> Json?

#
get_min_level

fn get_min_level() -> Level

#
info

#callsite(autofill(loc))
fn info(msg : String, fields? : Array[Field], loc~ : SourceLoc) -> Unit

#
intercept

fn intercept(subscriber : (Event) -> Unit, on_event : (Event) -> Unit) -> ((Event) -> Unit)

#
level_color

fn level_color(level : Level) -> String

#
next_span_id

fn next_span_id() -> String

#
next_trace_id

fn next_trace_id() -> String

#
noop

fn noop() -> ((Event) -> Unit)

#
remove_global_field

fn remove_global_field(key : String) -> Unit

#
set_global_field

fn[T : ToJson] set_global_field(key : String, value : T) -> Unit

#
set_min_level

fn set_min_level(level : Level) -> Unit

#
set_module_filter

fn set_module_filter(module_name : String, level : Level) -> Unit

Set a filter for a specific module to the given minimum level

Parameters:
  • module_name - The package name (e.g., "my/package")
  • level - The minimum level to log for this module

#
set_subscriber

fn set_subscriber(f : (Event) -> Unit) -> Unit

#
span

fn span(name : String, fields? : Array[Field], kind? : SpanKind) -> Span

#
span_with_trace

fn span_with_trace(name : String, trace_id : String, fields? : Array[Field], kind? : SpanKind) -> Span

#
trace

#callsite(autofill(loc))
fn trace(msg : String, fields? : Array[Field], loc~ : SourceLoc) -> Unit

#
warn

#callsite(autofill(loc))
fn warn(msg : String, fields? : Array[Field], loc~ : SourceLoc) -> Unit

#
with_child_span

fn[T] with_child_span(parent : Span, name : String, fields? : Array[Field], f : (Span) -> T) -> T

#
with_filter

fn with_filter(subscriber : (Event) -> Unit, min_level : Level) -> ((Event) -> Unit)

#
with_span

fn[T] with_span(name : String, fields? : Array[Field], f : () -> T) -> T

#
with_span_ctx

fn[T] with_span_ctx(name : String, fields? : Array[Field], f : (Span) -> T) -> T