From ff3d32a26a999dec6d1c53e609e90162c862f8fc Mon Sep 17 00:00:00 2001 From: Nanaloveyuki Date: Fri, 8 May 2026 14:18:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Release=20BitLogger=20v0.1.0=20c?= =?UTF-8?q?ore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 36 ++++++++++++++ README.md | 25 ++++++++++ bitlogger/BitLogger.mbt | 2 + bitlogger/BitLogger_test.mbt | 31 ++++++++++++ bitlogger/BitLogger_wbtest.mbt | 54 +++++++++++++++++++++ bitlogger/README.mbt.md | 50 +++++++++++++++++++ bitlogger/formatter.mbt | 43 +++++++++++++++++ bitlogger/global.mbt | 39 +++++++++++++++ bitlogger/level.mbt | 31 ++++++++++++ bitlogger/logger.mbt | 87 ++++++++++++++++++++++++++++++++++ bitlogger/moon.pkg | 7 +++ bitlogger/record.mbt | 26 ++++++++++ bitlogger/sinks.mbt | 71 +++++++++++++++++++++++++++ examples/basic/main.mbt | 36 ++++++++++++++ examples/basic/moon.pkg | 7 +++ moon.mod.json | 13 +++++ 16 files changed, 558 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 bitlogger/BitLogger.mbt create mode 100644 bitlogger/BitLogger_test.mbt create mode 100644 bitlogger/BitLogger_wbtest.mbt create mode 100644 bitlogger/README.mbt.md create mode 100644 bitlogger/formatter.mbt create mode 100644 bitlogger/global.mbt create mode 100644 bitlogger/level.mbt create mode 100644 bitlogger/logger.mbt create mode 100644 bitlogger/moon.pkg create mode 100644 bitlogger/record.mbt create mode 100644 bitlogger/sinks.mbt create mode 100644 examples/basic/main.mbt create mode 100644 examples/basic/moon.pkg create mode 100644 moon.mod.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8263d00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e96d2df --- /dev/null +++ b/README.md @@ -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). diff --git a/bitlogger/BitLogger.mbt b/bitlogger/BitLogger.mbt new file mode 100644 index 0000000..779803c --- /dev/null +++ b/bitlogger/BitLogger.mbt @@ -0,0 +1,2 @@ +///| +/// BitLogger public API surface. diff --git a/bitlogger/BitLogger_test.mbt b/bitlogger/BitLogger_test.mbt new file mode 100644 index 0000000..b7f154c --- /dev/null +++ b/bitlogger/BitLogger_test.mbt @@ -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") +} diff --git a/bitlogger/BitLogger_wbtest.mbt b/bitlogger/BitLogger_wbtest.mbt new file mode 100644 index 0000000..7e34081 --- /dev/null +++ b/bitlogger/BitLogger_wbtest.mbt @@ -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") +} diff --git a/bitlogger/README.mbt.md b/bitlogger/README.mbt.md new file mode 100644 index 0000000..b44dca4 --- /dev/null +++ b/bitlogger/README.mbt.md @@ -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. diff --git a/bitlogger/formatter.mbt b/bitlogger/formatter.mbt new file mode 100644 index 0000000..cc0f9a0 --- /dev/null +++ b/bitlogger/formatter.mbt @@ -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() + } +} diff --git a/bitlogger/global.mbt b/bitlogger/global.mbt new file mode 100644 index 0000000..8039e81 --- /dev/null +++ b/bitlogger/global.mbt @@ -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) +} diff --git a/bitlogger/level.mbt b/bitlogger/level.mbt new file mode 100644 index 0000000..83f99d1 --- /dev/null +++ b/bitlogger/level.mbt @@ -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() +} diff --git a/bitlogger/logger.mbt b/bitlogger/logger.mbt new file mode 100644 index 0000000..684361a --- /dev/null +++ b/bitlogger/logger.mbt @@ -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) +} diff --git a/bitlogger/moon.pkg b/bitlogger/moon.pkg new file mode 100644 index 0000000..5ebe50c --- /dev/null +++ b/bitlogger/moon.pkg @@ -0,0 +1,7 @@ +import { + "moonbitlang/core/array", + "moonbitlang/core/builtin", + "moonbitlang/core/env" @env, + "moonbitlang/core/json", + "moonbitlang/core/ref", +} diff --git a/bitlogger/record.mbt b/bitlogger/record.mbt new file mode 100644 index 0000000..ebe44d1 --- /dev/null +++ b/bitlogger/record.mbt @@ -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 } +} diff --git a/bitlogger/sinks.mbt b/bitlogger/sinks.mbt new file mode 100644 index 0000000..e91f556 --- /dev/null +++ b/bitlogger/sinks.mbt @@ -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) +} diff --git a/examples/basic/main.mbt b/examples/basic/main.mbt new file mode 100644 index 0000000..82d58ed --- /dev/null +++ b/examples/basic/main.mbt @@ -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") +} diff --git a/examples/basic/moon.pkg b/examples/basic/moon.pkg new file mode 100644 index 0000000..c6d65f6 --- /dev/null +++ b/examples/basic/moon.pkg @@ -0,0 +1,7 @@ +import { + "miaom/BitLogger/bitlogger" @lib, +} + +options( + "is-main": true, +) diff --git a/moon.mod.json b/moon.mod.json new file mode 100644 index 0000000..ffc4fbd --- /dev/null +++ b/moon.mod.json @@ -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." +}