diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d77be1..c8825c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,9 +67,15 @@ jobs: moon check --deny-warn moon test --deny-warn moon check --target native --deny-warn + moon check --target wasm --deny-warn moon check --target wasm-gc --deny-warn moon check --target js --deny-warn + - name: Test wasm path + shell: bash + run: | + moon test --target wasm --deny-warn + - name: Run sync example shell: bash run: | @@ -105,15 +111,17 @@ jobs: run: | moon update - - name: Check async package on wasm-gc and js + - name: Check async package on wasm, wasm-gc and js shell: bash run: | + moon check src-async --target wasm --deny-warn moon check src-async --target wasm-gc --deny-warn moon check src-async --target js --deny-warn - - name: Test async package on wasm-gc and js + - name: Test async package on wasm, wasm-gc and js shell: bash run: | + moon test src-async --target wasm --deny-warn moon test src-async --target wasm-gc --deny-warn moon test src-async --target js --deny-warn diff --git a/README.md b/README.md index 0475616..555d9f5 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,8 @@ fn main { ## 支持情况 -- `BitLogger` 当前在 CI 中检查/验证的目标是 `native`、`js`、`wasm-gc` -- `bitlogger_async` 当前在 CI 中检查 `native`、`js`、`wasm-gc`,测试覆盖 `native`、`js`、`wasm-gc` -- `wasm` 目标在源码 `moon.pkg` 中保留声明,但当前未纳入 CI 验证口径 +- `BitLogger` 当前在 CI 中检查/验证的目标是 `native`、`wasm`、`js`、`wasm-gc` +- `bitlogger_async` 当前在 CI 中检查和测试 `native`、`wasm`、`js`、`wasm-gc` - `llvm` 目前按实验性目标处理,当前环境未完成验证 - 文件输出是 native 能力;跨端代码里建议先判断 `native_files_supported()` - `src-async` 可用,但示例 `examples/async_basic` 目前仍按 native 入口提供 @@ -69,6 +68,7 @@ fn main { - 配置驱动构建:`build_logger(...)`、`build_async_logger(...)` - 组合能力:queue、filter、patch、fanout、split、callback - 异步日志:独立 `src-async` package +- Trace context:标准 `trace_id`、`span_id`、`trace_flags`、`trace_state` 字段绑定 ## 文档 diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..682077c --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,11 @@ +# BitLogger Benchmarks + +Run the native baseline from the repository root: + +```powershell +moon bench benchmarks --target native --release --deny-warn +``` + +The suite measures callback emission, trace-context field binding, synchronous queue enqueue-and-flush, and native file writes. It intentionally has no pass/fail threshold: compare results only on the same machine, toolchain, target, and power profile. + +Async throughput remains lifecycle-sensitive and is exercised by the native integration test rather than mixed into these synchronous microbenchmarks. Record its end-to-end measurements separately when profiling an application workload. diff --git a/benchmarks/bitlogger_bench.mbt b/benchmarks/bitlogger_bench.mbt new file mode 100644 index 0000000..05f06a1 --- /dev/null +++ b/benchmarks/bitlogger_bench.mbt @@ -0,0 +1,61 @@ +///| +let benchmark_record : @log.Record = @log.Record::new( + @log.Level::Info, + "benchmark record", + target="bench", + fields=[@log.field("component", "bitlogger")], +) + +///| +test "bench callback logger emission" (it : @bench.T) { + let writes : Ref[Int] = Ref(0) + let logger = @log.Logger::new(@log.callback_sink(fn(_) { writes.val += 1 })) + it.bench(fn() { + logger.info("benchmark record", fields=[ + @log.field("component", "bitlogger"), + ]) + it.keep(writes.val) + }) +} + +///| +test "bench trace context field binding" (it : @bench.T) { + let writes : Ref[Int] = Ref(0) + let context = @log.trace_context("trace-bench", "span-bench") + let logger = @log.Logger::new(@log.callback_sink(fn(_) { writes.val += 1 })).with_trace_context( + context, + ) + it.bench(fn() { + logger.info("benchmark record", fields=[@log.field("event", "request")]) + it.keep(writes.val) + }) +} + +///| +test "bench queued logger enqueue and flush" (it : @bench.T) { + let writes : Ref[Int] = Ref(0) + let logger = @log.Logger::new(@log.callback_sink(fn(_) { writes.val += 1 })).with_queue( + max_pending=8, + ) + it.bench(fn() { + logger.info("benchmark record") + it.keep(logger.sink.flush()) + }) +} + +///| +test "bench native file sink write" (it : @bench.T) { + let sink = @log.file_sink( + "logs/bitlogger-benchmark.log", + append=false, + auto_flush=false, + formatter=fn(rec) { rec.message }, + ) + if sink.is_available() { + it.bench(fn() { + sink.write(benchmark_record) + it.keep(sink.write_failures()) + }) + ignore(sink.close()) + } +} diff --git a/benchmarks/moon.pkg b/benchmarks/moon.pkg new file mode 100644 index 0000000..d18a9cf --- /dev/null +++ b/benchmarks/moon.pkg @@ -0,0 +1,4 @@ +import { + "Nanaloveyuki/BitLogger/src" @log, + "moonbitlang/core/bench", +} diff --git a/benchmarks/pkg.generated.mbti b/benchmarks/pkg.generated.mbti new file mode 100644 index 0000000..ee73bfb --- /dev/null +++ b/benchmarks/pkg.generated.mbti @@ -0,0 +1,12 @@ +// Generated using `moon info`, DON'T EDIT IT +package "Nanaloveyuki/BitLogger/benchmarks" + +// Values + +// Errors + +// Types and methods + +// Type aliases + +// Traits diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6a0b166..6a5b381 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -121,6 +121,7 @@ function buildExtendSidebar(): DefaultTheme.SidebarItem[] { { text: 'Sink Composition', link: '/extend/composition' }, { text: 'Text Formatting', link: '/extend/formatting' }, { text: 'Target Boundaries', link: '/extend/targets' }, + { text: 'Trace Context', link: '/extend/observability' }, ] } @@ -141,6 +142,7 @@ function buildChineseExtendSidebar(): DefaultTheme.SidebarItem[] { { text: 'Sink 组合', link: '/zh/extend/composition' }, { text: '文本格式与样式', link: '/zh/extend/formatting' }, { text: '目标平台边界', link: '/zh/extend/targets' }, + { text: 'Trace Context', link: '/zh/extend/observability' }, ] } diff --git a/docs/README-en.md b/docs/README-en.md index 51bc959..22e6e2e 100644 --- a/docs/README-en.md +++ b/docs/README-en.md @@ -44,7 +44,7 @@ Continue with the [Examples guide](./examples/index.md): console fields, file ro ## Support Status -- Current local verification covers `native`, `js`, `wasm`, and `wasm-gc` for the main `src` package and `src-async` +- CI checks and tests `native`, `wasm`, `js`, and `wasm-gc` for the main `src` package and `src-async` - `llvm` is still treated as experimental in the current release context and was not locally re-verified in the current environment - The source packages still declare `wasm` support alongside `native`, `llvm`, `js`, and `wasm-gc`; see [`target-verification.md`](./api/target-verification.md) for the current release-facing verification boundary - File output is a native capability; check `native_files_supported()` in cross-target code @@ -58,6 +58,7 @@ Continue with the [Examples guide](./examples/index.md): console fields, file ro - Config-based builders: `build_logger(...)` and `build_async_logger(...)` - Composition helpers: queue, filter, patch, fanout, split, callback - Separate async package under `src-async` +- Trace-context binding through standard `trace_id`, `span_id`, `trace_flags`, and `trace_state` fields ## Documentation diff --git a/docs/api/async-logger-with-trace-context.md b/docs/api/async-logger-with-trace-context.md new file mode 100644 index 0000000..8452ed5 --- /dev/null +++ b/docs/api/async-logger-with-trace-context.md @@ -0,0 +1,21 @@ +--- +name: async-logger-with-trace-context +group: api +category: async +update-time: 20260719 +description: Bind TraceContext fields before records enter an AsyncLogger queue. +key-word: + - async + - trace + - context +--- + +## Async-logger-with-trace-context + +```moonbit +let contextual = logger.with_trace_context( + @bitlogger.trace_context("trace-1", "span-1"), +) +``` + +The helper stores the mapped fields on the returned async logger and preserves its queue, lifecycle, target, and threshold state. Context is added before records are enqueued. diff --git a/docs/api/index.md b/docs/api/index.md index 6dbed5c..885a351 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -31,6 +31,7 @@ BitLogger API navigation. - [logger-with-timestamp.md](./logger-with-timestamp.md) - [logger-with-context-fields.md](./logger-with-context-fields.md) - [logger-bind.md](./logger-bind.md) +- [logger-with-trace-context.md](./logger-with-trace-context.md) - [logger-with-filter.md](./logger-with-filter.md) - [logger-with-patch.md](./logger-with-patch.md) - [logger-with-queue.md](./logger-with-queue.md) @@ -99,6 +100,10 @@ BitLogger API navigation. - [fields.md](./fields.md) - [text-formatter-config-type.md](./text-formatter-config-type.md) +## Observability + +- [trace-context.md](./trace-context.md) + ## Record and level - [record.md](./record.md) @@ -266,6 +271,7 @@ BitLogger API navigation. - [async-logger-with-min-level.md](./async-logger-with-min-level.md) - [async-logger-with-timestamp.md](./async-logger-with-timestamp.md) - [async-logger-with-context-fields.md](./async-logger-with-context-fields.md) +- [async-logger-with-trace-context.md](./async-logger-with-trace-context.md) - [async-logger-with-filter.md](./async-logger-with-filter.md) - [async-logger-with-patch.md](./async-logger-with-patch.md) @@ -322,6 +328,7 @@ BitLogger API navigation. - [library-logger-child.md](./library-logger-child.md) - [library-logger-with-context-fields.md](./library-logger-with-context-fields.md) - [library-logger-bind.md](./library-logger-bind.md) +- [library-logger-with-trace-context.md](./library-logger-with-trace-context.md) - [library-logger-is-enabled.md](./library-logger-is-enabled.md) - [library-logger-log.md](./library-logger-log.md) - [library-logger-info.md](./library-logger-info.md) @@ -341,6 +348,7 @@ BitLogger API navigation. - [library-async-logger-child.md](./library-async-logger-child.md) - [library-async-logger-with-context-fields.md](./library-async-logger-with-context-fields.md) - [library-async-logger-bind.md](./library-async-logger-bind.md) +- [library-async-logger-with-trace-context.md](./library-async-logger-with-trace-context.md) - [library-async-logger-is-enabled.md](./library-async-logger-is-enabled.md) - [library-async-logger-log.md](./library-async-logger-log.md) - [library-async-logger-info.md](./library-async-logger-info.md) diff --git a/docs/api/library-async-logger-with-trace-context.md b/docs/api/library-async-logger-with-trace-context.md new file mode 100644 index 0000000..d3214ce --- /dev/null +++ b/docs/api/library-async-logger-with-trace-context.md @@ -0,0 +1,15 @@ +--- +name: library-async-logger-with-trace-context +group: api +category: facade +update-time: 20260719 +description: Bind TraceContext fields through the restricted LibraryAsyncLogger facade. +key-word: + - library + - async + - trace +--- + +## Library-async-logger-with-trace-context + +`LibraryAsyncLogger::with_trace_context(...)` returns a derived library facade with `trace_id`, `span_id`, `trace_flags`, and optional `trace_state` attached to each later record. It does not start a worker or change lifecycle state. diff --git a/docs/api/library-logger-with-trace-context.md b/docs/api/library-logger-with-trace-context.md new file mode 100644 index 0000000..64a9f9c --- /dev/null +++ b/docs/api/library-logger-with-trace-context.md @@ -0,0 +1,20 @@ +--- +name: library-logger-with-trace-context +group: api +category: facade +update-time: 20260719 +description: Bind TraceContext fields through the restricted LibraryLogger facade. +key-word: + - library + - trace + - context +--- + +## Library-logger-with-trace-context + +```moonbit +let logger = LibraryLogger::new(console_sink()) + .with_trace_context(trace_context("trace-1", "span-1")) +``` + +The derived facade preserves its library-facing API while wrapping the sink with context fields. Use `to_logger()` only when broader composition is required. diff --git a/docs/api/logger-with-trace-context.md b/docs/api/logger-with-trace-context.md new file mode 100644 index 0000000..8026cc7 --- /dev/null +++ b/docs/api/logger-with-trace-context.md @@ -0,0 +1,22 @@ +--- +name: logger-with-trace-context +group: api +category: logger +update-time: 20260719 +description: Bind a TraceContext to every record from a synchronous Logger. +key-word: + - logger + - trace + - context +--- + +## Logger-with-trace-context + +```moonbit +pub fn[S] Logger::with_trace_context( + self : Logger[S], + context : TraceContext, +) -> Logger[ContextSink[S]] +``` + +Returns a derived logger that prepends the context fields to every record. The original logger is unchanged. This is equivalent to `with_context_fields(context.as_fields())`, while making trace intent explicit. diff --git a/docs/api/trace-context.md b/docs/api/trace-context.md new file mode 100644 index 0000000..f8e50a1 --- /dev/null +++ b/docs/api/trace-context.md @@ -0,0 +1,25 @@ +--- +name: trace-context +group: api +category: observability +update-time: 20260719 +description: Represent and bind distributed trace metadata using stable structured-log fields. +key-word: + - trace + - context + - observability +--- + +## Trace-context + +`TraceContext` carries the standard log fields used to correlate records with distributed traces: `trace_id`, `span_id`, `trace_flags`, and optional `trace_state`. + +```moonbit +let context = trace_context( + "4bf92f3577b34da6a3ce929d0e0e4736", + "00f067aa0ba902b7", + trace_state="vendor=value", +) +``` + +Use `context.as_fields()` when integrating with a custom carrier, or pass the value to a logger's `with_trace_context(...)` helper. BitLogger does not parse HTTP headers or export OTLP data; an application-owned tracing library remains responsible for propagation and sampling. diff --git a/docs/extend/index.md b/docs/extend/index.md index f65157c..62f71d2 100644 --- a/docs/extend/index.md +++ b/docs/extend/index.md @@ -10,5 +10,6 @@ Start from an [Example flow](../examples/index.md), then add one extension at a | Send records to multiple destinations or route by level | [Sink composition](./composition.md) | More explicit construction than presets. | | Control terminal appearance | [Text formatting](./formatting.md) | Formatting affects presentation, not record structure. | | Share code across native and web targets | [Target boundaries](./targets.md) | File and async runtime behavior differ by backend. | +| Correlate logs with distributed requests | [Trace context](./observability.md) | Propagation and export stay application-owned. | For exact function signatures, follow each page's links into the [API reference](../api/index.md). diff --git a/docs/extend/observability.md b/docs/extend/observability.md new file mode 100644 index 0000000..445a16d --- /dev/null +++ b/docs/extend/observability.md @@ -0,0 +1,18 @@ +# Trace Context + +BitLogger keeps trace interoperability in its structured record model rather than owning HTTP propagation or a telemetry transport. Use the tracing library already selected by the application to extract a request context, then bind its identifiers to the logger. + +```moonbit +let request_logger = logger.with_trace_context( + @log.trace_context( + trace_id, + span_id, + trace_flags="01", + trace_state=trace_state, + ), +) +``` + +Every record from `request_logger` receives `trace_id`, `span_id`, `trace_flags`, and, when supplied, `trace_state`. The base logger is unchanged, so a child request cannot leak identifiers into unrelated work. + +Use `context.as_fields()` for a custom logger facade. BitLogger intentionally does not parse `traceparent` headers, create spans, make sampling decisions, or export OTLP data; those concerns remain with the application tracing implementation. diff --git a/docs/zh/extend/index.md b/docs/zh/extend/index.md index c6db287..bd33b8d 100644 --- a/docs/zh/extend/index.md +++ b/docs/zh/extend/index.md @@ -10,5 +10,6 @@ | 同时发送到多个位置或按 level 路由 | [Sink 组合](./composition.md) | 比 preset 更需要显式构造。 | | 调整终端可读性 | [文本格式](./formatting.md) | 格式只改变呈现,不改变记录结构。 | | 在 native 与 web 目标之间共享代码 | [目标平台边界](./targets.md) | 文件与异步运行时行为因后端而异。 | +| 将日志关联到分布式请求 | [Trace context](./observability.md) | 传播与导出仍由应用负责。 | 需要精确函数签名时,沿页面链接进入[英文 API 参考](../../api/index.md)。 diff --git a/docs/zh/extend/observability.md b/docs/zh/extend/observability.md new file mode 100644 index 0000000..11c65a0 --- /dev/null +++ b/docs/zh/extend/observability.md @@ -0,0 +1,13 @@ +# Trace Context + +BitLogger 通过结构化字段承载 trace 上下文,不负责 HTTP 传播或遥测传输。应用应继续使用已选定的 tracing 库提取请求上下文,再把标识绑定到 logger。 + +```moonbit +let request_logger = logger.with_trace_context( + @log.trace_context(trace_id, span_id, trace_flags="01"), +) +``` + +派生 logger 的每条记录都会带有 `trace_id`、`span_id`、`trace_flags`,以及传入时的 `trace_state`。原 logger 不会被修改,避免请求上下文泄漏到无关任务。 + +`context.as_fields()` 可用于自定义 facade。`traceparent` 解析、span 创建、采样与 OTLP 导出仍由应用选用的 tracing 实现负责。 diff --git a/src-async/BitLoggerAsync_test.mbt b/src-async/BitLoggerAsync_test.mbt index 7795da7..1354b00 100644 --- a/src-async/BitLoggerAsync_test.mbt +++ b/src-async/BitLoggerAsync_test.mbt @@ -455,6 +455,30 @@ async test "async logger with_context_fields prepends shared fields without muta inspect(written_fields.val[2].value, content="test") } +///| +test "async facades bind trace context through standard fields" { + let context = @bitlogger.trace_context("trace-async", "span-async") + let base = async_logger( + @bitlogger.callback_sink(fn(_) { }), + config=AsyncLoggerConfig::new(max_pending=4), + min_level=@bitlogger.Level::Info, + ) + let derived = base.with_trace_context(context) + inspect(base.context_fields.length(), content="0") + inspect(derived.context_fields.length(), content="3") + inspect(derived.context_fields[0].key, content="trace_id") + inspect(derived.context_fields[0].value, content="trace-async") + inspect(derived.context_fields[1].key, content="span_id") + inspect(derived.context_fields[2].value, content="01") + + let library = LibraryAsyncLogger::new(@bitlogger.callback_sink(fn(_) { })).with_trace_context( + context, + ) + let full = library.to_async_logger() + inspect(full.context_fields.length(), content="3") + inspect(full.context_fields[1].value, content="span-async") +} + ///| async test "async logger child composes target and preserves other state" { let written_target : Ref[String] = Ref("") diff --git a/src-async/async_logger_shared.mbt b/src-async/async_logger_shared.mbt index 7d62d00..cabdc87 100644 --- a/src-async/async_logger_shared.mbt +++ b/src-async/async_logger_shared.mbt @@ -274,6 +274,14 @@ pub fn[S] AsyncLogger::with_context_fields( { ..self, context_fields: fields } } +///| +pub fn[S] AsyncLogger::with_trace_context( + self : AsyncLogger[S], + context : @bitlogger.TraceContext, +) -> AsyncLogger[S] { + self.with_context_fields(context.as_fields()) +} + ///| pub fn[S] AsyncLogger::with_filter( self : AsyncLogger[S], diff --git a/src-async/async_native_file_integration_test.mbt b/src-async/async_native_file_integration_test.mbt new file mode 100644 index 0000000..2422d33 --- /dev/null +++ b/src-async/async_native_file_integration_test.mbt @@ -0,0 +1,90 @@ +///| +async test "native async file lifecycle flushes rotates and restarts" { + if !@bitlogger.native_files_supported() { + inspect(@bitlogger.native_files_supported(), content="false") + } else { + let path = "logs/bitlogger-async-native-e2e.log" + let first_flushes : Ref[Int] = Ref(0) + let first_sink = @bitlogger.file_sink( + path, + append=false, + auto_flush=false, + rotation=Some(@bitlogger.file_rotation(8, max_backups=1)), + formatter=fn(rec) { rec.message }, + ) + let first = async_logger( + first_sink, + config=AsyncLoggerConfig::new( + max_pending=4, + overflow=AsyncOverflowPolicy::Blocking, + max_batch=4, + linger_ms=0, + flush=AsyncFlushPolicy::Batch, + ), + min_level=@bitlogger.Level::Info, + target="async.file.e2e", + flush=fn(sink) { + first_flushes.val += 1 + ignore(sink.flush()) + }, + ) + + @async.with_task_group(group => { + group.spawn_bg(() => first.run()) + first.info("first-record") + first.shutdown() + }) + + inspect(first.is_closed(), content="true") + inspect(first.has_failed(), content="false") + inspect(first.pending_count(), content="0") + inspect(first_flushes.val > 0, content="true") + inspect(first_sink.path(), content="logs/bitlogger-async-native-e2e.log") + inspect(first_sink.rotation_enabled(), content="true") + inspect(first_sink.rotation_failures(), content="0") + inspect(first_sink.write_failures(), content="0") + inspect(first_sink.flush_failures(), content="0") + inspect(first_sink.close(), content="true") + + let restart_flushes : Ref[Int] = Ref(0) + let restarted_sink = @bitlogger.file_sink( + path, + append=true, + auto_flush=false, + rotation=Some(@bitlogger.file_rotation(8, max_backups=1)), + formatter=fn(rec) { rec.message }, + ) + let restarted = async_logger( + restarted_sink, + config=AsyncLoggerConfig::new( + max_pending=4, + overflow=AsyncOverflowPolicy::Blocking, + max_batch=4, + linger_ms=0, + flush=AsyncFlushPolicy::Shutdown, + ), + min_level=@bitlogger.Level::Info, + target="async.file.e2e", + flush=fn(sink) { + restart_flushes.val += 1 + ignore(sink.flush()) + }, + ) + + @async.with_task_group(group => { + group.spawn_bg(() => restarted.run()) + restarted.info("second-record") + restarted.shutdown() + }) + + inspect(restarted.is_closed(), content="true") + inspect(restarted.has_failed(), content="false") + inspect(restarted.pending_count(), content="0") + inspect(restart_flushes.val, content="1") + inspect(restarted_sink.rotation_enabled(), content="true") + inspect(restarted_sink.rotation_failures(), content="0") + inspect(restarted_sink.write_failures(), content="0") + inspect(restarted_sink.flush_failures(), content="0") + inspect(restarted_sink.close(), content="true") + } +} diff --git a/src-async/library_async_logger.mbt b/src-async/library_async_logger.mbt index 3e7cc17..37d6d1e 100644 --- a/src-async/library_async_logger.mbt +++ b/src-async/library_async_logger.mbt @@ -93,6 +93,14 @@ pub fn[S] LibraryAsyncLogger::bind( self.with_context_fields(fields) } +///| +pub fn[S] LibraryAsyncLogger::with_trace_context( + self : LibraryAsyncLogger[S], + context : @bitlogger.TraceContext, +) -> LibraryAsyncLogger[S] { + library_async_logger(self.inner.with_trace_context(context)) +} + ///| pub fn[S] LibraryAsyncLogger::is_enabled( self : LibraryAsyncLogger[S], diff --git a/src-async/pkg.generated.mbti b/src-async/pkg.generated.mbti index c800288..666a3b8 100644 --- a/src-async/pkg.generated.mbti +++ b/src-async/pkg.generated.mbti @@ -114,6 +114,7 @@ pub fn[S] AsyncLogger::with_min_level(Self[S], @core.Level) -> Self[S] pub fn[S] AsyncLogger::with_patch(Self[S], (@core.Record) -> @core.Record) -> Self[S] pub fn[S] AsyncLogger::with_target(Self[S], String) -> Self[S] pub fn[S] AsyncLogger::with_timestamp(Self[S], enabled? : Bool) -> Self[S] +pub fn[S] AsyncLogger::with_trace_context(Self[S], @core.TraceContext) -> Self[S] pub struct LibraryAsyncLogger[S] { inner : AsyncLogger[S] @@ -131,6 +132,7 @@ pub fn[S] LibraryAsyncLogger::to_async_logger(Self[S]) -> AsyncLogger[S] pub async fn[S] LibraryAsyncLogger::warn(Self[S], String, fields? : Array[@core.Field]) -> Unit pub fn[S] LibraryAsyncLogger::with_context_fields(Self[S], Array[@core.Field]) -> Self[S] pub fn[S] LibraryAsyncLogger::with_target(Self[S], String) -> Self[S] +pub fn[S] LibraryAsyncLogger::with_trace_context(Self[S], @core.TraceContext) -> Self[S] // Type aliases pub type ApplicationAsyncLogger = AsyncLogger[@runtime.RuntimeSink] diff --git a/src/core/pkg.generated.mbti b/src/core/pkg.generated.mbti index a464961..d5f9685 100644 --- a/src/core/pkg.generated.mbti +++ b/src/core/pkg.generated.mbti @@ -39,6 +39,15 @@ pub fn Record::with_fields(Self, Array[Field]) -> Self pub fn Record::with_message(Self, String) -> Self pub fn Record::with_target(Self, String) -> Self +pub struct TraceContext { + trace_id : String + span_id : String + trace_flags : String + trace_state : String? +} +pub fn TraceContext::as_fields(Self) -> Array[Field] +pub fn TraceContext::new(String, String, trace_flags? : String, trace_state? : String) -> Self + // Type aliases // Traits diff --git a/src/core/trace_context.mbt b/src/core/trace_context.mbt new file mode 100644 index 0000000..e136903 --- /dev/null +++ b/src/core/trace_context.mbt @@ -0,0 +1,31 @@ +///| +pub struct TraceContext { + trace_id : String + span_id : String + trace_flags : String + trace_state : String? +} + +///| +pub fn TraceContext::new( + trace_id : String, + span_id : String, + trace_flags? : String = "01", + trace_state? : String, +) -> TraceContext { + { trace_id, span_id, trace_flags, trace_state } +} + +///| +pub fn TraceContext::as_fields(self : TraceContext) -> Array[Field] { + let fields = [ + field("trace_id", self.trace_id), + field("span_id", self.span_id), + field("trace_flags", self.trace_flags), + ] + match self.trace_state { + Some(value) => fields.push(field("trace_state", value)) + None => () + } + fields +} diff --git a/src/library_logger.mbt b/src/library_logger.mbt index a77df7b..fc1fda2 100644 --- a/src/library_logger.mbt +++ b/src/library_logger.mbt @@ -80,6 +80,14 @@ pub fn[S] LibraryLogger::bind( self.with_context_fields(fields) } +///| +pub fn[S] LibraryLogger::with_trace_context( + self : LibraryLogger[S], + context : TraceContext, +) -> LibraryLogger[ContextSink[S]] { + library_logger(self.inner.with_trace_context(context)) +} + ///| pub fn[S] LibraryLogger::is_enabled( self : LibraryLogger[S], diff --git a/src/logger.mbt b/src/logger.mbt index 8cc4bbf..4117477 100644 --- a/src/logger.mbt +++ b/src/logger.mbt @@ -57,6 +57,14 @@ pub fn[S] Logger::bind( self.with_context_fields(fields) } +///| +pub fn[S] Logger::with_trace_context( + self : Logger[S], + context : TraceContext, +) -> Logger[ContextSink[S]] { + self.with_context_fields(context.as_fields()) +} + ///| pub fn[S] Logger::with_filter( self : Logger[S], diff --git a/src/pkg.generated.mbti b/src/pkg.generated.mbti index 9899a2e..986848d 100644 --- a/src/pkg.generated.mbti +++ b/src/pkg.generated.mbti @@ -187,6 +187,8 @@ pub fn text_style(fg? : String?, bg? : String?, bold? : Bool, dim? : Bool, itali pub fn trace(String, fields? : Array[@core.Field]) -> Unit +pub fn trace_context(String, String, trace_flags? : String, trace_state? : String) -> @core.TraceContext + pub fn warn(String, fields? : Array[@core.Field]) -> Unit pub fn with_file_rotation(@config_model.LoggerConfig, Int, max_backups? : Int) -> @config_model.LoggerConfig @@ -212,6 +214,7 @@ pub fn[S] LibraryLogger::to_logger(Self[S]) -> Logger[S] pub fn[S : @sink_graph.Sink] LibraryLogger::warn(Self[S], String, fields? : Array[@core.Field]) -> Unit pub fn[S] LibraryLogger::with_context_fields(Self[S], Array[@core.Field]) -> Self[@sink_graph.ContextSink[S]] pub fn[S] LibraryLogger::with_target(Self[S], String) -> Self[S] +pub fn[S] LibraryLogger::with_trace_context(Self[S], @core.TraceContext) -> Self[@sink_graph.ContextSink[S]] pub struct Logger[S] { min_level : @core.Level @@ -276,6 +279,7 @@ pub fn[S] Logger::with_patch(Self[S], (@core.Record) -> @core.Record) -> Self[@s pub fn[S] Logger::with_queue(Self[S], max_pending? : Int, overflow? : @queue_model.QueueOverflowPolicy) -> Self[@sink_graph.QueuedSink[S]] pub fn[S] Logger::with_target(Self[S], String) -> Self[S] pub fn[S] Logger::with_timestamp(Self[S], enabled? : Bool) -> Self[S] +pub fn[S] Logger::with_trace_context(Self[S], @core.TraceContext) -> Self[@sink_graph.ContextSink[S]] // Type aliases pub type ApplicationLogger = Logger[@runtime.RuntimeSink] @@ -358,6 +362,8 @@ pub using @config_model {type TextFormatterConfig} pub using @formatting {type TextStyle} +pub using @core {type TraceContext} + pub using @sink_graph {trait Sink} // Traits diff --git a/src/record.mbt b/src/record.mbt index b90c092..44b4671 100644 --- a/src/record.mbt +++ b/src/record.mbt @@ -1,6 +1,9 @@ ///| pub type Field = @core.Field +///| +pub type TraceContext = @core.TraceContext + ///| pub fn field(key : String, value : String) -> Field { @core.field(key, value) @@ -11,6 +14,25 @@ pub fn fields(entries : Array[(String, String)]) -> Array[Field] { @core.fields(entries) } +///| +pub fn trace_context( + trace_id : String, + span_id : String, + trace_flags? : String = "01", + trace_state? : String, +) -> TraceContext { + match trace_state { + Some(value) => + @core.TraceContext::new( + trace_id, + span_id, + trace_flags~, + trace_state=value, + ) + None => @core.TraceContext::new(trace_id, span_id, trace_flags~) + } +} + ///| pub type Record = @core.Record diff --git a/src/trace_context_test.mbt b/src/trace_context_test.mbt new file mode 100644 index 0000000..27ff054 --- /dev/null +++ b/src/trace_context_test.mbt @@ -0,0 +1,40 @@ +///| +test "trace context maps W3C fields in a stable order" { + let context = trace_context( + "4bf92f3577b34da6a3ce929d0e0e4736", + "00f067aa0ba902b7", + trace_state="vendor=value", + ) + let fields = context.as_fields() + inspect(fields.length(), content="4") + inspect(fields[0].key, content="trace_id") + inspect(fields[0].value, content="4bf92f3577b34da6a3ce929d0e0e4736") + inspect(fields[1].key, content="span_id") + inspect(fields[1].value, content="00f067aa0ba902b7") + inspect(fields[2].key, content="trace_flags") + inspect(fields[2].value, content="01") + inspect(fields[3].key, content="trace_state") + inspect(fields[3].value, content="vendor=value") +} + +///| +test "sync facades bind trace context without mutating their base logger" { + let captured : Ref[Array[Field]] = Ref([]) + let base = Logger::new(callback_sink(fn(rec) { captured.val = rec.fields })) + let context = trace_context("trace-1", "span-1", trace_flags="00") + let derived = base.with_trace_context(context) + base.info("base") + inspect(captured.val.length(), content="0") + derived.info("derived", fields=[field("event", "request")]) + inspect(captured.val.length(), content="4") + inspect(captured.val[0].key, content="trace_id") + inspect(captured.val[2].value, content="00") + inspect(captured.val[3].key, content="event") + + let library = LibraryLogger::new( + callback_sink(fn(rec) { captured.val = rec.fields }), + ).with_trace_context(context) + library.info("library") + inspect(captured.val.length(), content="3") + inspect(captured.val[1].value, content="span-1") +}