From 18479a8b6fee30e20a328b13d935e60ec0cbcd90 Mon Sep 17 00:00:00 2001 From: Nanaloveyuki Date: Sat, 9 May 2026 20:41:11 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20template-based=20text=20forma?= =?UTF-8?q?tter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +++++-- bitlogger/BitLogger_test.mbt | 5 ++- bitlogger/BitLogger_wbtest.mbt | 29 +++++++++++++++++ bitlogger/README.mbt.md | 18 ++++++++--- bitlogger/config.mbt | 6 ++++ bitlogger/formatter.mbt | 59 +++++++++++++++++++++++++++++++++- docs/README-en.md | 11 +++++-- docs/changes/0.3.0.md | 5 +++ examples/basic/main.mbt | 10 ++++-- 9 files changed, 140 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3d85957..56208e0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ BitLogger 是一个基于 MoonBit 编写的结构化日志库。 - 🔎 可复用过滤:提供 `target_has_prefix(...)`、`message_contains(...)`、`level_at_least(...)`、`field_equals(...)` 等过滤辅助函数。 - 🩹 可变换 Record:支持 `with_patch(...)`、`patch_sink(...)` 与脱敏/补字段/message 变换 helper。 - 📮 显式队列:支持 `queued_sink(...)` / `with_queue(...)`、有界积压与溢出策略,作为后续 async sink 的 runtime-safe 基础。 -- 🧾 可配置文本格式:支持 `text_formatter(...)`、`format_text(...)`、`text_console_sink(...)` 和 `formatted_callback_sink(...)`。 +- 🧾 可配置文本格式:支持 `text_formatter(...)`、`format_text(...)`、`text_console_sink(...)`、`formatted_callback_sink(...)` 与模板化 `template` 输出。 - 💾 Native 文件输出:支持 `file_sink(...)`,但当前仅保证 `native/llvm` backend 可用。 - 📦 面向 MoonBit:API 和工程结构围绕 MoonBit 的 package / visibility / toolchain 现实约束设计。 @@ -130,7 +130,11 @@ ignore(logger.sink.flush())
自定义文本 formatter 示例 ```moonbit -let formatter = text_formatter(show_timestamp=false, separator=" | ") +let formatter = text_formatter( + show_timestamp=false, + field_separator=",", + template="[{level}] {target} {message} :: {fields}", +) let logger = Logger::new(text_console_sink(formatter), target="pretty") logger.info("hello", fields=[field("mode", "pretty")]) @@ -142,7 +146,7 @@ logger.info("hello", fields=[field("mode", "pretty")]) ```moonbit let config = parse_logger_config_text( - "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", + "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", ) let logger = build_logger(config) @@ -179,6 +183,7 @@ if native_files_supported() { - 当前提供 JSON 配置层:`parse_logger_config_text(...)`、`stringify_logger_config(...)`、`build_logger(...)` - 已支持字段:`min_level`、`target`、`timestamp`、`sink.kind`、`sink.path`、`sink.append`、`sink.auto_flush`、`sink.text_formatter`、`queue` +- `sink.text_formatter.template` 当前支持固定 token:`{timestamp}`、`{timestamp_ms}`、`{level}`、`{target}`、`{message}`、`{fields}` - 当前可由配置直接组装的 sink 类型:`console`、`json_console`、`text_console`、`file` - `queue` 会作为显式包装层附着在最终 sink 外侧;这仍然是同步 drain 模型,不是 async runtime diff --git a/bitlogger/BitLogger_test.mbt b/bitlogger/BitLogger_test.mbt index f70473d..79e332f 100644 --- a/bitlogger/BitLogger_test.mbt +++ b/bitlogger/BitLogger_test.mbt @@ -41,7 +41,7 @@ test "logger config parser reads core options" { test "logger config parser reads formatter and queue options" { let config = parse_logger_config_text( - "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false}},\"queue\":{\"max_pending\":32,\"overflow\":\"DropOldest\"}}", + "{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false,\"template\":\"[{level}] {message}\"}},\"queue\":{\"max_pending\":32,\"overflow\":\"DropOldest\"}}", ) inspect(match config.sink.kind { SinkKind::TextConsole => "TextConsole" @@ -49,6 +49,7 @@ test "logger config parser reads formatter and queue options" { }, content="TextConsole") inspect(config.sink.text_formatter.separator, content=" | ") inspect(config.sink.text_formatter.show_timestamp, content="false") + inspect(config.sink.text_formatter.template, content="[{level}] {message}") match config.queue { Some(queue) => { inspect(queue.max_pending, content="32") @@ -76,6 +77,7 @@ test "logger config stringify roundtrips stable fields" { show_fields=true, separator=" | ", field_separator=",", + template="[{level}] {target} {message}", ), ), queue=Some(QueueConfig::new(8, overflow=QueueOverflowPolicy::DropNewest)), @@ -86,6 +88,7 @@ test "logger config stringify roundtrips stable fields" { inspect(config.target, content="api") inspect(config.timestamp, content="true") inspect(config.sink.text_formatter.separator, content=" | ") + inspect(config.sink.text_formatter.template, content="[{level}] {target} {message}") } test "build logger from config supports queued text console" { diff --git a/bitlogger/BitLogger_wbtest.mbt b/bitlogger/BitLogger_wbtest.mbt index 0081285..9a5fff4 100644 --- a/bitlogger/BitLogger_wbtest.mbt +++ b/bitlogger/BitLogger_wbtest.mbt @@ -41,6 +41,35 @@ test "text formatter can emit message only" { inspect(format_text(rec, formatter=message_only), content="just message") } +test "text formatter supports template rendering" { + let rec = record( + Level::Info, + "template hello", + timestamp_ms=123UL, + target="svc.api", + fields=[field("user", "alice"), field("request_id", "42")], + ) + let formatter = text_formatter( + separator=" | ", + field_separator=",", + template="[{level}] {target} {message} :: {fields} @{timestamp}", + ) + inspect( + format_text(rec, formatter=formatter), + content="[INFO] svc.api template hello :: user=alice,request_id=42 @123", + ) +} + +test "text formatter template respects disabled fields" { + let rec = record(Level::Warn, "just message", target="svc") + let formatter = text_formatter( + show_target=false, + show_fields=false, + template="[{level}] {target}{message}{fields}", + ) + inspect(format_text(rec, formatter=formatter), content="[WARN] just message") +} + test "formatted callback sink receives rendered text" { let rendered : Ref[String] = Ref::new("") let sink = text_callback_sink( diff --git a/bitlogger/README.mbt.md b/bitlogger/README.mbt.md index 755c319..64eec71 100644 --- a/bitlogger/README.mbt.md +++ b/bitlogger/README.mbt.md @@ -36,8 +36,8 @@ BitLogger 是一个基于 MoonBit 的结构化日志库。 - 支持 `with_patch(...)`、`patch_sink(...)` 以及常见 record patch helper - explicit queued delivery via `queued_sink(...)` and `with_queue(...)` - 支持 `queued_sink(...)`、`with_queue(...)`、有界积压与溢出策略 -- configurable text formatting via `text_formatter(...)`, `format_text(...)`, and `text_console_sink(...)` -- 支持 `text_formatter(...)`、`format_text(...)`、`text_console_sink(...)` 等文本格式化能力 +- configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output +- 支持 `text_formatter(...)`、`format_text(...)`、`text_console_sink(...)` 以及模板化 `template` 文本输出 - JSON config parsing via `parse_logger_config_text(...)` and `stringify_logger_config(...)` - 支持 `parse_logger_config_text(...)`、`stringify_logger_config(...)` 进行最小 JSON 配置读写 - config-driven logger assembly via `build_logger(...)` @@ -144,7 +144,11 @@ test { ```mbt check test { - let formatter = text_formatter(show_timestamp=false, separator=" | ") + let formatter = text_formatter( + show_timestamp=false, + field_separator=",", + template="[{level}] {target} {message} :: {fields}", + ) let logger = Logger::new(text_console_sink(formatter), target="pretty") logger.info("hello", fields=[field("mode", "pretty")]) } @@ -153,7 +157,7 @@ test { ```mbt check test { let config = parse_logger_config_text( - "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", + "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", ) let logger = build_logger(config) logger.info("configured from json") @@ -161,6 +165,12 @@ test { } ``` +## Formatter Template / 模板格式 + +- supported tokens / 支持的 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}` +- disabled or missing parts render as empty text / 被关闭或缺失的部分会渲染为空字符串 +- `template` is intentionally a simple token replacement layer, not a full DSL / `template` 当前刻意保持为轻量 token 替换层,而不是完整 DSL + ```mbt check test { if native_files_supported() { diff --git a/bitlogger/config.mbt b/bitlogger/config.mbt index 92d70c4..c7991e7 100644 --- a/bitlogger/config.mbt +++ b/bitlogger/config.mbt @@ -16,6 +16,7 @@ pub struct TextFormatterConfig { show_fields : Bool separator : String field_separator : String + template : String } pub fn TextFormatterConfig::new( @@ -25,6 +26,7 @@ pub fn TextFormatterConfig::new( show_fields~ : Bool = true, separator~ : String = " ", field_separator~ : String = " ", + template~ : String = "", ) -> TextFormatterConfig { { show_timestamp, @@ -33,6 +35,7 @@ pub fn TextFormatterConfig::new( show_fields, separator, field_separator, + template, } } @@ -44,6 +47,7 @@ pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextForm show_fields=self.show_fields, separator=self.separator, field_separator=self.field_separator, + template=self.template, ) } @@ -344,6 +348,7 @@ fn parse_text_formatter_config(value : @json_parser.JsonValue) -> TextFormatterC show_fields=get_bool(obj, "show_fields", default=true), separator=get_string(obj, "separator", default=" "), field_separator=get_string(obj, "field_separator", default=" "), + template=get_string(obj, "template", default=""), ) } @@ -416,6 +421,7 @@ fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_parser.J "show_fields": @json_parser.JsonValue::Bool(config.show_fields), "separator": @json_parser.JsonValue::String(config.separator), "field_separator": @json_parser.JsonValue::String(config.field_separator), + "template": @json_parser.JsonValue::String(config.template), }) } diff --git a/bitlogger/formatter.mbt b/bitlogger/formatter.mbt index 0598361..47356a9 100644 --- a/bitlogger/formatter.mbt +++ b/bitlogger/formatter.mbt @@ -7,6 +7,7 @@ pub struct TextFormatter { show_fields : Bool separator : String field_separator : String + template : String } pub fn text_formatter( @@ -16,8 +17,17 @@ pub fn text_formatter( show_fields~ : Bool = true, separator~ : String = " ", field_separator~ : String = " ", + template~ : String = "", ) -> TextFormatter { - { show_timestamp, show_level, show_target, show_fields, separator, field_separator } + { + show_timestamp, + show_level, + show_target, + show_fields, + separator, + field_separator, + template, + } } fn fields_to_json(fields : Array[Field]) -> Json { @@ -32,7 +42,54 @@ fn format_fields(fields : Array[Field], separator : String) -> String { fields.map(fn(f) { "\{f.key}=\{f.value}" }).join(separator) } +fn timestamp_text(rec : Record, formatter : TextFormatter) -> String { + if formatter.show_timestamp && rec.timestamp_ms != 0UL { + rec.timestamp_ms.to_string() + } else { + "" + } +} + +fn level_text(rec : Record, formatter : TextFormatter) -> String { + if formatter.show_level { + rec.level.label() + } else { + "" + } +} + +fn target_text(rec : Record, formatter : TextFormatter) -> String { + if formatter.show_target && rec.target != "" { + rec.target + } else { + "" + } +} + +fn fields_text(rec : Record, formatter : TextFormatter) -> String { + if formatter.show_fields && rec.fields.length() != 0 { + format_fields(rec.fields, formatter.field_separator) + } else { + "" + } +} + +fn render_template(rec : Record, formatter : TextFormatter) -> String { + formatter.template + .replace_all(old="{timestamp}", new=timestamp_text(rec, formatter)) + .replace_all(old="{timestamp_ms}", new=timestamp_text(rec, formatter)) + .replace_all(old="{level}", new=level_text(rec, formatter)) + .replace_all(old="{target}", new=target_text(rec, formatter)) + .replace_all(old="{message}", new=rec.message) + .replace_all(old="{fields}", new=fields_text(rec, formatter)) + .trim() + .to_owned() +} + pub fn format_text(rec : Record, formatter~ : TextFormatter = text_formatter()) -> String { + if formatter.template != "" { + return render_template(rec, formatter) + } let parts : Array[String] = [] if formatter.show_timestamp && rec.timestamp_ms != 0UL { parts.push("[\{rec.timestamp_ms.to_string()}]") diff --git a/docs/README-en.md b/docs/README-en.md index 61faaf8..22c3a15 100644 --- a/docs/README-en.md +++ b/docs/README-en.md @@ -22,7 +22,7 @@ BitLogger currently provides: - patch helpers such as `prefix_message(...)`, `append_fields(...)`, and `redact_fields(...)` - explicit queued delivery via `queued_sink(...)` and `with_queue(...)` - bounded backlog with `QueueOverflowPolicy::DropNewest` and `QueueOverflowPolicy::DropOldest` -- configurable text formatting via `text_formatter(...)`, `format_text(...)`, and `text_console_sink(...)` +- configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output - formatter-based callback integration via `formatted_callback_sink(...)` - native-only file output via `file_sink(...)` - `native_files_supported()` for backend capability detection @@ -124,7 +124,11 @@ ignore(logger.sink.flush()) Custom text formatter: ```moonbit -let formatter = text_formatter(show_timestamp=false, separator=" | ") +let formatter = text_formatter( + show_timestamp=false, + field_separator=",", + template="[{level}] {target} {message} :: {fields}", +) let logger = Logger::new(text_console_sink(formatter), target="pretty") logger.info("hello", fields=[field("mode", "pretty")]) @@ -134,7 +138,7 @@ JSON config loading: ```moonbit let config = parse_logger_config_text( - "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", + "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", ) let logger = build_logger(config) @@ -168,6 +172,7 @@ if native_files_supported() { - BitLogger now includes a JSON config layer via `parse_logger_config_text(...)`, `stringify_logger_config(...)`, and `build_logger(...)`. - Supported keys include `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.text_formatter`, and `queue`. +- `sink.text_formatter.template` currently supports fixed tokens: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, and `{fields}`. - Config-driven sink assembly currently supports `console`, `json_console`, `text_console`, and `file`. - `queue` remains a synchronous bounded wrapper around the final sink, not an async runtime. diff --git a/docs/changes/0.3.0.md b/docs/changes/0.3.0.md index 170b814..d39dee0 100644 --- a/docs/changes/0.3.0.md +++ b/docs/changes/0.3.0.md @@ -25,6 +25,8 @@ version 0.3.0 - feat: support config keys `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.text_formatter`, and `queue` - feat: support config-driven sink assembly for `console`, `json_console`, `text_console`, and `file` - feat: use `maria/json_parser` as the first external dependency for practical config loading +- feat: add `TextFormatter.template` and `TextFormatterConfig.template` for template-driven text rendering +- feat: support formatter tokens `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, and `{fields}` in both runtime and JSON config paths ### Test @@ -34,6 +36,7 @@ version 0.3.0 - test: cover config-built queued text logger flushing and pending count behavior - test: cover partial drain behavior for config-built queued logger - test: cover dropped-count reporting for bounded config-built queue +- test: cover template-based formatter rendering and disabled token behavior - test: add async logger lifecycle, config roundtrip, and batching/flush policy test seeds - build: verify `bitlogger_async --target native` and `examples/async_basic --target native` compile successfully @@ -44,11 +47,13 @@ version 0.3.0 - docs: add `examples/async_basic` to show async worker startup and queue-backed logging flow - docs: update `examples/async_basic` to use unified JSON-driven async logger config - docs: update root README, English README, and Mooncake README with config usage notes +- docs: update formatter examples to demonstrate template-based text rendering - docs: update root README and English README with async adapter notes and current scope - chore: ignore local `.mooncakes/` cache directory in git ### Notes - current config scope is still intentionally constrained to stable built-in sink shapes +- formatter templates are intentionally limited to simple token replacement and do not yet include conditional blocks or alignment/padding controls - queue wrapping remains synchronous drain-based delivery, not async runtime scheduling - async logging remains an isolated adapter layer and currently targets `native/llvm` only diff --git a/examples/basic/main.mbt b/examples/basic/main.mbt index 80928b5..217e8b4 100644 --- a/examples/basic/main.mbt +++ b/examples/basic/main.mbt @@ -35,7 +35,13 @@ fn main { callback_logger.info("callback sink ready") let pretty_logger = @lib.Logger::new( - @lib.text_console_sink(@lib.text_formatter(show_timestamp=false, separator=" | ")), + @lib.text_console_sink( + @lib.text_formatter( + show_timestamp=false, + field_separator=",", + template="[{level}] {target} {message} :: {fields}", + ), + ), min_level=@lib.Level::Info, target="pretty", ) @@ -102,7 +108,7 @@ fn main { ignore(queued_logger.sink.flush()) let config_logger = @lib.parse_and_build_logger( - "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"separator\":\" | \",\"show_timestamp\":false}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", + "{\"min_level\":\"debug\",\"target\":\"config.demo\",\"timestamp\":true,\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"show_timestamp\":false,\"field_separator\":\",\",\"template\":\"[{level}] {target} {message} :: {fields}\"}},\"queue\":{\"max_pending\":2,\"overflow\":\"DropOldest\"}}", ) catch { err => { ignore(err)