mirror of
https://github.com/Nanaloveyuki/BitLogger.git
synced 2026-05-30 15:42:25 +00:00
🎉 Release BitLogger v0.1.0 core
This commit is contained in:
@@ -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
|
||||
@@ -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).
|
||||
@@ -0,0 +1,2 @@
|
||||
///|
|
||||
/// BitLogger public API surface.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
"moonbitlang/core/array",
|
||||
"moonbitlang/core/builtin",
|
||||
"moonbitlang/core/env" @env,
|
||||
"moonbitlang/core/json",
|
||||
"moonbitlang/core/ref",
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
"miaom/BitLogger/bitlogger" @lib,
|
||||
}
|
||||
|
||||
options(
|
||||
"is-main": true,
|
||||
)
|
||||
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user