Structured tracing for MoonBit. Spans, structured fields, pluggable subscribers.
Inspired by Rust's tracing crate and loguru's developer experience.
moon add brickfrog/moontrace
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
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.
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 fieldsExplicit fields override global context on key collision.
@moontrace.remove_global_field("version") // remove a single field
@moontrace.clear_global_fields() // remove allFilter 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 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 nanosecondsEvery span gets auto-generated trace_id (32 hex chars) and span_id (16 hex chars).
@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.
// 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.
For distributed tracing across process boundaries, inject an existing trace ID:
let s = @moontrace.span_with_trace("handle_request", external_trace_id)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 receive events. Set one globally:
@moontrace.set_subscriber(fn(event) {
println(event.format())
})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
Machine-readable JSON output:
@json.initialize()
// or with options:
@moontrace.set_subscriber(@json.subscriber(min_level=@moontrace.Warn))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 dataThe OTLP package handles format conversion (events to log records, spans to OTLP spans with proper severity codes and attributes). You provide the transport.
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)
)// 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()) },
))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 eventsSet a global minimum level to skip event allocation entirely:
@moontrace.set_min_level(@moontrace.Info)
// trace() and debug() calls now short-circuit before allocating EventGlobal context fields use a fast path — zero allocation overhead when no context is set.
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 coloredAll types implement Show for println and string interpolation:
println(event) // human-readable output
let s = "\{span}" // string interpolation works@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.
Contributions welcome! Please:
moon fmtbefore committingmoon test --target nativemust pass- Run
moon info && moon fmtif you change public APIs (updates.mbtifiles) - Add tests for new features
The pre-commit hook runs moon fmt and moon check automatically.
git clone https://github.com/brickfrog/moontrace
cd moontrace
git config core.hooksPath .githooks
moon test --target nativeApache-2.0