🎉 Release BitLogger v0.1.0 core

This commit is contained in:
Nanaloveyuki
2026-05-08 14:18:27 +08:00
parent d8687a8371
commit ff3d32a26a
16 changed files with 558 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
moonbit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install MoonBit
run: |
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
echo "$HOME/.moon/bin" >> "$GITHUB_PATH"
- name: Show tool versions
run: |
moon version
- name: Check bitlogger
run: |
moon check bitlogger
- name: Test bitlogger
run: |
moon test bitlogger
- name: Run example
run: |
moon run examples/basic
+25
View File
@@ -0,0 +1,25 @@
# BitLogger
BitLogger is a MoonBit logging library in early development.
## Repository Layout
- `bitlogger/`: library package, tests, and checked package README
- `examples/basic/`: runnable example program
- `docs/dev/`: development notes and MoonBit gotchas collected during implementation
## Current MVP
- log levels
- structured fields
- sink trait
- console sink
- JSON console sink
- context fields
- child target composition
- fanout sink composition
- callback sink
- optional timestamps
- global default logger helpers
For the checked MoonBit example, see [bitlogger/README.mbt.md](./bitlogger/README.mbt.md).
+2
View File
@@ -0,0 +1,2 @@
///|
/// BitLogger public API surface.
+31
View File
@@ -0,0 +1,31 @@
test "level filter works" {
let logger = Logger::new(console_sink(), min_level=Level::Warn, target="test")
inspect(logger.is_enabled(Level::Error), content="true")
inspect(logger.is_enabled(Level::Info), content="false")
}
test "context sink merges fields" {
let logger = Logger::new(console_sink(), min_level=Level::Info, target="ctx")
.with_context_fields([field("service", "bitlogger")])
let merged = [field("service", "bitlogger"), field("mode", "test")]
inspect(merged.length(), content="2")
inspect(merged[0].key, content="service")
inspect(merged[1].key, content="mode")
logger.info("hello", fields=[field("mode", "test")])
}
test "fanout sink can write to plain and json outputs" {
let logger = Logger::new(
fanout_sink(console_sink(), json_console_sink()),
min_level=Level::Info,
target="fanout",
)
inspect(logger.is_enabled(Level::Info), content="true")
logger.info("hello", fields=[field("kind", "dual")])
}
test "child logger composes target path" {
let logger = Logger::new(console_sink(), min_level=Level::Info, target="app")
.child("worker")
inspect(logger.target, content="app.worker")
}
+54
View File
@@ -0,0 +1,54 @@
test "default logger can be reconfigured" {
set_default_min_level(Level::Debug)
set_default_target("global")
let logger = default_logger()
inspect(logger.min_level.label(), content="DEBUG")
inspect(logger.target, content="global")
}
test "logger can enable timestamps" {
let logger = Logger::new(console_sink(), min_level=Level::Info, target="time")
.with_timestamp()
inspect(logger.timestamp, content="true")
}
test "callback sink receives record" {
let captured_target : Ref[String] = Ref::new("")
let captured_message : Ref[String] = Ref::new("")
let logger = Logger::new(
callback_sink(fn(rec) {
captured_target.val = rec.target
captured_message.val = rec.message
}),
min_level=Level::Info,
target="callback",
)
logger.info("hello")
inspect(captured_target.val, content="callback")
inspect(captured_message.val, content="hello")
}
test "callback sink sees child target and context logger shape" {
let captured_target : Ref[String] = Ref::new("")
let captured_message : Ref[String] = Ref::new("")
let captured_field_count : Ref[Int] = Ref::new(0)
let captured_timestamp : Ref[UInt64] = Ref::new(0UL)
let logger = Logger::new(
callback_sink(fn(rec) {
captured_target.val = rec.target
captured_message.val = rec.message
captured_field_count.val = rec.fields.length()
captured_timestamp.val = rec.timestamp_ms
}),
min_level=Level::Info,
target="app",
)
.child("worker")
.with_context_fields([field("service", "bitlogger")])
.with_timestamp()
logger.info("ready", fields=[field("mode", "test")])
inspect(captured_target.val, content="app.worker")
inspect(captured_message.val, content="ready")
inspect(captured_field_count.val, content="2")
inspect(captured_timestamp.val > 0UL, content="true")
}
+50
View File
@@ -0,0 +1,50 @@
# BitLogger
BitLogger is a minimal structured logger for MoonBit.
## Features
- log levels: `Trace`, `Debug`, `Info`, `Warn`, `Error`
- structured key-value fields
- sink abstraction
- default global console logger
- context fields via `with_context_fields(...)`
- child target composition via `child(...)`
- optional timestamps via `with_timestamp()`
- JSON console output via `json_console_sink()`
- sink composition via `fanout_sink(...)`
- custom callback sink via `callback_sink(...)`
## Example
```mbt check
test {
let logger = Logger::new(console_sink(), min_level=Level::Debug, target="demo")
.with_timestamp()
logger.info("starting", fields=[field("port", "8080")])
}
```
```mbt check
test {
let logger = Logger::new(console_sink(), target="app").child("worker")
logger.info("ready")
}
```
```mbt check
test {
let logger = Logger::new(
fanout_sink(console_sink(), json_console_sink()),
min_level=Level::Info,
target="demo",
)
logger.info("ready", fields=[field("mode", "fanout")])
}
```
## Notes
Current MVP includes plain-text output, JSON console output, context fields, child target composition, and simple sink fanout.
The intended public entry points are `Logger`, sink constructor helpers, `field(...)`, and the default global logger helpers.
Next useful steps are file sink and buffered or async delivery.
+43
View File
@@ -0,0 +1,43 @@
fn fields_to_json(fields : Array[Field]) -> Json {
let obj : Map[String, Json] = {}
for item in fields {
obj[item.key] = Json::string(item.value)
}
Json::object(obj)
}
fn format_record(rec : Record) -> String {
let prefix = if rec.timestamp_ms == 0UL {
"[\{rec.level.label()}]"
} else {
"[\{rec.timestamp_ms.to_string()}] [\{rec.level.label()}]"
}
let base = if rec.target == "" {
"\{prefix} \{rec.message}"
} else {
"\{prefix} [\{rec.target}] \{rec.message}"
}
if rec.fields.length() == 0 {
base
} else {
let details = rec.fields.map(fn(f) { "\{f.key}=\{f.value}" }).join(" ")
"\{base} \{details}"
}
}
fn format_record_json(rec : Record) -> String {
let obj : Map[String, Json] = {
"level": Json::string(rec.level.label()),
"message": Json::string(rec.message),
"fields": fields_to_json(rec.fields),
}
if rec.timestamp_ms != 0UL {
obj["timestamp_ms"] = rec.timestamp_ms.to_json()
}
if rec.target == "" {
Json::object(obj).stringify()
} else {
obj["target"] = Json::string(rec.target)
Json::object(obj).stringify()
}
}
+39
View File
@@ -0,0 +1,39 @@
let default_console_sink : ConsoleSink = console_sink()
let default_min_level_ref : Ref[Level] = Ref::new(Level::Info)
let default_target_ref : Ref[String] = Ref::new("")
pub fn set_default_min_level(level : Level) -> Unit {
default_min_level_ref.val = level
}
pub fn set_default_target(target : String) -> Unit {
default_target_ref.val = target
}
pub fn default_logger() -> Logger[ConsoleSink] {
Logger::new(default_console_sink, min_level=default_min_level_ref.val, target=default_target_ref.val)
}
pub fn log(level : Level, message : String, fields~ : Array[Field] = []) -> Unit {
default_logger().log(level, message, fields=fields)
}
pub fn trace(message : String, fields~ : Array[Field] = []) -> Unit {
default_logger().trace(message, fields=fields)
}
pub fn debug(message : String, fields~ : Array[Field] = []) -> Unit {
default_logger().debug(message, fields=fields)
}
pub fn info(message : String, fields~ : Array[Field] = []) -> Unit {
default_logger().info(message, fields=fields)
}
pub fn warn(message : String, fields~ : Array[Field] = []) -> Unit {
default_logger().warn(message, fields=fields)
}
pub fn error(message : String, fields~ : Array[Field] = []) -> Unit {
default_logger().error(message, fields=fields)
}
+31
View File
@@ -0,0 +1,31 @@
pub(all) enum Level {
Trace
Debug
Info
Warn
Error
}
fn Level::priority(self : Level) -> Int {
match self {
Level::Trace => 10
Level::Debug => 20
Level::Info => 30
Level::Warn => 40
Level::Error => 50
}
}
pub fn Level::label(self : Level) -> String {
match self {
Level::Trace => "TRACE"
Level::Debug => "DEBUG"
Level::Info => "INFO"
Level::Warn => "WARN"
Level::Error => "ERROR"
}
}
fn Level::enabled(self : Level, min_level : Level) -> Bool {
self.priority() >= min_level.priority()
}
+87
View File
@@ -0,0 +1,87 @@
pub struct Logger[S] {
min_level : Level
sink : S
target : String
timestamp : Bool
}
pub fn[S] Logger::new(sink : S, min_level~ : Level = Level::Info, target~ : String = "") -> Logger[S] {
{ min_level, sink, target, timestamp: false }
}
pub fn[S] Logger::with_target(self : Logger[S], target : String) -> Logger[S] {
{ ..self, target }
}
fn combine_targets(parent : String, child : String) -> String {
if parent == "" {
child
} else if child == "" {
parent
} else {
"\{parent}.\{child}"
}
}
pub fn[S] Logger::child(self : Logger[S], target : String) -> Logger[S] {
{ ..self, target: combine_targets(self.target, target) }
}
pub fn[S] Logger::with_context_fields(self : Logger[S], fields : Array[Field]) -> Logger[ContextSink[S]] {
{
min_level: self.min_level,
sink: ContextSink::{ sink: self.sink, context_fields: fields },
target: self.target,
timestamp: self.timestamp,
}
}
pub fn[S] Logger::with_min_level(self : Logger[S], min_level : Level) -> Logger[S] {
{ ..self, min_level }
}
pub fn[S] Logger::with_timestamp(self : Logger[S], enabled~ : Bool = true) -> Logger[S] {
{ ..self, timestamp: enabled }
}
pub fn[S] Logger::is_enabled(self : Logger[S], level : Level) -> Bool {
level.enabled(self.min_level)
}
pub fn[S : Sink] Logger::log(
self : Logger[S],
level : Level,
message : String,
fields~ : Array[Field] = [],
target? : String = "",
) -> Unit {
if !self.is_enabled(level) {
()
} else {
let actual_target = if target == "" { self.target } else { target }
let timestamp_ms = if self.timestamp { @env.now() } else { 0UL }
self.sink.write(
record(level, message, timestamp_ms=timestamp_ms, target=actual_target, fields=fields),
)
}
}
pub fn[S : Sink] Logger::trace(self : Logger[S], message : String, fields~ : Array[Field] = []) -> Unit {
self.log(Level::Trace, message, fields=fields)
}
pub fn[S : Sink] Logger::debug(self : Logger[S], message : String, fields~ : Array[Field] = []) -> Unit {
self.log(Level::Debug, message, fields=fields)
}
pub fn[S : Sink] Logger::info(self : Logger[S], message : String, fields~ : Array[Field] = []) -> Unit {
self.log(Level::Info, message, fields=fields)
}
pub fn[S : Sink] Logger::warn(self : Logger[S], message : String, fields~ : Array[Field] = []) -> Unit {
self.log(Level::Warn, message, fields=fields)
}
pub fn[S : Sink] Logger::error(self : Logger[S], message : String, fields~ : Array[Field] = []) -> Unit {
self.log(Level::Error, message, fields=fields)
}
+7
View File
@@ -0,0 +1,7 @@
import {
"moonbitlang/core/array",
"moonbitlang/core/builtin",
"moonbitlang/core/env" @env,
"moonbitlang/core/json",
"moonbitlang/core/ref",
}
+26
View File
@@ -0,0 +1,26 @@
pub struct Field {
key : String
value : String
}
pub fn field(key : String, value : String) -> Field {
{ key, value }
}
pub struct Record {
level : Level
timestamp_ms : UInt64
target : String
message : String
fields : Array[Field]
}
fn record(
level : Level,
message : String,
timestamp_ms~ : UInt64 = 0UL,
target~ : String = "",
fields~ : Array[Field] = [],
) -> Record {
{ level, timestamp_ms, target, message, fields }
}
+71
View File
@@ -0,0 +1,71 @@
pub trait Sink {
write(Self, Record) -> Unit
}
pub struct ConsoleSink {
_dummy : Unit
}
pub fn console_sink() -> ConsoleSink {
{ _dummy: () }
}
pub impl Sink for ConsoleSink with write(self, rec) {
ignore(self)
println(format_record(rec))
}
pub struct ContextSink[S] {
sink : S
context_fields : Array[Field]
}
pub impl[S : Sink] Sink for ContextSink[S] with write(self, rec) {
let merged = if self.context_fields.length() == 0 {
rec.fields
} else if rec.fields.length() == 0 {
self.context_fields
} else {
self.context_fields + rec.fields
}
self.sink.write({ ..rec, fields: merged })
}
pub struct JsonConsoleSink {
_dummy : Unit
}
pub fn json_console_sink() -> JsonConsoleSink {
{ _dummy: () }
}
pub impl Sink for JsonConsoleSink with write(self, rec) {
ignore(self)
println(format_record_json(rec))
}
pub struct FanoutSink[A, B] {
left : A
right : B
}
pub fn[A, B] fanout_sink(left : A, right : B) -> FanoutSink[A, B] {
{ left, right }
}
pub impl[A : Sink, B : Sink] Sink for FanoutSink[A, B] with write(self, rec) {
self.left.write(rec)
self.right.write({ ..rec })
}
pub struct CallbackSink {
callback : (Record) -> Unit
}
pub fn callback_sink(callback : (Record) -> Unit) -> CallbackSink {
{ callback, }
}
pub impl Sink for CallbackSink with write(self, rec) {
(self.callback)(rec)
}
+36
View File
@@ -0,0 +1,36 @@
fn main {
@lib.set_default_min_level(@lib.Level::Debug)
@lib.set_default_target("bitlogger")
@lib.info("hello from BitLogger", fields=[@lib.field("mode", "demo")])
let logger = @lib.Logger::new(@lib.console_sink(), min_level=@lib.Level::Trace, target="custom")
.with_context_fields([@lib.field("service", "bitlogger")])
logger.debug("custom logger ready", fields=[@lib.field("sink", "console")])
let json_logger = @lib.Logger::new(@lib.json_console_sink(), min_level=@lib.Level::Info, target="json")
json_logger.info("json output", fields=[@lib.field("kind", "example")])
let fanout_logger = @lib.Logger::new(
@lib.fanout_sink(@lib.console_sink(), @lib.json_console_sink()),
min_level=@lib.Level::Info,
target="fanout",
)
fanout_logger.info("dual output", fields=[@lib.field("kind", "fanout")])
let timed_logger = @lib.Logger::new(@lib.console_sink(), min_level=@lib.Level::Info, target="timed")
.with_timestamp()
timed_logger.info("timestamp enabled", fields=[@lib.field("kind", "time")])
let child_logger = @lib.Logger::new(@lib.console_sink(), min_level=@lib.Level::Info, target="app")
.child("worker")
child_logger.info("child target ready")
let callback_logger = @lib.Logger::new(
@lib.callback_sink(fn(rec) {
println("callback saw [\{rec.target}] \{rec.message}")
}),
min_level=@lib.Level::Info,
target="hook",
)
callback_logger.info("callback sink ready")
}
+7
View File
@@ -0,0 +1,7 @@
import {
"miaom/BitLogger/bitlogger" @lib,
}
options(
"is-main": true,
)
+13
View File
@@ -0,0 +1,13 @@
{
"name": "miaom/BitLogger",
"version": "0.1.0",
"readme": "README.mbt.md",
"repository": "",
"license": "Apache-2.0",
"keywords": [
"logger",
"logging",
"moonbit"
],
"description": "A minimal structured logger for MoonBit."
}