diff --git a/.gitignore b/.gitignore index c88711b..6e177e5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ obj/ # Python __pycache__ *.pyc +uv.lock .venv/ # Temporary / artifacts diff --git a/Clip.Benchmarks/FastConfig.cs b/Clip.Benchmarks/FastConfig.cs index dc569c0..4da7468 100644 --- a/Clip.Benchmarks/FastConfig.cs +++ b/Clip.Benchmarks/FastConfig.cs @@ -9,46 +9,28 @@ namespace Clip.Benchmarks; // -// Modes (env vars): -// BENCH_MODE=fast (default) — InProcess, 1 warmup, 3 × 100 ms iterations (~5 min) -// BENCH_MODE=full — InProcess, 2 warmups, 5 × 250 ms iterations (~10-15 min) -// BENCH_CONFIG=asm — out-of-process + DisassemblyDiagnoser, artifacts in tmp/BenchmarkDotNet.AsmArtifacts/ +// BENCH_MODE env var: +// fast (default) — InProcess, 2 warmups, 5 × 200 ms iterations +// full — out-of-process, 3 warmups, 50 × 1000 ms iterations +// asm — out-of-process + DisassemblyDiagnoser, artifacts in tmp/BenchmarkDotNet.AsmArtifacts/ // internal sealed class FastConfig : ManualConfig { public FastConfig() { - var asm = string.Equals( - Environment.GetEnvironmentVariable("BENCH_CONFIG"), - "asm", StringComparison.OrdinalIgnoreCase); + var mode = (Environment.GetEnvironmentVariable("BENCH_MODE") ?? "fast") + .ToLowerInvariant(); - ArtifactsPath = Path.Combine("tmp", asm + ArtifactsPath = Path.Combine("tmp", mode == "asm" ? "BenchmarkDotNet.AsmArtifacts" : "BenchmarkDotNet.Artifacts"); - if (asm) + switch (mode) { - AddJob(Job.Default.WithWarmupCount(1).WithIterationCount(2)); - AddDiagnoser(new DisassemblyDiagnoser(new DisassemblyDiagnoserConfig(3))); - } - else - { - var full = string.Equals( - Environment.GetEnvironmentVariable("BENCH_MODE"), - "full", StringComparison.OrdinalIgnoreCase); - - AddJob(Job.Default - .WithToolchain(InProcessEmitToolchain.Instance) - .WithWarmupCount(full - ? 2 - : 1) - .WithIterationCount(full - ? 5 - : 3) - .WithIterationTime(TimeInterval.FromMilliseconds(full - ? 250 - : 100))); + case "asm": ConfigureAsm(); break; + case "full": ConfigureFull(); break; + default: ConfigureFast(); break; } AddExporter(MarkdownExporter.GitHub); @@ -56,4 +38,41 @@ public FastConfig() AddColumn(CategoriesColumn.Default); AddLogicalGroupRules(BenchmarkLogicalGroupRule.ByCategory); } + + // + // Fast — quick iteration during development + // + + private void ConfigureFast() + { + AddJob(Job.Default + .WithToolchain(InProcessEmitToolchain.Instance) + .WithWarmupCount(2) + .WithIterationCount(5) + .WithIterationTime(TimeInterval.FromMilliseconds(200))); + } + + // + // Full — publication-quality, out-of-process + // + + private void ConfigureFull() + { + AddJob(Job.Default + .WithWarmupCount(3) + .WithIterationCount(50) + .WithIterationTime(TimeInterval.FromMilliseconds(1000))); + } + + // + // Asm — JIT disassembly + // + + private void ConfigureAsm() + { + AddJob(Job.Default + .WithWarmupCount(1) + .WithIterationCount(2)); + AddDiagnoser(new DisassemblyDiagnoser(new DisassemblyDiagnoserConfig(3))); + } } diff --git a/Makefile b/Makefile index e9fc1a4..f87983d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: help build test check .PHONY: fmt fmt-cs fmt-py -.PHONY: bench bench-full bench-asm archive-bench +.PHONY: bench bench-full bench-clip bench-clip-full bench-asm bench-update archive-bench .PHONY: charts docs pdf usage .PHONY: demo demo-console demo-json .PHONY: pkg @@ -33,26 +33,41 @@ format-cs: ## Format Python scripts format-py: - .venv/bin/black scripts/ + uv run ruff format scripts/ -## Run fast benchmarks (~40 min) +## Run fast benchmarks — all loggers (~40 min, 5 data points) bench: BENCH_MODE=fast dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*ConsoleBenchmarks*' '*JsonBenchmarks*' '*FilteredBenchmarks*' @rm -f tmp/BenchmarkDotNet.Artifacts/*.log - @$(MAKE) archive-bench - @$(MAKE) docs + @$(MAKE) bench-update -## Run full benchmarks (~90 min, publication-quality) +## Run full benchmarks — all loggers (~1h45m, 50 data points) bench-full: BENCH_MODE=full dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*ConsoleBenchmarks*' '*JsonBenchmarks*' '*FilteredBenchmarks*' @rm -f tmp/BenchmarkDotNet.Artifacts/*.log + @$(MAKE) bench-update + +## Run fast benchmarks — Clip only (~12 min, 5 data points) +bench-clip: + BENCH_MODE=fast dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*_Clip' '*_ClipZero' '*_ClipMEL' + @rm -f tmp/BenchmarkDotNet.Artifacts/*.log + @$(MAKE) bench-update + +## Run full benchmarks — Clip only (~30 min, 50 data points) +bench-clip-full: + BENCH_MODE=full dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*_Clip' '*_ClipZero' '*_ClipMEL' + @rm -f tmp/BenchmarkDotNet.Artifacts/*.log + @$(MAKE) bench-update + +## Import results into DB and archive raw artifacts +bench-update: + @uv run python3 scripts/benchdb.py import @$(MAKE) archive-bench - @$(MAKE) docs ## Dump JIT assembly for Clip hot paths bench-asm: - BENCH_CONFIG=asm dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*FiveFields_Clip*' - BENCH_CONFIG=asm dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*WithContext_Clip*' + BENCH_MODE=asm dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*FiveFields_Clip*' + BENCH_MODE=asm dotnet run -c Release --project $(BENCH_PROJECT) -- --filter '*WithContext_Clip*' @rm -f tmp/BenchmarkDotNet.AsmArtifacts/*.log ## Archive benchmark results to tmp/bench-history/ @@ -65,22 +80,22 @@ archive-bench: cp tmp/BenchmarkDotNet.Artifacts/results/*.md tmp/BenchmarkDotNet.Artifacts/results/*.csv "$$dir/" 2>/dev/null; \ echo "Archived to $$dir" -## Generate bar charts from BDN artifacts +## Generate bar charts from benchmark database charts: - .venv/bin/python3 scripts/chart.py + uv run python3 scripts/chart.py ## Generate docs/COMPARE.md (depends on charts) docs: charts - .venv/bin/python3 scripts/compare.py + uv run python3 scripts/compare.py ## Generate PDFs from docs pdf: docs - .venv/bin/python3 scripts/pdf.py + uv run python3 scripts/pdf.py ## Generate docs/USAGE.md (code + output for all loggers) usage: dotnet run -c Release --project Clip.ComparisonDemo > tmp/raw/usage.txt - .venv/bin/python3 scripts/usage.py tmp/raw/usage.txt + uv run python3 scripts/usage.py tmp/raw/usage.txt ## Run the demo app (console output) demo: demo-console @@ -104,10 +119,9 @@ pkg: @echo "" @ls -lh pkg/*.nupkg -## Create venv and install Python dependencies +## Install Python dependencies setup: - python3 -m venv .venv - .venv/bin/pip install -r scripts/requirements.txt + uv sync ## Remove build artifacts and benchmark results clean: diff --git a/docs/COMPARE.md b/docs/COMPARE.md index dab1963..8f2b60c 100644 --- a/docs/COMPARE.md +++ b/docs/COMPARE.md @@ -2,7 +2,7 @@ BenchmarkDotNet v0.15.8, macOS Tahoe 26.3.1 (25D2128) [Darwin 25.3.0] Apple M5, 1 CPU, 10 logical and 10 physical cores -Run: 2026-03-24 20:31 +Run: 2026-03-25 03:34 Clip is a zero-dependency structured logging library for .NET 9. It formats directly into pooled UTF-8 byte buffers — no intermediate strings, no allocations on the hot path, no background-thread tricks to hide latency. @@ -35,17 +35,37 @@ This report puts Clip head-to-head against six established .NET loggers, all wri ## Feature Matrix -| Feature | Clip | Serilog | NLog | MEL | ZLogger | Log4Net | ZeroLog | +| API & Data Model | Clip | Serilog | NLog | MEL | ZLogger | Log4Net | ZeroLog | |---------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | Structured Fields | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | -| Typed Fields | ✅ | — | — | — | — | — | ✅ | +| Typed Fields | ✅ | — | — | — | ✅ | — | ✅ | | Zero-Alloc API | ✅ | — | — | — | ✅ | — | ✅ | +| Message Templates | — | ✅ | ✅ | ✅ | — | — | — | +| Source Generator | — | — | — | ✅ | ✅ | — | — | + +| Pipeline | Clip | Serilog | NLog | MEL | ZLogger | Log4Net | ZeroLog | +|---------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| Enrichers | ✅ | ✅ | ✅ | ✅ | — | — | — | +| Level-Gated Enrichers | ✅ | ✅ | — | — | — | — | — | +| Filters | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| Redactors | ✅ | — | — | ✅ | — | — | — | | Scoped Context | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | + +| Output | Clip | Serilog | NLog | MEL | ZLogger | Log4Net | ZeroLog | +|---------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | Console Sink | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | JSON Sink | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | -| Async / Background | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Message Templates | — | ✅ | ✅ | ✅ | ✅ | — | — | -| Source Generator | — | — | — | ✅ | ✅ | — | — | +| File Sink | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | +| OpenTelemetry / OTLP | ✅ | ✅ | ✅ | ✅ | — | — | — | + +| Architecture | Clip | Serilog | NLog | MEL | ZLogger | Log4Net | ZeroLog | +|---------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| Sync-by-Default | ✅ | ✅ | ✅ | — | — | ✅ | — | +| Async / Background | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | +| Buffer Pooling | ✅ | — | ✅ | — | ✅ | — | ✅ | +| Zero Dependencies | ✅ | — | ✅ | — | — | — | — | +| MEL Adapter | ✅ | ✅ | ✅ | — | — | ✅ | — | + --- @@ -63,14 +83,14 @@ logger.Debug("This is filtered out"); |--------|-----:|----------:| | **Clip** | 0.0000 ns | - | | **ClipZero** | 0.0000 ns | - | -| **ClipMEL** | 5.8569 ns | - | -| MEL | 5.6991 ns | - | -| MELSrcGen | 0.5580 ns | - | -| Serilog | 0.6194 ns | - | -| ZLogger | 2.6083 ns | - | +| **ClipMEL** | 5.1255 ns | - | +| MEL | 5.2687 ns | - | +| MELSrcGen | 0.6043 ns | - | +| Serilog | 0.5558 ns | - | +| ZLogger | 2.5100 ns | - | | NLog | 0.0000 ns | - | -| Log4Net | 3.5747 ns | - | -| ZeroLog | 0.7129 ns | - | +| Log4Net | 3.5907 ns | - | +| ZeroLog | 0.4398 ns | - | > **Clip:** Single integer comparison, inlined by the JIT. @@ -138,16 +158,16 @@ logger.Info("Request handled"); | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 26.18 ns | 1.00 | - | -| **ClipZero** | 26.35 ns | 1.01 | - | -| **ClipMEL** | 59.15 ns | 2.26 | 64 B | -| MEL | 1,873.77 ns | 71.57 | 352 B | -| MELSrcGen | 1,942.53 ns | 74.20 | 368 B | -| Serilog | 283.57 ns | 10.83 | 416 B | -| ZLogger | 342.52 ns | 13.08 | - | -| NLog | 151.85 ns | 5.80 | 304 B | -| Log4Net | 181.46 ns | 6.93 | 392 B | -| ZeroLog | 115.89 ns | 4.43 | - | +| **Clip** | 25.19 ns | 1.00 | - | +| **ClipZero** | 25.48 ns | 1.01 | - | +| **ClipMEL** | 56.68 ns | 2.25 | 64 B | +| MEL | 397.58 ns | 15.78 | 352 B | +| MELSrcGen | 469.05 ns | 18.62 | 368 B | +| Serilog | 284.84 ns | 11.31 | 416 B | +| ZLogger | 289.82 ns | 11.51 | - | +| NLog | 160.99 ns | 6.39 | 304 B | +| Log4Net | 188.63 ns | 7.49 | 392 B | +| ZeroLog | 116.85 ns | 4.64 | - | > **Clip:** Formats into a pooled byte buffer and writes UTF-8 directly — no intermediate strings. Timestamp is cached so repeated calls within the same millisecond skip reformatting. @@ -201,16 +221,16 @@ logger.Info("Request handled", new { | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 173.03 ns | 1.00 | 72 B | -| **ClipZero** | 138.29 ns | 0.80 | - | -| **ClipMEL** | 404.90 ns | 2.34 | 608 B | -| MEL | 859.01 ns | 4.96 | 808 B | -| MELSrcGen | 1,031.66 ns | 5.96 | 904 B | -| Serilog | 716.79 ns | 4.14 | 1216 B | -| ZLogger | 596.42 ns | 3.45 | 231 B | -| NLog | 629.14 ns | 3.64 | 1368 B | -| Log4Net | 2,285.66 ns | 13.21 | 888 B | -| ZeroLog | 321.75 ns | 1.86 | - | +| **Clip** | 187.96 ns | 1.00 | 72 B | +| **ClipZero** | 135.80 ns | 0.72 | - | +| **ClipMEL** | 384.34 ns | 2.04 | 608 B | +| MEL | 811.72 ns | 4.32 | 808 B | +| MELSrcGen | 917.01 ns | 4.88 | 904 B | +| Serilog | 730.01 ns | 3.88 | 1216 B | +| ZLogger | 404.19 ns | 2.15 | - | +| NLog | 653.08 ns | 3.47 | 1368 B | +| Log4Net | 332.17 ns | 1.77 | 888 B | +| ZeroLog | 306.35 ns | 1.63 | - | > **Clip:** Ergonomic tier allocates one anonymous object (40 B); fields extracted via compiled expression trees (cached per type). Zero-alloc tier passes fields as stack-allocated structs — no boxing, no heap allocation. Both write typed values into the same pooled byte buffer. @@ -261,14 +281,14 @@ using (logger.AddContext(new { RequestId = "abc-123", UserId = 42 })) | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 126.50 ns | 1.00 | 232 B | -| **ClipZero** | 105.12 ns | 0.83 | 176 B | -| **ClipMEL** | 243.99 ns | 1.93 | 576 B | -| MEL | 653.67 ns | 5.17 | 792 B | -| MELSrcGen | 611.77 ns | 4.84 | 808 B | -| Serilog | 4,754.03 ns | 37.58 | 1344 B | -| ZLogger | 615.47 ns | 4.87 | 200 B | -| NLog | 450.72 ns | 3.56 | 1288 B | +| **Clip** | 127.06 ns | 1.00 | 232 B | +| **ClipZero** | 110.90 ns | 0.87 | 176 B | +| **ClipMEL** | 239.12 ns | 1.88 | 576 B | +| MEL | 601.33 ns | 4.73 | 792 B | +| MELSrcGen | 609.39 ns | 4.80 | 808 B | +| Serilog | 687.15 ns | 5.41 | 1344 B | +| ZLogger | 388.59 ns | 3.06 | 200 B | +| NLog | 454.57 ns | 3.58 | 1288 B | > **Clip:** Context stored in AsyncLocal. Ergonomic tier allocates an anonymous object for call-site fields; zero-alloc tier passes them as stack-allocated structs. Context and call-site fields merged at write time. @@ -315,16 +335,16 @@ logger.Error("Connection failed", ex, new { | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 1,741.25 ns | 1.00 | 2384 B | -| **ClipZero** | 1,711.19 ns | 0.98 | 2352 B | -| **ClipMEL** | 1,988.26 ns | 1.14 | 2648 B | -| MEL | 3,088.26 ns | 1.77 | 4024 B | -| MELSrcGen | 3,357.95 ns | 1.93 | 4016 B | -| Serilog | 2,311.48 ns | 1.33 | 3864 B | -| ZLogger | 806.94 ns | 0.46 | 1378 B | -| NLog | 2,644.80 ns | 1.52 | 4040 B | -| Log4Net | 2,204.93 ns | 1.27 | 4448 B | -| ZeroLog | 2,073.50 ns | 1.19 | 2736 B | +| **Clip** | 1,726.42 ns | 1.00 | 2384 B | +| **ClipZero** | 1,643.80 ns | 0.95 | 2352 B | +| **ClipMEL** | 1,777.05 ns | 1.03 | 2648 B | +| MEL | 3,231.40 ns | 1.87 | 4024 B | +| MELSrcGen | 3,774.82 ns | 2.19 | 4017 B | +| Serilog | 2,189.35 ns | 1.27 | 3864 B | +| ZLogger | 599.03 ns | 0.35 | 1377 B | +| NLog | 2,213.76 ns | 1.28 | 4040 B | +| Log4Net | 2,383.64 ns | 1.38 | 4449 B | +| ZeroLog | 2,182.29 ns | 1.26 | 2736 B | > **Clip:** Exception rendered synchronously into the same pooled byte buffer. @@ -403,13 +423,13 @@ logger.Info("Request handled"); | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 28.24 ns | 1.00 | - | -| **ClipZero** | 28.29 ns | 1.00 | - | -| MEL | 1,082.01 ns | 38.31 | 784 B | -| MELSrcGen | 1,282.05 ns | 45.39 | 752 B | -| Serilog | 329.59 ns | 11.67 | 608 B | -| ZLogger | 359.83 ns | 12.74 | - | -| NLog | 192.09 ns | 6.80 | 288 B | +| **Clip** | 28.03 ns | 1.00 | - | +| **ClipZero** | 28.13 ns | 1.00 | - | +| MEL | 962.46 ns | 34.34 | 784 B | +| MELSrcGen | 1,083.35 ns | 38.65 | 752 B | +| Serilog | 296.88 ns | 10.59 | 608 B | +| ZLogger | 345.03 ns | 12.31 | - | +| NLog | 170.71 ns | 6.09 | 288 B | > **Clip:** Builds JSON as raw UTF-8 bytes into a pooled buffer. String values are escaped using SIMD. @@ -455,13 +475,13 @@ logger.Info("Request handled", new { | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 176.94 ns | 1.00 | 72 B | -| **ClipZero** | 130.11 ns | 0.74 | - | -| MEL | 1,969.99 ns | 11.13 | 1824 B | -| MELSrcGen | 2,093.64 ns | 11.83 | 2272 B | -| Serilog | 1,057.08 ns | 5.97 | 1408 B | -| ZLogger | 738.17 ns | 4.17 | 555 B | -| NLog | 928.33 ns | 5.25 | 1384 B | +| **Clip** | 178.98 ns | 1.00 | 72 B | +| **ClipZero** | 129.29 ns | 0.72 | - | +| MEL | 2,033.44 ns | 11.36 | 1824 B | +| MELSrcGen | 2,250.46 ns | 12.57 | 2272 B | +| Serilog | 1,111.71 ns | 6.21 | 1408 B | +| ZLogger | 324.29 ns | 1.81 | 326 B | +| NLog | 1,051.19 ns | 5.87 | 1384 B | > **Clip:** Ergonomic tier allocates one anonymous object (40 B); fields extracted via expression trees. Zero-alloc tier passes stack-allocated structs directly. Both write typed JSON values with no boxing and no intermediate strings. @@ -504,13 +524,13 @@ using (logger.AddContext(new { RequestId = "abc-123", UserId = 42 })) | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 181.91 ns | 1.00 | 232 B | -| **ClipZero** | 207.00 ns | 1.14 | 176 B | -| MEL | 2,518.87 ns | 13.89 | 1440 B | -| MELSrcGen | 1,986.03 ns | 10.95 | 1432 B | -| Serilog | 1,213.20 ns | 6.69 | 1432 B | -| ZLogger | 1,724.46 ns | 9.51 | 280 B | -| NLog | 827.25 ns | 4.56 | 1288 B | +| **Clip** | 128.08 ns | 1.00 | 232 B | +| **ClipZero** | 109.97 ns | 0.86 | 176 B | +| MEL | 1,616.38 ns | 12.62 | 1440 B | +| MELSrcGen | 1,873.38 ns | 14.63 | 1432 B | +| Serilog | 817.03 ns | 6.38 | 1432 B | +| ZLogger | 1,188.49 ns | 9.28 | 286 B | +| NLog | 563.18 ns | 4.40 | 1288 B | > **Clip:** Ergonomic tier allocates an anonymous object for call-site fields; zero-alloc tier uses stack-allocated structs. Context and call-site fields merged at write time into the same pooled buffer. @@ -557,13 +577,13 @@ logger.Error("Connection failed", ex, new { | Logger | Mean | vs Clip | Allocated | |--------|-----:|--------:|----------:| -| **Clip** | 2,133.19 ns | 1.00 | 2384 B | -| **ClipZero** | 2,083.44 ns | 0.98 | 2352 B | -| MEL | 5,083.48 ns | 2.38 | 4264 B | -| MELSrcGen | 5,260.39 ns | 2.47 | 4272 B | -| Serilog | 2,928.47 ns | 1.37 | 3664 B | -| ZLogger | 569.15 ns | 0.27 | 1462 B | -| NLog | 2,992.95 ns | 1.40 | 4336 B | +| **Clip** | 1,706.64 ns | 1.00 | 2384 B | +| **ClipZero** | 1,673.80 ns | 0.98 | 2352 B | +| MEL | 4,475.70 ns | 2.62 | 4265 B | +| MELSrcGen | 4,542.27 ns | 2.66 | 4273 B | +| Serilog | 2,494.88 ns | 1.46 | 3665 B | +| ZLogger | 753.70 ns | 0.44 | 1376 B | +| NLog | 2,471.18 ns | 1.45 | 4336 B | > **Clip:** Exception serialized as a structured JSON object synchronously into the pooled buffer. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ceb928 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "clip-scripts" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = [ + "markdown==3.7", + "Pygments==2.19.1", + "weasyprint==68.1", +] + +[dependency-groups] +dev = [ + "ruff>=0.11", +] + +[tool.ruff] +line-length = 100 +indent-width = 2 + +[tool.ruff.format] +indent-style = "space" diff --git a/scripts/benchdb.py b/scripts/benchdb.py new file mode 100644 index 0000000..6c36780 --- /dev/null +++ b/scripts/benchdb.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Benchmark database — import BDN results, show contents. + +Usage: + python3 scripts/benchdb.py import # import from default artifacts dir + python3 scripts/benchdb.py import --dir path/ # import from custom dir + python3 scripts/benchdb.py show # print DB summary +""" + +import argparse +import csv +import json +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +DB_PATH = Path("tmp/benchdb.json") +ARTIFACTS_DIR = Path("tmp/BenchmarkDotNet.Artifacts/results") + +BENCH_CLASSES = ("FilteredBenchmarks", "ConsoleBenchmarks", "JsonBenchmarks") + +# Columns to extract from BDN CSV files. +FIELDS = { + "Method", + "Categories", + "Mean", + "Error", + "StdDev", + "Median", + "Ratio", + "Gen0", + "Gen1", + "Gen2", + "Allocated", +} + +_CSV_TO_DB = { + "Categories": "categories", + "Mean": "mean", + "Error": "error", + "StdDev": "stddev", + "Median": "median", + "Gen0": "gen0", + "Gen1": "gen1", + "Gen2": "gen2", + "Allocated": "allocated", +} + + +def _git_sha() -> str: + """Return short git SHA, or 'unknown'.""" + try: + return subprocess.check_output( + ["git", "rev-parse", "--short=7", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "unknown" + + +def _parse_env_header(md_path: Path) -> str: + """Extract the raw environment header from a BDN markdown report.""" + text = md_path.read_text() + blocks = text.split("```") + if len(blocks) < 2: + return "" + return blocks[1].strip() + + +def _parse_csv(csv_path: Path) -> list[dict[str, str]]: + """Parse a BDN CSV file, extracting only the columns we care about.""" + rows = [] + with open(csv_path, newline="") as f: + reader = csv.DictReader(f) + for raw in reader: + row = {} + for key in FIELDS: + val = raw.get(key, "").strip() + if val: + row[key] = val + if "Method" in row: + rows.append(row) + return rows + + +def _is_baseline(method: str, ratio: str) -> bool: + """Detect if this method is the baseline. + + BDN sets Ratio=1.00 for baselines. For Filtered benchmarks where + the baseline is 0 ns, BDN sets Ratio=? for all — fall back to + checking if the method ends with _Clip (the [Benchmark(Baseline=true)] + method in all benchmark classes). + """ + if ratio in ("1.00", "1"): + return True + if ratio == "?" and method.endswith("_Clip"): + return True + return False + + +def import_cmd(artifacts_dir: Path, db_path: Path, logger_filter: list[str] | None = None): + """Import BDN CSV results into the database. + + If logger_filter is set, only import methods whose logger suffix + (the part after the last '_') matches one of the given names. + E.g., ['Clip', 'ClipZero'] imports only *_Clip and *_ClipZero methods. + """ + if not artifacts_dir.exists(): + print(f"Artifacts directory not found: {artifacts_dir}", file=sys.stderr) + sys.exit(1) + + db: dict = {"environment": {}, "results": {}} + if db_path.exists(): + try: + with open(db_path) as f: + db = json.load(f) + except (json.JSONDecodeError, ValueError) as e: + print(f"Warning: existing database is corrupt ({e}), starting fresh.", file=sys.stderr) + + sha = _git_sha() + ts = datetime.now().isoformat(timespec="seconds") + imported = 0 + + for class_name in BENCH_CLASSES: + csv_file = artifacts_dir / f"Clip.Benchmarks.{class_name}-report.csv" + if not csv_file.exists(): + print(f" {class_name}: skipped (no CSV found)", file=sys.stderr) + continue + + md_file = artifacts_dir / f"Clip.Benchmarks.{class_name}-report-github.md" + if md_file.exists(): + header = _parse_env_header(md_file) + if header: + db["environment"]["raw_header"] = header + else: + print(f" Warning: could not parse environment header from {md_file.name}", file=sys.stderr) + + rows = _parse_csv(csv_file) + if not rows: + continue + + class_data = db.setdefault("results", {}).setdefault(class_name, {}) + class_imported = 0 + + for row in rows: + method = row.pop("Method") + + if logger_filter: + logger = method.rsplit("_", 1)[-1] if "_" in method else method + if logger not in logger_filter: + continue + + ratio = row.pop("Ratio", "") + baseline = _is_baseline(method, ratio) + + if "Mean" not in row: + print(f" Warning: {method} has no Mean value, skipping", file=sys.stderr) + continue + + entry: dict = { + "baseline": baseline, + "timestamp": ts, + "git_sha": sha, + } + + for csv_key, db_key in _CSV_TO_DB.items(): + if csv_key in row: + entry[db_key] = row[csv_key] + + class_data[method] = entry + imported += 1 + class_imported += 1 + + print(f" {class_name}: {class_imported} methods") + + if imported == 0: + print( + "Error: no results were imported. Check that CSV files exist in the artifacts directory.", + file=sys.stderr, + ) + sys.exit(1) + + # Write atomically + db_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = db_path.with_suffix(".tmp") + with open(tmp_path, "w") as f: + json.dump(db, f, indent=2) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, db_path) + + print(f"Imported {imported} results into {db_path}") + + +def show_cmd(db_path: Path): + """Print a summary of the database contents.""" + if not db_path.exists(): + print(f"No database found at {db_path}") + return + + with open(db_path) as f: + db = json.load(f) + + env = db.get("environment", {}).get("raw_header", "") + if env: + first_line = env.splitlines()[0] if env else "" + print(f"Environment: {first_line}") + print() + + results = db.get("results", {}) + for class_name in BENCH_CLASSES: + class_data = results.get(class_name, {}) + if not class_data: + continue + + # Group by logger (suffix after last _) + loggers: dict[str, list[str]] = {} + timestamps: set[str] = set() + for method, data in class_data.items(): + logger = method.rsplit("_", 1)[-1] if "_" in method else method + loggers.setdefault(logger, []).append(method) + timestamps.add(data.get("timestamp", "?")) + + ts_range = sorted(timestamps) + ts_display = ts_range[0] if len(ts_range) == 1 else f"{ts_range[0]} .. {ts_range[-1]}" + + print(f"{class_name}: {len(class_data)} methods") + print(f" Loggers: {', '.join(sorted(loggers.keys()))}") + print(f" Updated: {ts_display}") + print() + + +def main(): + parser = argparse.ArgumentParser(description="Benchmark database manager.") + sub = parser.add_subparsers(dest="command") + + imp = sub.add_parser("import", help="Import BDN CSV results into the database") + imp.add_argument( + "--dir", + type=Path, + default=ARTIFACTS_DIR, + help="Path to BDN artifacts directory", + ) + imp.add_argument( + "--db", + type=Path, + default=DB_PATH, + help="Path to benchdb.json", + ) + imp.add_argument( + "--filter", + nargs="+", + metavar="LOGGER", + help="Only import these loggers (e.g., --filter Clip ClipZero Serilog)", + ) + + show = sub.add_parser("show", help="Show database summary") + show.add_argument( + "--db", + type=Path, + default=DB_PATH, + help="Path to benchdb.json", + ) + + args = parser.parse_args() + + if args.command == "import": + import_cmd(args.dir, args.db, logger_filter=args.filter) + elif args.command == "show": + show_cmd(args.db) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchdb_reader.py b/scripts/benchdb_reader.py new file mode 100644 index 0000000..1a8c390 --- /dev/null +++ b/scripts/benchdb_reader.py @@ -0,0 +1,145 @@ +"""Read the benchmark database and return rows in parse_table() format. + +This module is the shared reader for benchdb.json. Both chart.py and +compare.py import from here instead of reading BDN artifact files. +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from metadata import parse_mean_ns + +DEFAULT_DB_PATH = Path("tmp/benchdb.json") + + +def load_db(path: Path = DEFAULT_DB_PATH) -> dict: + """Load and return the benchmark database.""" + if not path.exists(): + print(f"Benchmark database not found: {path}", file=sys.stderr) + print("Run 'make bench' or 'scripts/benchdb.py import' first.", file=sys.stderr) + sys.exit(1) + try: + with open(path) as f: + return json.load(f) + except json.JSONDecodeError as e: + print(f"Benchmark database is corrupt: {path} ({e})", file=sys.stderr) + print("Delete the file and re-import with 'make bench-update'.", file=sys.stderr) + sys.exit(1) + + +def get_environment(db: dict) -> list[str]: + """Return environment lines for the COMPARE.md header.""" + raw = db.get("environment", {}).get("raw_header", "") + if not raw: + return [] + lines = [ + l.strip() + for l in raw.splitlines() + if l.strip() and l.strip() != "-" and not l.strip().startswith("[") + ] + return lines[:2] + + +def _compute_ratios(methods: dict) -> dict[str, str]: + """Compute ratio strings for all methods within one category. + + Returns a dict mapping method name -> ratio string (e.g., "3.77"). + The baseline method gets "1.00". Returns "?" for all methods if no + baseline is found, the baseline mean cannot be parsed, or the + baseline mean is 0. + """ + # Find baseline + baseline_name = None + baseline_ns = None + for name, data in methods.items(): + if data.get("baseline"): + baseline_name = name + baseline_ns = parse_mean_ns(data.get("mean", "")) + break + + if baseline_name is None or baseline_ns is None or baseline_ns == 0: + return {name: "?" for name in methods} + + ratios = {} + for name, data in methods.items(): + if name == baseline_name: + ratios[name] = "1.00" + else: + mean_ns = parse_mean_ns(data.get("mean", "")) + if mean_ns is not None: + ratios[name] = f"{mean_ns / baseline_ns:.2f}" + else: + ratios[name] = "?" + return ratios + + +def _gen_display(val: str) -> str: + """Format a Gen column value: '0.0000' -> '-', else keep as-is.""" + if not val or val == "0.0000": + return "-" + return val + + +def load_class_rows(class_name: str, db: dict) -> list[dict[str, str]]: + """Load rows for a benchmark class, with recomputed ratios. + + Returns rows in the same format as metadata.parse_table(): a list + of dicts with keys like Method, Categories, Mean, Ratio, Allocated, etc. + """ + class_data = db.get("results", {}).get(class_name, {}) + if not class_data: + return [] + + # Warn if data comes from multiple runs (ratios may be inaccurate) + timestamps = {data.get("timestamp", "") for data in class_data.values()} + if len(timestamps) > 1: + print( + f"Warning: {class_name} contains data from {len(timestamps)} different runs. " + f"Ratios may be inaccurate. Run a full benchmark to get consistent data.", + file=sys.stderr, + ) + + # Group by category for ratio computation + by_cat: dict[str, dict[str, dict]] = {} + for method_name, data in class_data.items(): + cat = data.get("categories", "") + by_cat.setdefault(cat, {})[method_name] = data + + # Compute ratios per category + all_ratios: dict[str, str] = {} + for cat, cat_methods in by_cat.items(): + all_ratios.update(_compute_ratios(cat_methods)) + + # Build rows + rows = [] + for method_name, data in class_data.items(): + row: dict[str, str] = {"Method": method_name} + + cat = data.get("categories", "") + if cat: + row["Categories"] = cat + + row["Mean"] = data.get("mean", "") + row["Error"] = data.get("error", "") + row["StdDev"] = data.get("stddev", "") + + if "median" in data: + row["Median"] = data["median"] + + row["Ratio"] = all_ratios.get(method_name, "?") + + if "gen0" in data: + row["Gen0"] = _gen_display(data["gen0"]) + if "gen1" in data: + row["Gen1"] = _gen_display(data["gen1"]) + if "gen2" in data: + row["Gen2"] = _gen_display(data["gen2"]) + + alloc = data.get("allocated", "-") + row["Allocated"] = "-" if alloc in ("-", "0 B", "") else alloc + + rows.append(row) + + return rows diff --git a/scripts/chart.py b/scripts/chart.py index f7b2a11..8fc3d53 100644 --- a/scripts/chart.py +++ b/scripts/chart.py @@ -1,43 +1,43 @@ #!/usr/bin/env python3 -"""Generate comparison bar charts from current BenchmarkDotNet artifacts. +"""Generate comparison bar charts from the benchmark database. -Reads BDN markdown report files and produces horizontal bar charts +Reads tmp/benchdb.json and produces horizontal bar charts as SVG files — no matplotlib or numpy required. Usage: python3 scripts/chart.py - python3 scripts/chart.py --dir path/to/artifacts + python3 scripts/chart.py --db path/to/benchdb.json """ import argparse import html -import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from metadata import get_excludes, parse_table, strip_prefix, LOGGER_ORDER +from metadata import get_excludes, strip_prefix, parse_mean_ns, LOGGER_ORDER -ARTIFACTS_DIR = Path("tmp/BenchmarkDotNet.Artifacts/results") +from benchdb import BENCH_CLASSES +from benchdb_reader import load_db, load_class_rows + +DB_PATH = Path("tmp/benchdb.json") CHARTS_DIR = Path("tmp/charts") LOGGER_COLORS = { - "Clip": "#0077b6", - "ClipZero": "#00b4d8", - "ClipMEL": "#90e0ef", - "Serilog": "#e63946", - "NLog": "#f4a261", - "MEL": "#7b2d8b", - "MELSrcGen": "#b185c9", - "ZLogger": "#2a9d8f", - "Log4Net": "#8d99ae", - "ZeroLog": "#e76f51", + "Clip": "#0077b6", + "ClipZero": "#00a0c4", + "ClipMEL": "#40c9e0", + "Serilog": "#e63946", + "NLog": "#f4a261", + "MEL": "#7b2d8b", + "MELSrcGen": "#b185c9", + "ZLogger": "#2a9d8f", + "Log4Net": "#8d99ae", + "ZeroLog": "#e76f51", } DEFAULT_COLOR = "#adb5bd" -UNIT_NS = {"ns": 1, "μs": 1_000, "us": 1_000, "ms": 1_000_000, "s": 1_000_000_000} - # # Layout constants # @@ -50,7 +50,7 @@ LABEL_PAD = 8 VALUE_PAD = 6 RIGHT_MARGIN = 20 -INSIDE_LABEL_THRESHOLD = 0.45 +CHAR_WIDTH_ESTIMATE = 6.5 # approximate px per character at VALUE_FONT_SIZE FONT_FAMILY = "system-ui, -apple-system, sans-serif" LABEL_FONT_SIZE = 12 VALUE_FONT_SIZE = 11 @@ -60,171 +60,154 @@ VALUE_COLOR_INSIDE = "white" -def parse_mean_ns(value: str) -> float | None: - """Convert a BDN mean string like '27.22 ns' to nanoseconds.""" - m = re.match(r"([0-9.,]+)\s*(ns|μs|us|ms|s)", value.strip()) - if not m: - return None - num = float(m.group(1).replace(",", "")) - return num * UNIT_NS[m.group(2)] - - def parse_alloc(value: str) -> str: - """Return a short allocation label, or '' for zero/missing.""" - s = value.strip().rstrip("B").strip() - if not s or s == "-": - return "" - return value.strip() + """Return a short allocation label, or '' for zero/missing.""" + s = value.strip().rstrip("B").strip() + if not s or s == "-": + return "" + return value.strip() def _fmt_label(val: float, alloc: str) -> str: - """Format a value label like '1,234 ns (40 B)'.""" - time_label = f"{val:,.0f} ns" if val >= 10 else f"{val:.2f} ns" - if alloc: - return f"{time_label} ({alloc})" - return time_label + """Format a value label like '1,234 ns (40 B)'.""" + time_label = f"{val:,.0f} ns" if val >= 10 else f"{val:.2f} ns" + if alloc: + return f"{time_label} ({alloc})" + return time_label def make_chart(names: list[str], values: list[float], allocs: list[str], path: Path): - """Generate a horizontal bar chart as SVG.""" - n = len(names) - if n == 0: - return - - max_val = max(values) - bar_area_width = CHART_WIDTH - LABEL_WIDTH - RIGHT_MARGIN - total_height = n * (BAR_HEIGHT + BAR_GAP) + BAR_GAP - - lines = [ - f'', - f'', - ] - - for i, (name, val, alloc) in enumerate(zip(names, values, allocs)): - color = LOGGER_COLORS.get(name, DEFAULT_COLOR) - y = BAR_GAP + i * (BAR_HEIGHT + BAR_GAP) - bar_width = (val / max_val) * bar_area_width if max_val > 0 else 0 - if bar_width < 3: - bar_width = 3 - bar_x = LABEL_WIDTH - label = _fmt_label(val, alloc) - esc_name = html.escape(name) - esc_label = html.escape(label) - cy = y + BAR_HEIGHT / 2 - - # Logger name (right-aligned before the bar) - lines.append( - f'{esc_name}' - ) - - # Bar - lines.append( - f'' - ) - - # Value label — inside the bar (white) if bar is wide enough, else outside (dark) - if bar_width > bar_area_width * INSIDE_LABEL_THRESHOLD: - lines.append( - f'{esc_label}' - ) - else: - lines.append( - f'{esc_label}' - ) - - lines.append("") - - path.write_text("\n".join(lines)) - print(f" {path}") - - -def process_report(artifacts: Path, bench_name: str): - """Parse one BDN report and generate charts per category.""" - report = artifacts / f"Clip.Benchmarks.{bench_name}-report-github.md" - if not report.exists(): - return - - rows = parse_table(report.read_text()) - if not rows: - return - - # Group rows by category - by_cat: dict[str, list[tuple[str, float, str]]] = {} - for r in rows: - method = r.get("Method", "") - cat = r.get("Categories", "") - mean_str = r.get("Mean", "") - alloc_str = r.get("Allocated", "") - if not method or not mean_str: - continue - - # Filtered benchmarks have no Categories column; - # only treat as "Filtered" when processing that specific report. - if not cat: - if bench_name == "FilteredBenchmarks": - cat = "Filtered" - else: - continue - - name = strip_prefix(method, cat) - mean = parse_mean_ns(mean_str) - if mean is None: - continue - - alloc = parse_alloc(alloc_str) - by_cat.setdefault(cat, []).append((name, mean, alloc)) - - for cat, entries in by_cat.items(): - excludes = get_excludes(cat) - entries = [(n, v, a) for n, v, a in entries if n not in excludes] - order = {name: i for i, name in enumerate(LOGGER_ORDER)} - entries.sort(key=lambda e: order.get(e[0], len(LOGGER_ORDER))) - names = [e[0] for e in entries] - values = [e[1] for e in entries] - allocs = [e[2] for e in entries] - chart_path = CHARTS_DIR / f"{cat}.svg" - make_chart(names, values, allocs, chart_path) + """Generate a horizontal bar chart as SVG.""" + n = len(names) + if n == 0: + return + + max_val = max(values) + bar_area_width = CHART_WIDTH - LABEL_WIDTH - RIGHT_MARGIN + total_height = n * (BAR_HEIGHT + BAR_GAP) + BAR_GAP + + lines = [ + f'', + f'', + ] + + for i, (name, val, alloc) in enumerate(zip(names, values, allocs)): + color = LOGGER_COLORS.get(name, DEFAULT_COLOR) + y = BAR_GAP + i * (BAR_HEIGHT + BAR_GAP) + bar_width = (val / max_val) * bar_area_width if max_val > 0 else 0 + if bar_width < 3: + bar_width = 3 + bar_x = LABEL_WIDTH + label = _fmt_label(val, alloc) + esc_name = html.escape(name) + esc_label = html.escape(label) + cy = y + BAR_HEIGHT / 2 + + # Logger name (right-aligned before the bar) + lines.append( + f'{esc_name}' + ) + + # Bar + lines.append( + f'' + ) + + # Value label — inside the bar unless the label physically won't fit + label_width = len(label) * CHAR_WIDTH_ESTIMATE + 2 * VALUE_PAD + if bar_width >= label_width: + lines.append( + f'{esc_label}' + ) + else: + lines.append( + f'{esc_label}' + ) + + lines.append("") + + path.write_text("\n".join(lines)) + print(f" {path}") + + +def process_class(bench_name: str, db: dict): + """Load one benchmark class from the DB and generate charts per category.""" + rows = load_class_rows(bench_name, db) + if not rows: + return + + # Group rows by category + by_cat: dict[str, list[tuple[str, float, str]]] = {} + for r in rows: + method = r.get("Method", "") + cat = r.get("Categories", "") + mean_str = r.get("Mean", "") + alloc_str = r.get("Allocated", "") + if not method or not mean_str: + continue + + if not cat: + if bench_name == "FilteredBenchmarks": + cat = "Filtered" + else: + continue + + name = strip_prefix(method, cat) + mean = parse_mean_ns(mean_str) + if mean is None: + continue + + alloc = parse_alloc(alloc_str) + by_cat.setdefault(cat, []).append((name, mean, alloc)) + + for cat, entries in by_cat.items(): + excludes = get_excludes(cat) + entries = [(n, v, a) for n, v, a in entries if n not in excludes] + order = {name: i for i, name in enumerate(LOGGER_ORDER)} + entries.sort(key=lambda e: order.get(e[0], len(LOGGER_ORDER))) + names = [e[0] for e in entries] + values = [e[1] for e in entries] + allocs = [e[2] for e in entries] + chart_path = CHARTS_DIR / f"{cat}.svg" + make_chart(names, values, allocs, chart_path) def main(): - parser = argparse.ArgumentParser( - description="Generate comparison bar charts from BDN artifacts." - ) - parser.add_argument( - "--dir", - type=Path, - default=ARTIFACTS_DIR, - help="Path to BDN artifacts directory", - ) - args = parser.parse_args() - artifacts = args.dir + parser = argparse.ArgumentParser( + description="Generate comparison bar charts from benchmark database." + ) + parser.add_argument( + "--db", + type=Path, + default=DB_PATH, + help="Path to benchdb.json", + ) + args = parser.parse_args() - if not artifacts.exists(): - print(f"Artifacts directory not found: {artifacts}", file=sys.stderr) - sys.exit(1) + db = load_db(args.db) - CHARTS_DIR.mkdir(parents=True, exist_ok=True) + CHARTS_DIR.mkdir(parents=True, exist_ok=True) - # Clear old charts - for old in CHARTS_DIR.glob("*.svg"): - old.unlink() - for old in CHARTS_DIR.glob("*.png"): - old.unlink() + # Clear old charts + for old in CHARTS_DIR.glob("*.svg"): + old.unlink() + for old in CHARTS_DIR.glob("*.png"): + old.unlink() - print("Generating comparison charts...") - for bench in ("FilteredBenchmarks", "ConsoleBenchmarks", "JsonBenchmarks"): - process_report(artifacts, bench) + print("Generating comparison charts...") + for bench in BENCH_CLASSES: + process_class(bench, db) - print("Done.") + print("Done.") if __name__ == "__main__": - main() + main() diff --git a/scripts/compare.py b/scripts/compare.py index b67d0f7..fa1418e 100644 --- a/scripts/compare.py +++ b/scripts/compare.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 -"""Generate docs/COMPARE.md from BenchmarkDotNet artifact files. +"""Generate docs/COMPARE.md from the benchmark database. Usage: python3 scripts/compare.py # writes docs/COMPARE.md python3 scripts/compare.py --stdout # prints to stdout - python3 scripts/compare.py --dir path/ # custom artifacts dir + python3 scripts/compare.py --db path # custom DB path """ import argparse -import re import sys from datetime import datetime from pathlib import Path @@ -16,24 +15,25 @@ # Allow importing from scripts/ sys.path.insert(0, str(Path(__file__).resolve().parent)) from metadata import ( - render_caveats, - get_excludes, - get_description, - get_code_example, - render_feature_matrix, - parse_table, - strip_prefix, - LOGGER_ORDER, + render_caveats, + get_excludes, + get_description, + get_code_example, + render_feature_matrix, + strip_prefix, + LOGGER_ORDER, ) +from benchdb_reader import load_db, load_class_rows, get_environment -ARTIFACTS_DIR = Path("tmp/BenchmarkDotNet.Artifacts/results") +DB_PATH = Path("tmp/benchdb.json") OUTPUT_FILE = Path("docs/COMPARE.md") +CHARTS_DIR = Path("tmp/charts") CATEGORY_TITLES = { - "NoFields": "No Fields", - "FiveFields": "Five Fields", - "WithException": "With Exception", - "WithContext": "With Context", + "NoFields": "No Fields", + "FiveFields": "Five Fields", + "WithException": "With Exception", + "WithContext": "With Context", } CATEGORY_ORDER = ["NoFields", "FiveFields", "WithContext", "WithException"] @@ -43,279 +43,245 @@ _LOGGER_RANK = {name: i for i, name in enumerate(LOGGER_ORDER)} -def parse_env(text: str) -> list[str]: - """Extract environment lines from between ``` fences.""" - blocks = text.split("```") - if len(blocks) < 2: - return [] - lines = [ - l.strip() - for l in blocks[1].strip().splitlines() - if l.strip() and l.strip() != "-" and not l.strip().startswith("[") - ] - return lines[:2] - - def bold_clip(name: str) -> str: - return f"**{name}**" if name in CLIP_NAMES else name + return f"**{name}**" if name in CLIP_NAMES else name def fmt_alloc(alloc: str) -> str: - return "-" if not alloc or alloc == "-" else alloc + return "-" if not alloc or alloc == "-" else alloc def category_title(category: str) -> str: - """Convert a BDN category like 'Json_WithContext' into 'With Context'.""" - suffix = category.rsplit("_", 1)[-1] if "_" in category else category - return CATEGORY_TITLES.get(suffix, suffix) + """Convert a BDN category like 'Json_WithContext' into 'With Context'.""" + suffix = category.rsplit("_", 1)[-1] if "_" in category else category + return CATEGORY_TITLES.get(suffix, suffix) def emit_filtered(rows: list[dict[str, str]]) -> list[str]: - code = get_code_example("Filtered") - out = [ - "", - "## Filtered", - "", - get_description("Filtered"), - "", - ] - if code: - out += ["```csharp", code, "```", ""] - out += _chart_ref("Filtered") - out += [ - "| Logger | Mean | Allocated |", - "|--------|-----:|----------:|", - ] - pending = [] - for r in rows: - method = r.get("Method", "") - if not method: - continue - name = strip_prefix(method, "Filtered") - display = bold_clip(name) - mean = r.get("Mean", "") - alloc = fmt_alloc(r.get("Allocated", "")) - pending.append((name, f"| {display} | {mean} | {alloc} |")) - pending.sort(key=lambda e: _LOGGER_RANK.get(e[0], len(LOGGER_ORDER))) - out.extend(row for _, row in pending) - out += render_caveats("Filtered") - return out - - -CHARTS_DIR = Path("tmp/charts") + code = get_code_example("Filtered") + out = [ + "", + "## Filtered", + "", + get_description("Filtered"), + "", + ] + if code: + out += ["```csharp", code, "```", ""] + out += _chart_ref("Filtered") + out += [ + "| Logger | Mean | Allocated |", + "|--------|-----:|----------:|", + ] + pending = [] + for r in rows: + method = r.get("Method", "") + if not method: + continue + name = strip_prefix(method, "Filtered") + display = bold_clip(name) + mean = r.get("Mean", "") + alloc = fmt_alloc(r.get("Allocated", "")) + pending.append((name, f"| {display} | {mean} | {alloc} |")) + pending.sort(key=lambda e: _LOGGER_RANK.get(e[0], len(LOGGER_ORDER))) + out.extend(row for _, row in pending) + out += render_caveats("Filtered") + return out def _chart_ref(cat: str) -> list[str]: - """Return markdown image reference if chart exists.""" - chart = CHARTS_DIR / f"{cat}.svg" - if chart.exists(): - return [f"![{cat}]({chart})", ""] - return [] + """Return markdown image reference if chart exists.""" + chart = CHARTS_DIR / f"{cat}.svg" + if chart.exists(): + return [f"![{cat}]({chart})", ""] + return [] def _cat_sort_key(row: dict[str, str]) -> int: - """Sort key for category ordering within a section.""" - cat = row.get("Categories", "") - suffix = cat.rsplit("_", 1)[-1] if "_" in cat else cat - try: - return CATEGORY_ORDER.index(suffix) - except ValueError: - return len(CATEGORY_ORDER) + """Sort key for category ordering within a section.""" + cat = row.get("Categories", "") + suffix = cat.rsplit("_", 1)[-1] if "_" in cat else cat + try: + return CATEGORY_ORDER.index(suffix) + except ValueError: + return len(CATEGORY_ORDER) def emit_comparison(rows: list[dict[str, str]], section: str, desc: str) -> list[str]: - rows = sorted(rows, key=_cat_sort_key) - out = ["", f"## {section}", "", desc] - - current_cat = None - excludes = set() - pending: list[tuple[str, str]] = [] # (raw_name, formatted_row) - - def flush_cat(): - """Emit chart, then table, then caveats for the current category.""" - nonlocal pending - if current_cat is None: - return - out.extend(_chart_ref(current_cat)) - out.extend(table_header) - pending.sort(key=lambda e: _LOGGER_RANK.get(e[0], len(LOGGER_ORDER))) - out.extend(row for _, row in pending) - out.extend(render_caveats(current_cat)) - pending = [] - - table_header: list[str] = [] - - for r in rows: - method = r.get("Method", "") - cat = r.get("Categories", "") - if not method: - continue - - if cat != current_cat: - flush_cat() - current_cat = cat - pending = [] - excludes = get_excludes(cat) - title = f"{section}: {category_title(cat)}" - sub_desc = get_description(cat.rsplit("_", 1)[-1] if "_" in cat else cat) - sub_key = cat.rsplit("_", 1)[-1] if "_" in cat else cat - sub_code = get_code_example(sub_key) - out += ["", f"### {title}", ""] - if sub_desc: - out += [sub_desc, ""] - if sub_code: - out += ["```csharp", sub_code, "```", ""] - table_header = [ - "| Logger | Mean | vs Clip | Allocated |", - "|--------|-----:|--------:|----------:|", - ] - - name = strip_prefix(method, cat) - if name in excludes: - continue - display = bold_clip(name) - mean = r.get("Mean", "") - ratio = r.get("Ratio", "") - alloc = fmt_alloc(r.get("Allocated", "")) - pending.append((name, f"| {display} | {mean} | {ratio} | {alloc} |")) - - flush_cat() - return out - - -def find_report(artifacts: Path, name: str) -> Path | None: - p = artifacts / f"Clip.Benchmarks.{name}-report-github.md" - return p if p.exists() else None - - -def generate(artifacts: Path) -> str: - lines: list[str] = ["# Clip — Benchmark Comparison", ""] - - # Environment header from first available report - for name in ("FilteredBenchmarks", "ConsoleBenchmarks", "JsonBenchmarks"): - report = find_report(artifacts, name) - if report: - env = parse_env(report.read_text()) - env.append(f"Run: {datetime.now():%Y-%m-%d %H:%M}") - lines.append(" \n".join(env)) - break - - lines += [ - "", - "Clip is a zero-dependency structured logging library for .NET 9." - " It formats directly into pooled UTF-8 byte buffers — no intermediate" - " strings, no allocations on the hot path, no background-thread tricks" - " to hide latency.", - "", - "Clip ships two APIs that produce identical output:" - " **Clip** (ergonomic — pass an anonymous object, fields extracted" - " via compiled expression trees) and **ClipZero** (zero-alloc —" - " pass `Field` structs on the stack, nothing touches the heap).", - "", - "```csharp", - "// Ergonomic — one anonymous-object allocation, fields cached per type", - 'logger.Info("Request handled",', - " new { Method, Status, Elapsed, RequestId, Amount });", - "", - "// Zero-alloc — stack-allocated structs, zero heap allocations", - 'logger.Info("Request handled",', - ' new Field("Method", Method),', - ' new Field("Status", Status),', - ' new Field("Elapsed", Elapsed),', - ' new Field("RequestId", ReqId),', - ' new Field("Amount", Amount));', - "```", - "", - "This report puts Clip head-to-head against six established .NET loggers," - " all writing to `Stream.Null` so we measure pure formatting cost:", - "", - "- **Serilog** — rich sink ecosystem and message templates." - " Allocates a `LogEvent` and boxes value types per call.", - "- **NLog** — layout renderers give surgical control over output." - " String-based rendering with per-call allocations.", - "- **MEL** (Microsoft.Extensions.Logging) — ships with ASP.NET Core." - " Virtual dispatch, provider iteration, background I/O thread.", - "- **MELSrcGen** — MEL with `[LoggerMessage]` source generation." - " Eliminates runtime template parsing and value-type boxing." - " Same MEL pipeline underneath — this is how Microsoft recommends" - " using MEL in hot paths.", - "- **ZLogger** — Cysharp's high-performance logger built on MEL." - " Defers *all* formatting to a background thread — benchmarks" - " only reflect enqueue cost.", - "- **log4net** — the port of Java's Log4j." - " No structured fields, pattern layouts all the way down.", - "- **ClipMEL** — Clip behind MEL's `ILogger` via" - " `Clip.Extensions.Logging`. Shows MEL abstraction cost.", - "- **ZeroLog** — Abc-Arbitrage's zero-allocation logger." - " Builder API, synchronous mode — measures full formatting cost.", - ] - - lines += render_feature_matrix() - - lines += [ - "", - "---", - ] - - # Filtered - report = find_report(artifacts, "FilteredBenchmarks") - if report: - lines += emit_filtered(parse_table(report.read_text())) - - lines += ["", "---"] - - # Console - report = find_report(artifacts, "ConsoleBenchmarks") - if report: - lines += emit_comparison( - parse_table(report.read_text()), - "Console", - get_description("Console"), - ) - - lines += ["", "---"] - - # JSON - report = find_report(artifacts, "JsonBenchmarks") - if report: - lines += emit_comparison( - parse_table(report.read_text()), - "JSON", - get_description("Json"), - ) - - return "\n".join(lines) + "\n" - + rows = sorted(rows, key=_cat_sort_key) + out = ["", f"## {section}", "", desc] + + current_cat = None + excludes = set() + pending: list[tuple[str, str]] = [] # (raw_name, formatted_row) + + def flush_cat(): + """Emit chart, then table, then caveats for the current category.""" + nonlocal pending + if current_cat is None: + return + out.extend(_chart_ref(current_cat)) + out.extend(table_header) + pending.sort(key=lambda e: _LOGGER_RANK.get(e[0], len(LOGGER_ORDER))) + out.extend(row for _, row in pending) + out.extend(render_caveats(current_cat)) + pending = [] -def main(): - parser = argparse.ArgumentParser( - description="Generate docs/COMPARE.md from BDN artifacts." + table_header: list[str] = [] + + for r in rows: + method = r.get("Method", "") + cat = r.get("Categories", "") + if not method: + continue + + if cat != current_cat: + flush_cat() + current_cat = cat + pending = [] + excludes = get_excludes(cat) + title = f"{section}: {category_title(cat)}" + sub_key = cat.rsplit("_", 1)[-1] if "_" in cat else cat + sub_desc = get_description(sub_key) + sub_code = get_code_example(sub_key) + out += ["", f"### {title}", ""] + if sub_desc: + out += [sub_desc, ""] + if sub_code: + out += ["```csharp", sub_code, "```", ""] + table_header = [ + "| Logger | Mean | vs Clip | Allocated |", + "|--------|-----:|--------:|----------:|", + ] + + name = strip_prefix(method, cat) + if name in excludes: + continue + display = bold_clip(name) + mean = r.get("Mean", "") + ratio = r.get("Ratio", "") + alloc = fmt_alloc(r.get("Allocated", "")) + pending.append((name, f"| {display} | {mean} | {ratio} | {alloc} |")) + + flush_cat() + return out + + +def generate(db: dict) -> str: + lines: list[str] = ["# Clip — Benchmark Comparison", ""] + + # Environment header + env = get_environment(db) + env.append(f"Run: {datetime.now():%Y-%m-%d %H:%M}") + lines.append(" \n".join(env)) + + lines += [ + "", + "Clip is a zero-dependency structured logging library for .NET 9." + " It formats directly into pooled UTF-8 byte buffers — no intermediate" + " strings, no allocations on the hot path, no background-thread tricks" + " to hide latency.", + "", + "Clip ships two APIs that produce identical output:" + " **Clip** (ergonomic — pass an anonymous object, fields extracted" + " via compiled expression trees) and **ClipZero** (zero-alloc —" + " pass `Field` structs on the stack, nothing touches the heap).", + "", + "```csharp", + "// Ergonomic — one anonymous-object allocation, fields cached per type", + 'logger.Info("Request handled",', + " new { Method, Status, Elapsed, RequestId, Amount });", + "", + "// Zero-alloc — stack-allocated structs, zero heap allocations", + 'logger.Info("Request handled",', + ' new Field("Method", Method),', + ' new Field("Status", Status),', + ' new Field("Elapsed", Elapsed),', + ' new Field("RequestId", ReqId),', + ' new Field("Amount", Amount));', + "```", + "", + "This report puts Clip head-to-head against six established .NET loggers," + " all writing to `Stream.Null` so we measure pure formatting cost:", + "", + "- **Serilog** — rich sink ecosystem and message templates." + " Allocates a `LogEvent` and boxes value types per call.", + "- **NLog** — layout renderers give surgical control over output." + " String-based rendering with per-call allocations.", + "- **MEL** (Microsoft.Extensions.Logging) — ships with ASP.NET Core." + " Virtual dispatch, provider iteration, background I/O thread.", + "- **MELSrcGen** — MEL with `[LoggerMessage]` source generation." + " Eliminates runtime template parsing and value-type boxing." + " Same MEL pipeline underneath — this is how Microsoft recommends" + " using MEL in hot paths.", + "- **ZLogger** — Cysharp's high-performance logger built on MEL." + " Defers *all* formatting to a background thread — benchmarks" + " only reflect enqueue cost.", + "- **log4net** — the port of Java's Log4j." + " No structured fields, pattern layouts all the way down.", + "- **ClipMEL** — Clip behind MEL's `ILogger` via" + " `Clip.Extensions.Logging`. Shows MEL abstraction cost.", + "- **ZeroLog** — Abc-Arbitrage's zero-allocation logger." + " Builder API, synchronous mode — measures full formatting cost.", + ] + + lines += render_feature_matrix() + + lines += [ + "", + "---", + ] + + # Filtered + rows = load_class_rows("FilteredBenchmarks", db) + if rows: + lines += emit_filtered(rows) + + lines += ["", "---"] + + # Console + rows = load_class_rows("ConsoleBenchmarks", db) + if rows: + lines += emit_comparison( + rows, + "Console", + get_description("Console"), ) - parser.add_argument( - "--dir", - type=Path, - default=ARTIFACTS_DIR, - help="Path to BDN artifacts directory", - ) - parser.add_argument( - "--stdout", action="store_true", help="Print to stdout instead of file" + + lines += ["", "---"] + + # JSON + rows = load_class_rows("JsonBenchmarks", db) + if rows: + lines += emit_comparison( + rows, + "JSON", + get_description("Json"), ) - args = parser.parse_args() - artifacts = args.dir - to_stdout = args.stdout - if not artifacts.exists(): - print(f"Artifacts directory not found: {artifacts}", file=sys.stderr) - sys.exit(1) + return "\n".join(lines) + "\n" - result = generate(artifacts) - if to_stdout: - print(result, end="") - else: - OUTPUT_FILE.write_text(result) - print(f"Written {OUTPUT_FILE}") +def main(): + parser = argparse.ArgumentParser(description="Generate docs/COMPARE.md from benchmark database.") + parser.add_argument( + "--db", + type=Path, + default=DB_PATH, + help="Path to benchdb.json", + ) + parser.add_argument("--stdout", action="store_true", help="Print to stdout instead of file") + args = parser.parse_args() + + db = load_db(args.db) + result = generate(db) + + if args.stdout: + print(result, end="") + else: + OUTPUT_FILE.write_text(result) + print(f"Written {OUTPUT_FILE}") if __name__ == "__main__": - main() + main() diff --git a/scripts/metadata.py b/scripts/metadata.py index d8162b9..e12422a 100644 --- a/scripts/metadata.py +++ b/scripts/metadata.py @@ -13,743 +13,701 @@ EXCLUDES: loggers to filter out of specific categories. """ +import re + # Short explanation of what each benchmark category measures. # Keyed by section name or subcategory name (same matching rules). DESCRIPTIONS = { - "Filtered": { - "text": ( - "Debug call at Info minimum level — measures the cost" - " of checking the level and returning without doing" - " any work." - ), - "code": 'logger.Debug("This is filtered out");', - }, - "Console": { - "text": ( - "Human-readable text output — the format most developers" - " stare at during local development. Each logger formats" - " a line with timestamp, level, message, and structured" - " fields, then writes to `Stream.Null` so we measure" - " pure formatting cost, not I/O." - "\n\n" - "Clip's console output:" - "\n\n" - "```\n" - "2026-03-19 10:30:45.123 INFO Request handled" - " Method=GET Path=/api/users Status=200\n" - "2026-03-19 10:30:45.860 ERRO Connection failed" - " Host=db.local Port=5432\n" - " System.InvalidOperationException: connection refused\n" - "```" - "\n\n" - "This is where architectural choices really show." - " Clip formats directly into a pooled UTF-8 byte buffer" - " — one pass, no intermediate strings, no garbage." - " Serilog and NLog allocate event objects and render" - " through layers of abstractions." - " MEL formats synchronously, then hands a string to a" - " background thread for the actual write — you pay for" - " formatting *and* the handoff." - " ZLogger punts *everything* to a background thread," - " so its numbers here only show what it costs to drop" - " a message on a queue — the real work happens later," - " off the clock." - ), - }, - "Json": { - "text": ( - "Structured JSON — the format that actually goes to" - " production log aggregators. Each logger serializes" - " a JSON object with timestamp, level, message, and" - " fields to `Stream.Null` so we measure serialization" - " cost, not I/O." - "\n\n" - "Clip's JSON output:" - "\n\n" - "```json\n" - "{\n" - ' "ts": "2026-03-19T10:30:45.123Z",\n' - ' "level": "info",\n' - ' "msg": "Request handled",\n' - ' "fields": {\n' - ' "Method": "GET",\n' - ' "Status": 200,\n' - ' "Elapsed": 1.234,\n' - ' "RequestId": "550e8400-e29b-41d4-a716-446655440000",\n' - ' "Amount": 49.95\n' - " }\n" - "}\n" - "```" - "\n\n" - "Fields are typed — strings are quoted, numbers are bare," - " exceptions become nested `error` objects with `type`," - " `msg`, and `stack` fields. No toString() on everything." - "\n\n" - "JSON serialization is a harder test than console output." - " You need proper escaping, correct numeric formatting," - " and structured nesting — not just string concatenation." - " Clip writes JSON as raw UTF-8 bytes using a Utf8JsonWriter-style" - " approach with SIMD string escaping." - " Serilog wraps every value in its own property/value object" - " hierarchy before serializing through a TextWriter." - " NLog renders each attribute individually through its" - " layout engine — strings all the way." - " ZLogger defers serialization entirely to a background thread." - " And log4net? It doesn't have a JSON formatter at all — it" - " fakes it with a pattern string shaped like JSON. Structured" - " fields don't even make it into the output." - ), - }, - "NoFields": { - "text": "Message only, no structured fields attached.", - "code": 'logger.Info("Request handled");', - }, - "FiveFields": { - "text": ( - "Message with five structured fields:" - " string, int, double, Guid, and decimal." - ), - "code": ( - 'logger.Info("Request handled", new {\n' - ' Method = "GET",\n' - " Status = 200,\n" - " Elapsed = 1.234d,\n" - ' RequestId = Guid.Parse("550e8400-e29b-41d4-a716-446655440000"),\n' - " Amount = 49.95m,\n" - "});" - ), - }, - "WithContext": { - "text": ( - "Message inside a logging scope that adds" - " two context fields, plus one call-site field." - ), - "code": ( - "using (logger.AddContext(" - 'new { RequestId = "abc-123", UserId = 42 }))\n' - "{\n" - ' logger.Info("Processing", new { Step = "auth" });\n' - "}" - ), - }, - "WithException": { - "text": ("Message with an attached exception" " including a full stack trace."), - "code": ( - 'logger.Error("Connection failed", ex, new {\n' - ' Host = "db.local",\n' - " Port = 5432,\n" - "});" - ), - }, + "Filtered": { + "text": ( + "Debug call at Info minimum level — measures the cost" + " of checking the level and returning without doing" + " any work." + ), + "code": 'logger.Debug("This is filtered out");', + }, + "Console": { + "text": ( + "Human-readable text output — the format most developers" + " stare at during local development. Each logger formats" + " a line with timestamp, level, message, and structured" + " fields, then writes to `Stream.Null` so we measure" + " pure formatting cost, not I/O." + "\n\n" + "Clip's console output:" + "\n\n" + "```\n" + "2026-03-19 10:30:45.123 INFO Request handled" + " Method=GET Path=/api/users Status=200\n" + "2026-03-19 10:30:45.860 ERRO Connection failed" + " Host=db.local Port=5432\n" + " System.InvalidOperationException: connection refused\n" + "```" + "\n\n" + "This is where architectural choices really show." + " Clip formats directly into a pooled UTF-8 byte buffer" + " — one pass, no intermediate strings, no garbage." + " Serilog and NLog allocate event objects and render" + " through layers of abstractions." + " MEL formats synchronously, then hands a string to a" + " background thread for the actual write — you pay for" + " formatting *and* the handoff." + " ZLogger punts *everything* to a background thread," + " so its numbers here only show what it costs to drop" + " a message on a queue — the real work happens later," + " off the clock." + ), + }, + "Json": { + "text": ( + "Structured JSON — the format that actually goes to" + " production log aggregators. Each logger serializes" + " a JSON object with timestamp, level, message, and" + " fields to `Stream.Null` so we measure serialization" + " cost, not I/O." + "\n\n" + "Clip's JSON output:" + "\n\n" + "```json\n" + "{\n" + ' "ts": "2026-03-19T10:30:45.123Z",\n' + ' "level": "info",\n' + ' "msg": "Request handled",\n' + ' "fields": {\n' + ' "Method": "GET",\n' + ' "Status": 200,\n' + ' "Elapsed": 1.234,\n' + ' "RequestId": "550e8400-e29b-41d4-a716-446655440000",\n' + ' "Amount": 49.95\n' + " }\n" + "}\n" + "```" + "\n\n" + "Fields are typed — strings are quoted, numbers are bare," + " exceptions become nested `error` objects with `type`," + " `msg`, and `stack` fields. No toString() on everything." + "\n\n" + "JSON serialization is a harder test than console output." + " You need proper escaping, correct numeric formatting," + " and structured nesting — not just string concatenation." + " Clip writes JSON as raw UTF-8 bytes using a Utf8JsonWriter-style" + " approach with SIMD string escaping." + " Serilog wraps every value in its own property/value object" + " hierarchy before serializing through a TextWriter." + " NLog renders each attribute individually through its" + " layout engine — strings all the way." + " ZLogger defers serialization entirely to a background thread." + " And log4net? It doesn't have a JSON formatter at all — it" + " fakes it with a pattern string shaped like JSON. Structured" + " fields don't even make it into the output." + ), + }, + "NoFields": { + "text": "Message only, no structured fields attached.", + "code": 'logger.Info("Request handled");', + }, + "FiveFields": { + "text": ("Message with five structured fields: string, int, double, Guid, and decimal."), + "code": ( + 'logger.Info("Request handled", new {\n' + ' Method = "GET",\n' + " Status = 200,\n" + " Elapsed = 1.234d,\n" + ' RequestId = Guid.Parse("550e8400-e29b-41d4-a716-446655440000"),\n' + " Amount = 49.95m,\n" + "});" + ), + }, + "WithContext": { + "text": ( + "Message inside a logging scope that adds two context fields, plus one call-site field." + ), + "code": ( + "using (logger.AddContext(" + 'new { RequestId = "abc-123", UserId = 42 }))\n' + "{\n" + ' logger.Info("Processing", new { Step = "auth" });\n' + "}" + ), + }, + "WithException": { + "text": ("Message with an attached exception including a full stack trace."), + "code": ( + 'logger.Error("Connection failed", ex, new {\n Host = "db.local",\n Port = 5432,\n});' + ), + }, } CAVEATS = { - # - # Filtered - # - "Filtered": [ - { - "text": ( - "All loggers check the level and return immediately." - " No message is formatted, no output is written." - ), - }, - { - "logger": "Clip", - "text": ("Single integer comparison, inlined by the JIT."), - }, - { - "logger": "Serilog", - "text": ( - "Enum comparison against a mutable level switch." - " Fast, but the indirection prevents inlining." - ), - }, - { - "logger": "NLog", - "text": ("Reads a cached boolean flag. Near-zero overhead."), - }, - { - "logger": "MEL", - "text": ( - "Virtual dispatch through the `ILogger` interface," - " then iterates registered providers to check their" - " levels." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated method checks `ILogger.IsEnabled`" - " before doing any work — same dispatch cost as MEL." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Built on MEL, so pays the same interface-dispatch" - " and provider-iteration cost." - ), - }, - { - "logger": "Log4Net", - "text": ( - "Walks a parent-child logger hierarchy to resolve" - " the effective level." - ), - }, - { - "logger": "ClipMEL", - "text": ( - "Clip behind MEL's ILogger interface. The cost here" - " is entirely MEL's own dispatch — MEL's global" - " filter rejects the call before it ever reaches" - " Clip's provider. Matches bare MEL." - ), - }, - { - "logger": "ZeroLog", - "text": ( - "Checks a cached level flag. Near-zero overhead." - " Benchmarked via the concrete sealed `ZeroLog.Log`" - " class — ZeroLog does not expose an interface," - " giving it a small dispatch advantage over loggers" - " benchmarked through interfaces." - ), - }, - ], - # - # Console_NoFields - # - "Console_NoFields": [ - { - "logger": "Clip", - "text": ( - "Formats into a pooled byte buffer and writes UTF-8" - " directly — no intermediate strings. Timestamp is" - " cached so repeated calls within the same millisecond" - " skip reformatting." - ), - }, - { - "logger": "Serilog", - "text": ( - "Allocates a log-event object and parses the message" - " template per call. Output is rendered as strings via" - " a TextWriter, not raw bytes." - ), - }, - { - "logger": "NLog", - "text": ( - "Allocates a log-event struct per call. Output is" - " produced by a chain of layout renderers writing" - " strings." - ), - }, - { - "logger": "MEL", - "text": ( - "Formats the message synchronously on the calling" - " thread via SimpleConsoleFormatter, then enqueues" - " the formatted string for background I/O. The" - " benchmark captures the full formatting cost." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated `[LoggerMessage]` method — skips" - " runtime template parsing. Same MEL pipeline" - " (SimpleConsoleFormatter + background I/O) but" - " the generated code is more efficient at the" - " call site." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Enqueues the raw state to a background thread —" - " formatting is fully deferred. The benchmark" - " captures enqueue cost only." - ), - }, - { - "logger": "Log4Net", - "text": ( - "Synchronous like Clip. Allocates a log-event object" - " and formats through a pattern layout to strings." - ), - }, - { - "logger": "ClipMEL", - "text": ( - "Clip's formatting engine behind MEL's ILogger." - " Measures the cost of MEL's abstraction layer" - " on top of Clip." - ), - }, - { - "logger": "ZeroLog", - "text": ( - "Abc-Arbitrage's zero-allocation logger. Running in" - " synchronous mode so the benchmark measures full" - " formatting cost, not just enqueue." - " Benchmarked via the concrete sealed `ZeroLog.Log`" - " class (no interface available), giving it a small" - " dispatch advantage." - ), - }, - ], - # - # Console_FiveFields - # - "Console_FiveFields": [ - { - "logger": "Clip", - "text": ( - "Ergonomic tier allocates one anonymous object (40 B);" - " fields extracted via compiled expression trees (cached" - " per type). Zero-alloc tier passes fields as stack-allocated" - " structs — no boxing, no heap allocation. Both write typed" - " values into the same pooled byte buffer." - ), - }, - { - "logger": "Serilog", - "text": ( - "Each value is wrapped in a property object and" - " value types are boxed. The template is parsed to" - " match placeholders to arguments." - ), - }, - { - "logger": "NLog", - "text": ( - "Value-type arguments are boxed. Layout renderers" - " write each property as a string." - ), - }, - { - "logger": "MEL", - "text": ( - "Formats synchronously, then enqueues for background" - " I/O. Value-type arguments are boxed." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated — no template parsing, no boxing." - " Strongly-typed parameters passed directly." - " Same MEL formatting pipeline underneath." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Background thread — enqueue cost only." - " Interpolated-string handlers avoid boxing but add" - " struct construction overhead." - ), - }, - { - "logger": "Log4Net", - "text": ( - "Synchronous. Uses printf-style placeholders ({0})" - " — no named structured fields. Arguments are boxed." - ), - }, - { - "logger": "ClipMEL", - "text": ( - "Same MEL template API as MEL, but formatting is" - " handled by Clip's engine underneath." - ), - }, - { - "logger": "ZeroLog", - "text": ( - "Synchronous mode. Fields attached via builder API" - " (AppendKeyValue). Zero heap allocation per call." - " Benchmarked via concrete sealed class (no interface" - " available)." - ), - }, - ], - # - # WithContext (applies to all *_WithContext charts) - # - "WithContext": [ - { - "text": ( - "Log4Net and ZeroLog are excluded from context benchmarks" - " — neither has a scoped-context API comparable to Serilog" - " LogContext, NLog ScopeContext, or MEL BeginScope." - ), - }, - ], - # - # Console_WithContext - # - "Console_WithContext": [ - { - "logger": "Clip", - "text": ( - "Context stored in AsyncLocal. Ergonomic tier" - " allocates an anonymous object for call-site fields;" - " zero-alloc tier passes them as stack-allocated structs." - " Context and call-site fields merged at write time." - ), - }, - { - "logger": "Serilog", - "text": ( - "LogContext pushes properties via AsyncLocal." - " Properties are merged into the event object at" - " construction time." - ), - }, - { - "logger": "NLog", - "text": ( - "ScopeContext pushes properties via AsyncLocal." - " Merged at layout render time." - ), - }, - { - "logger": "MEL", - "text": ( - "Scope stored on the calling thread, formatted" - " synchronously by SimpleConsoleFormatter." - " Only the final I/O write is deferred." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated log call within MEL's BeginScope." - " No template parsing or boxing for the call-site" - " field. Same scope + formatting pipeline as MEL." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Scope stored on the calling thread, formatting" - " deferred to a background thread. The benchmark" - " only measures the calling thread." - ), - }, - { - "logger": "ClipMEL", - "text": ( - "Uses MEL's BeginScope, then delegates to Clip's" " formatting engine." - ), - }, - ], - # - # Console_WithException - # - "Console_WithException": [ - { - "logger": "Clip", - "text": ( - "Exception rendered synchronously into the same" " pooled byte buffer." - ), - }, - { - "logger": "Serilog", - "text": ( - "Exception rendered synchronously, appended as a" - " string to the output." - ), - }, - { - "logger": "NLog", - "text": ( - "Exception rendered synchronously via the layout." - " Full stack trace appended as text after the message." - ), - }, - { - "logger": "MEL", - "text": ( - "Exception formatted synchronously on the calling" - " thread by SimpleConsoleFormatter (including" - " exception.ToString()). Only the final I/O write" - " is deferred to a background thread." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated — no template parsing or boxing." - " Exception still formatted synchronously by" - " SimpleConsoleFormatter." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Exception formatting deferred to a background" - " thread. The benchmark only measures enqueue cost." - ), - }, - { - "logger": "Log4Net", - "text": ( - "Exception rendered synchronously via the layout" - " pattern. Full stack trace appended as text." - ), - }, - { - "logger": "ClipMEL", - "text": ( - "Exception formatted by Clip's engine behind MEL's" - " ILogger interface." - ), - }, - { - "logger": "ZeroLog", - "text": ( - "Synchronous mode. Exception attached via builder." - " Zero heap allocation per call." - " Benchmarked via concrete sealed class (no interface" - " available)." - ), - }, - { - "text": ( - "Exception benchmarks are not directly comparable" - " across loggers — ZLogger defers formatting to a" - " background thread while all others format" - " synchronously." - ), - }, - ], - # - # JSON-specific (applies to all Json_* charts) - # - "Json": [ - { - "text": ( - "Log4Net and ZeroLog are excluded from JSON" - " benchmarks. Log4Net has no JSON formatter." - " ZeroLog has no built-in JSON output mode." - ), - }, - ], - # - # Json_NoFields - # - "Json_NoFields": [ - { - "logger": "Clip", - "text": ( - "Builds JSON as raw UTF-8 bytes into a pooled buffer." - " String values are escaped using SIMD." - ), - }, - { - "logger": "Serilog", - "text": ( - "Serializes through its own object model — each value" - " is wrapped in a property object. Output goes through" - " a TextWriter (strings, not raw bytes)." - ), - }, - { - "logger": "NLog", - "text": ( - "Each JSON attribute is rendered individually through" - " the layout engine. String-based output." - ), - }, - { - "logger": "MEL", - "text": ( - "Uses JsonConsoleFormatter. Formats synchronously" - " on the calling thread, then enqueues for" - " background I/O." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated — no template parsing." - " Same JsonConsoleFormatter pipeline as MEL." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Background thread — benchmark measures enqueue cost" - " only. Has a real JSON formatter." - ), - }, - ], - # - # Json_FiveFields - # - "Json_FiveFields": [ - { - "logger": "Clip", - "text": ( - "Ergonomic tier allocates one anonymous object (40 B);" - " fields extracted via expression trees. Zero-alloc tier" - " passes stack-allocated structs directly. Both write typed" - " JSON values with no boxing and no intermediate strings." - ), - }, - { - "logger": "Serilog", - "text": ( - "Each argument is wrapped in a property object then" - " serialized. Value types are boxed." - ), - }, - { - "logger": "NLog", - "text": ( - "Event properties are boxed and rendered through the" - " layout engine as strings." - ), - }, - { - "logger": "MEL", - "text": ( - "Uses JsonConsoleFormatter. Value types are boxed." - " Formatted synchronously, then enqueued for" - " background I/O." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated — no template parsing, no boxing." - " Same JsonConsoleFormatter pipeline as MEL." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Background thread — enqueue cost only." - " Interpolated-string handlers avoid boxing but add" - " struct construction overhead." - ), - }, - ], - # - # Json_WithContext - # - "Json_WithContext": [ - { - "logger": "Clip", - "text": ( - "Ergonomic tier allocates an anonymous object for" - " call-site fields; zero-alloc tier uses stack-allocated" - " structs. Context and call-site fields merged at write" - " time into the same pooled buffer." - ), - }, - { - "logger": "Serilog", - "text": ( - "Context properties merged into the event object" - " and serialized through the object model." - ), - }, - { - "logger": "NLog", - "text": ( - "Scope properties merged and rendered through the" " layout engine." - ), - }, - { - "logger": "MEL", - "text": ( - "Scope stored on the calling thread, formatted" - " synchronously by JsonConsoleFormatter." - " Only the final I/O write is deferred." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated log call within MEL's BeginScope." - " Same JsonConsoleFormatter pipeline as MEL." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Scope stored on the calling thread, rendered on a" - " background thread. The benchmark only measures the" - " calling thread." - ), - }, - ], - # - # Json_WithException - # - "Json_WithException": [ - { - "logger": "Clip", - "text": ( - "Exception serialized as a structured JSON object" - " synchronously into the pooled buffer." - ), - }, - { - "logger": "Serilog", - "text": ("Exception serialized as a string property" " synchronously."), - }, - { - "logger": "NLog", - "text": ( - "Exception serialized as a JSON string attribute" " synchronously." - ), - }, - { - "logger": "MEL", - "text": ( - "Exception formatted synchronously by" - " JsonConsoleFormatter. Only the final I/O" - " write is deferred." - ), - }, - { - "logger": "MELSrcGen", - "text": ( - "Source-generated — no template parsing or boxing." - " Exception still formatted synchronously by" - " JsonConsoleFormatter." - ), - }, - { - "logger": "ZLogger", - "text": ( - "Exception formatting deferred to a background" - " thread. The benchmark only measures enqueue cost." - ), - }, - { - "text": ( - "Exception benchmarks are not directly comparable" - " across loggers — ZLogger defers formatting to a" - " background thread while all others format" - " synchronously." - ), - }, - ], + # + # Filtered + # + "Filtered": [ + { + "text": ( + "All loggers check the level and return immediately." + " No message is formatted, no output is written." + ), + }, + { + "logger": "Clip", + "text": ("Single integer comparison, inlined by the JIT."), + }, + { + "logger": "Serilog", + "text": ( + "Enum comparison against a mutable level switch." + " Fast, but the indirection prevents inlining." + ), + }, + { + "logger": "NLog", + "text": ("Reads a cached boolean flag. Near-zero overhead."), + }, + { + "logger": "MEL", + "text": ( + "Virtual dispatch through the `ILogger` interface," + " then iterates registered providers to check their" + " levels." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated method checks `ILogger.IsEnabled`" + " before doing any work — same dispatch cost as MEL." + ), + }, + { + "logger": "ZLogger", + "text": ("Built on MEL, so pays the same interface-dispatch and provider-iteration cost."), + }, + { + "logger": "Log4Net", + "text": ("Walks a parent-child logger hierarchy to resolve the effective level."), + }, + { + "logger": "ClipMEL", + "text": ( + "Clip behind MEL's ILogger interface. The cost here" + " is entirely MEL's own dispatch — MEL's global" + " filter rejects the call before it ever reaches" + " Clip's provider. Matches bare MEL." + ), + }, + { + "logger": "ZeroLog", + "text": ( + "Checks a cached level flag. Near-zero overhead." + " Benchmarked via the concrete sealed `ZeroLog.Log`" + " class — ZeroLog does not expose an interface," + " giving it a small dispatch advantage over loggers" + " benchmarked through interfaces." + ), + }, + ], + # + # Console_NoFields + # + "Console_NoFields": [ + { + "logger": "Clip", + "text": ( + "Formats into a pooled byte buffer and writes UTF-8" + " directly — no intermediate strings. Timestamp is" + " cached so repeated calls within the same millisecond" + " skip reformatting." + ), + }, + { + "logger": "Serilog", + "text": ( + "Allocates a log-event object and parses the message" + " template per call. Output is rendered as strings via" + " a TextWriter, not raw bytes." + ), + }, + { + "logger": "NLog", + "text": ( + "Allocates a log-event struct per call. Output is" + " produced by a chain of layout renderers writing" + " strings." + ), + }, + { + "logger": "MEL", + "text": ( + "Formats the message synchronously on the calling" + " thread via SimpleConsoleFormatter, then enqueues" + " the formatted string for background I/O. The" + " benchmark captures the full formatting cost." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated `[LoggerMessage]` method — skips" + " runtime template parsing. Same MEL pipeline" + " (SimpleConsoleFormatter + background I/O) but" + " the generated code is more efficient at the" + " call site." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Enqueues the raw state to a background thread —" + " formatting is fully deferred. The benchmark" + " captures enqueue cost only." + ), + }, + { + "logger": "Log4Net", + "text": ( + "Synchronous like Clip. Allocates a log-event object" + " and formats through a pattern layout to strings." + ), + }, + { + "logger": "ClipMEL", + "text": ( + "Clip's formatting engine behind MEL's ILogger." + " Measures the cost of MEL's abstraction layer" + " on top of Clip." + ), + }, + { + "logger": "ZeroLog", + "text": ( + "Abc-Arbitrage's zero-allocation logger. Running in" + " synchronous mode so the benchmark measures full" + " formatting cost, not just enqueue." + " Benchmarked via the concrete sealed `ZeroLog.Log`" + " class (no interface available), giving it a small" + " dispatch advantage." + ), + }, + ], + # + # Console_FiveFields + # + "Console_FiveFields": [ + { + "logger": "Clip", + "text": ( + "Ergonomic tier allocates one anonymous object (40 B);" + " fields extracted via compiled expression trees (cached" + " per type). Zero-alloc tier passes fields as stack-allocated" + " structs — no boxing, no heap allocation. Both write typed" + " values into the same pooled byte buffer." + ), + }, + { + "logger": "Serilog", + "text": ( + "Each value is wrapped in a property object and" + " value types are boxed. The template is parsed to" + " match placeholders to arguments." + ), + }, + { + "logger": "NLog", + "text": ("Value-type arguments are boxed. Layout renderers write each property as a string."), + }, + { + "logger": "MEL", + "text": ( + "Formats synchronously, then enqueues for background I/O. Value-type arguments are boxed." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated — no template parsing, no boxing." + " Strongly-typed parameters passed directly." + " Same MEL formatting pipeline underneath." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Background thread — enqueue cost only." + " Interpolated-string handlers avoid boxing but add" + " struct construction overhead." + ), + }, + { + "logger": "Log4Net", + "text": ( + "Synchronous. Uses printf-style placeholders ({0})" + " — no named structured fields. Arguments are boxed." + ), + }, + { + "logger": "ClipMEL", + "text": ( + "Same MEL template API as MEL, but formatting is handled by Clip's engine underneath." + ), + }, + { + "logger": "ZeroLog", + "text": ( + "Synchronous mode. Fields attached via builder API" + " (AppendKeyValue). Zero heap allocation per call." + " Benchmarked via concrete sealed class (no interface" + " available)." + ), + }, + ], + # + # WithContext (applies to all *_WithContext charts) + # + "WithContext": [ + { + "text": ( + "Log4Net and ZeroLog are excluded from context benchmarks" + " — neither has a scoped-context API comparable to Serilog" + " LogContext, NLog ScopeContext, or MEL BeginScope." + ), + }, + ], + # + # Console_WithContext + # + "Console_WithContext": [ + { + "logger": "Clip", + "text": ( + "Context stored in AsyncLocal. Ergonomic tier" + " allocates an anonymous object for call-site fields;" + " zero-alloc tier passes them as stack-allocated structs." + " Context and call-site fields merged at write time." + ), + }, + { + "logger": "Serilog", + "text": ( + "LogContext pushes properties via AsyncLocal." + " Properties are merged into the event object at" + " construction time." + ), + }, + { + "logger": "NLog", + "text": ("ScopeContext pushes properties via AsyncLocal. Merged at layout render time."), + }, + { + "logger": "MEL", + "text": ( + "Scope stored on the calling thread, formatted" + " synchronously by SimpleConsoleFormatter." + " Only the final I/O write is deferred." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated log call within MEL's BeginScope." + " No template parsing or boxing for the call-site" + " field. Same scope + formatting pipeline as MEL." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Scope stored on the calling thread, formatting" + " deferred to a background thread. The benchmark" + " only measures the calling thread." + ), + }, + { + "logger": "ClipMEL", + "text": ("Uses MEL's BeginScope, then delegates to Clip's formatting engine."), + }, + ], + # + # Console_WithException + # + "Console_WithException": [ + { + "logger": "Clip", + "text": ("Exception rendered synchronously into the same pooled byte buffer."), + }, + { + "logger": "Serilog", + "text": ("Exception rendered synchronously, appended as a string to the output."), + }, + { + "logger": "NLog", + "text": ( + "Exception rendered synchronously via the layout." + " Full stack trace appended as text after the message." + ), + }, + { + "logger": "MEL", + "text": ( + "Exception formatted synchronously on the calling" + " thread by SimpleConsoleFormatter (including" + " exception.ToString()). Only the final I/O write" + " is deferred to a background thread." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated — no template parsing or boxing." + " Exception still formatted synchronously by" + " SimpleConsoleFormatter." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Exception formatting deferred to a background" + " thread. The benchmark only measures enqueue cost." + ), + }, + { + "logger": "Log4Net", + "text": ( + "Exception rendered synchronously via the layout" + " pattern. Full stack trace appended as text." + ), + }, + { + "logger": "ClipMEL", + "text": ("Exception formatted by Clip's engine behind MEL's ILogger interface."), + }, + { + "logger": "ZeroLog", + "text": ( + "Synchronous mode. Exception attached via builder." + " Zero heap allocation per call." + " Benchmarked via concrete sealed class (no interface" + " available)." + ), + }, + { + "text": ( + "Exception benchmarks are not directly comparable" + " across loggers — ZLogger defers formatting to a" + " background thread while all others format" + " synchronously." + ), + }, + ], + # + # JSON-specific (applies to all Json_* charts) + # + "Json": [ + { + "text": ( + "Log4Net and ZeroLog are excluded from JSON" + " benchmarks. Log4Net has no JSON formatter." + " ZeroLog has no built-in JSON output mode." + ), + }, + ], + # + # Json_NoFields + # + "Json_NoFields": [ + { + "logger": "Clip", + "text": ( + "Builds JSON as raw UTF-8 bytes into a pooled buffer. String values are escaped using SIMD." + ), + }, + { + "logger": "Serilog", + "text": ( + "Serializes through its own object model — each value" + " is wrapped in a property object. Output goes through" + " a TextWriter (strings, not raw bytes)." + ), + }, + { + "logger": "NLog", + "text": ( + "Each JSON attribute is rendered individually through" + " the layout engine. String-based output." + ), + }, + { + "logger": "MEL", + "text": ( + "Uses JsonConsoleFormatter. Formats synchronously" + " on the calling thread, then enqueues for" + " background I/O." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated — no template parsing. Same JsonConsoleFormatter pipeline as MEL." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Background thread — benchmark measures enqueue cost only. Has a real JSON formatter." + ), + }, + ], + # + # Json_FiveFields + # + "Json_FiveFields": [ + { + "logger": "Clip", + "text": ( + "Ergonomic tier allocates one anonymous object (40 B);" + " fields extracted via expression trees. Zero-alloc tier" + " passes stack-allocated structs directly. Both write typed" + " JSON values with no boxing and no intermediate strings." + ), + }, + { + "logger": "Serilog", + "text": ( + "Each argument is wrapped in a property object then serialized. Value types are boxed." + ), + }, + { + "logger": "NLog", + "text": ("Event properties are boxed and rendered through the layout engine as strings."), + }, + { + "logger": "MEL", + "text": ( + "Uses JsonConsoleFormatter. Value types are boxed." + " Formatted synchronously, then enqueued for" + " background I/O." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated — no template parsing, no boxing." + " Same JsonConsoleFormatter pipeline as MEL." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Background thread — enqueue cost only." + " Interpolated-string handlers avoid boxing but add" + " struct construction overhead." + ), + }, + ], + # + # Json_WithContext + # + "Json_WithContext": [ + { + "logger": "Clip", + "text": ( + "Ergonomic tier allocates an anonymous object for" + " call-site fields; zero-alloc tier uses stack-allocated" + " structs. Context and call-site fields merged at write" + " time into the same pooled buffer." + ), + }, + { + "logger": "Serilog", + "text": ( + "Context properties merged into the event object and serialized through the object model." + ), + }, + { + "logger": "NLog", + "text": ("Scope properties merged and rendered through the layout engine."), + }, + { + "logger": "MEL", + "text": ( + "Scope stored on the calling thread, formatted" + " synchronously by JsonConsoleFormatter." + " Only the final I/O write is deferred." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated log call within MEL's BeginScope." + " Same JsonConsoleFormatter pipeline as MEL." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Scope stored on the calling thread, rendered on a" + " background thread. The benchmark only measures the" + " calling thread." + ), + }, + ], + # + # Json_WithException + # + "Json_WithException": [ + { + "logger": "Clip", + "text": ( + "Exception serialized as a structured JSON object synchronously into the pooled buffer." + ), + }, + { + "logger": "Serilog", + "text": ("Exception serialized as a string property synchronously."), + }, + { + "logger": "NLog", + "text": ("Exception serialized as a JSON string attribute synchronously."), + }, + { + "logger": "MEL", + "text": ( + "Exception formatted synchronously by" + " JsonConsoleFormatter. Only the final I/O" + " write is deferred." + ), + }, + { + "logger": "MELSrcGen", + "text": ( + "Source-generated — no template parsing or boxing." + " Exception still formatted synchronously by" + " JsonConsoleFormatter." + ), + }, + { + "logger": "ZLogger", + "text": ( + "Exception formatting deferred to a background" + " thread. The benchmark only measures enqueue cost." + ), + }, + { + "text": ( + "Exception benchmarks are not directly comparable" + " across loggers — ZLogger defers formatting to a" + " background thread while all others format" + " synchronously." + ), + }, + ], } # Loggers to exclude from specific categories. # Keyed by category pattern (same matching rules as CAVEATS). EXCLUDES = { - "Json": ["Log4Net", "ZeroLog"], - "WithContext": ["Log4Net", "ZeroLog"], + "Json": ["Log4Net", "ZeroLog"], + "WithContext": ["Log4Net", "ZeroLog"], } # @@ -759,276 +717,378 @@ # Each feature maps to a short label shown in the column header. # Each logger maps to a dict of feature -> value. # Values: True / False / str (short note shown in the cell). -FEATURE_LABELS = [ - ("structured", "Structured Fields"), - ("typed_fields", "Typed Fields"), - ("zero_alloc", "Zero-Alloc API"), - ("scoped_ctx", "Scoped Context"), - ("console", "Console Sink"), - ("json", "JSON Sink"), - ("async", "Async / Background"), - ("msg_templates", "Message Templates"), - ("src_gen", "Source Generator"), +FEATURE_TABLES = [ + ( + "API & Data Model", + [ + ("structured", "Structured Fields"), + ("typed_fields", "Typed Fields"), + ("zero_alloc", "Zero-Alloc API"), + ("msg_templates", "Message Templates"), + ("src_gen", "Source Generator"), + ], + ), + ( + "Pipeline", + [ + ("enrichers", "Enrichers"), + ("level_gated_enrichers", "Level-Gated Enrichers"), + ("filters", "Filters"), + ("redactors", "Redactors"), + ("scoped_ctx", "Scoped Context"), + ], + ), + ( + "Output", + [ + ("console", "Console Sink"), + ("json", "JSON Sink"), + ("file", "File Sink"), + ("otlp", "OpenTelemetry / OTLP"), + ], + ), + ( + "Architecture", + [ + ("sync_default", "Sync-by-Default"), + ("async", "Async / Background"), + ("buffer_pooling", "Buffer Pooling"), + ("zero_deps", "Zero Dependencies"), + ("mel_adapter", "MEL Adapter"), + ], + ), ] FEATURES = { - "Clip": { - "structured": True, - "typed_fields": True, - "zero_alloc": True, - "scoped_ctx": True, - "console": True, - "json": True, - "async": True, - "msg_templates": False, - "src_gen": False, - }, - "Serilog": { - "structured": True, - "typed_fields": False, - "zero_alloc": False, - "scoped_ctx": True, - "console": True, - "json": True, - "async": True, - "msg_templates": True, - "src_gen": False, - }, - "NLog": { - "structured": True, - "typed_fields": False, - "zero_alloc": False, - "scoped_ctx": True, - "console": True, - "json": True, - "async": True, - "msg_templates": True, - "src_gen": False, - }, - "MEL": { - "structured": True, - "typed_fields": False, - "zero_alloc": False, - "scoped_ctx": True, - "console": True, - "json": True, - "async": True, - "msg_templates": True, - "src_gen": True, - }, - "ZLogger": { - "structured": True, - "typed_fields": False, - "zero_alloc": True, - "scoped_ctx": True, - "console": True, - "json": True, - "async": True, - "msg_templates": True, - "src_gen": True, - }, - "Log4Net": { - "structured": False, - "typed_fields": False, - "zero_alloc": False, - "scoped_ctx": False, - "console": True, - "json": False, - "async": True, - "msg_templates": False, - "src_gen": False, - }, - "ZeroLog": { - "structured": True, - "typed_fields": True, - "zero_alloc": True, - "scoped_ctx": False, - "console": True, - "json": False, - "async": True, - "msg_templates": False, - "src_gen": False, - }, + "Clip": { + "structured": True, + "typed_fields": True, + "zero_alloc": True, + "msg_templates": False, + "src_gen": False, + "enrichers": True, + "level_gated_enrichers": True, + "filters": True, + "redactors": True, + "scoped_ctx": True, + "console": True, + "json": True, + "file": True, + "otlp": True, + "sync_default": True, + "async": True, + "buffer_pooling": True, + "zero_deps": True, + "mel_adapter": True, + }, + "Serilog": { + "structured": True, + "typed_fields": False, + "zero_alloc": False, + "msg_templates": True, + "src_gen": False, + "enrichers": True, + "level_gated_enrichers": True, + "filters": True, + "redactors": False, + "scoped_ctx": True, + "console": True, + "json": True, + "file": True, + "otlp": True, + "sync_default": True, + "async": True, + "buffer_pooling": False, + "zero_deps": False, + "mel_adapter": True, + }, + "NLog": { + "structured": True, + "typed_fields": False, + "zero_alloc": False, + "msg_templates": True, + "src_gen": False, + "enrichers": True, + "level_gated_enrichers": False, + "filters": True, + "redactors": False, + "scoped_ctx": True, + "console": True, + "json": True, + "file": True, + "otlp": True, + "sync_default": True, + "async": True, + "buffer_pooling": True, + "zero_deps": True, + "mel_adapter": True, + }, + "MEL": { + "structured": True, + "typed_fields": False, + "zero_alloc": False, + "msg_templates": True, + "src_gen": True, + "enrichers": True, + "level_gated_enrichers": False, + "filters": True, + "redactors": True, + "scoped_ctx": True, + "console": True, + "json": True, + "file": False, + "otlp": True, + "sync_default": False, + "async": True, + "buffer_pooling": False, + "zero_deps": False, + "mel_adapter": False, + }, + "ZLogger": { + "structured": True, + "typed_fields": True, + "zero_alloc": True, + "msg_templates": False, + "src_gen": True, + "enrichers": False, + "level_gated_enrichers": False, + "filters": True, + "redactors": False, + "scoped_ctx": True, + "console": True, + "json": True, + "file": True, + "otlp": False, + "sync_default": False, + "async": True, + "buffer_pooling": True, + "zero_deps": False, + "mel_adapter": False, + }, + "Log4Net": { + "structured": False, + "typed_fields": False, + "zero_alloc": False, + "msg_templates": False, + "src_gen": False, + "enrichers": False, + "level_gated_enrichers": False, + "filters": True, + "redactors": False, + "scoped_ctx": False, + "console": True, + "json": False, + "file": True, + "otlp": False, + "sync_default": True, + "async": False, + "buffer_pooling": False, + "zero_deps": False, + "mel_adapter": True, + }, + "ZeroLog": { + "structured": True, + "typed_fields": True, + "zero_alloc": True, + "msg_templates": False, + "src_gen": False, + "enrichers": False, + "level_gated_enrichers": False, + "filters": False, + "redactors": False, + "scoped_ctx": False, + "console": True, + "json": False, + "file": True, + "otlp": False, + "sync_default": False, + "async": True, + "buffer_pooling": True, + "zero_deps": False, + "mel_adapter": False, + }, } _FEATURE_LOGGER_ORDER = [ - "Clip", - "Serilog", - "NLog", - "MEL", - "ZLogger", - "Log4Net", - "ZeroLog", + "Clip", + "Serilog", + "NLog", + "MEL", + "ZLogger", + "Log4Net", + "ZeroLog", ] def render_feature_matrix(): - """Render the feature matrix as a markdown table. + """Render the feature matrix as grouped markdown tables. - Returns a list of strings (one per line). - """ - loggers = [l for l in _FEATURE_LOGGER_ORDER if l in FEATURES] + Returns a list of strings (one per line). + """ + loggers = [l for l in _FEATURE_LOGGER_ORDER if l in FEATURES] - lines = ["", "## Feature Matrix", ""] + lines = ["", "## Feature Matrix", ""] - # Header row - header = "| Feature | " + " | ".join(loggers) + " |" + for group_title, labels in FEATURE_TABLES: + header = "| " + group_title + " | " + " | ".join(loggers) + " |" sep = "|---------|" + "|".join(":---:" for _ in loggers) + "|" lines += [header, sep] - for key, label in FEATURE_LABELS: - cells = [] - for logger in loggers: - val = FEATURES.get(logger, {}).get(key, False) - if val is True: - cells.append("\u2705") - elif val is False: - cells.append("\u2014") - else: - cells.append(str(val)) - lines.append(f"| {label} | " + " | ".join(cells) + " |") + for key, label in labels: + cells = [] + for logger in loggers: + val = FEATURES.get(logger, {}).get(key, False) + if val is True: + cells.append("\u2705") + elif val is False: + cells.append("\u2014") + else: + cells.append(str(val)) + lines.append(f"| {label} | " + " | ".join(cells) + " |") + + lines.append("") - return lines + return lines LOGGER_ORDER = [ - "Clip", - "ClipZero", - "ClipMEL", - "MEL", - "MELSrcGen", - "Serilog", - "ZLogger", - "NLog", - "Log4Net", - "ZeroLog", + "Clip", + "ClipZero", + "ClipMEL", + "MEL", + "MELSrcGen", + "Serilog", + "ZLogger", + "NLog", + "Log4Net", + "ZeroLog", ] +UNIT_NS = {"ns": 1, "μs": 1_000, "us": 1_000, "ms": 1_000_000, "s": 1_000_000_000} + + +def parse_mean_ns(value: str) -> float | None: + """Convert a BDN mean string like '27.22 ns' to nanoseconds.""" + m = re.match(r"([0-9.,]+)\s*(ns|μs|us|ms|s)", value.strip()) + if not m: + return None + num = float(m.group(1).replace(",", "")) + return num * UNIT_NS[m.group(2)] + + def parse_table(text: str) -> list[dict[str, str]]: - """Parse a BDN markdown table into a list of row dicts.""" - lines = [l for l in text.splitlines() if l.startswith("|")] - if len(lines) < 3: - return [] + """Parse a BDN markdown table into a list of row dicts.""" + lines = [l for l in text.splitlines() if l.startswith("|")] + if len(lines) < 3: + return [] - headers = [h.strip() for h in lines[0].split("|")[1:-1]] - rows = [] - for line in lines[2:]: # skip header + separator - cells = [c.strip() for c in line.split("|")[1:-1]] - if not any(cells): - continue - rows.append(dict(zip(headers, cells))) - return rows + headers = [h.strip() for h in lines[0].split("|")[1:-1]] + rows = [] + for line in lines[2:]: # skip header + separator + cells = [c.strip() for c in line.split("|")[1:-1]] + if not any(cells): + continue + rows.append(dict(zip(headers, cells))) + return rows def strip_prefix(method: str, category: str) -> str: - """Strip the category-derived prefix from a method name. + """Strip the category-derived prefix from a method name. - BDN method names follow the pattern _, where - matches the end of the category name. E.g. in category 'Json_WithContext', - method 'WithContext_Clip' -> 'Clip'. - """ - cat_suffix = category.rsplit("_", 1)[-1] if "_" in category else category - prefix = f"{cat_suffix}_" - if method.startswith(prefix): - return method[len(prefix) :] - # Also try the full category as prefix (for single-word categories) - if method.startswith(f"{category}_"): - return method[len(category) + 1 :] - return method + BDN method names follow the pattern _, where + matches the end of the category name. E.g. in category 'Json_WithContext', + method 'WithContext_Clip' -> 'Clip'. + """ + cat_suffix = category.rsplit("_", 1)[-1] if "_" in category else category + prefix = f"{cat_suffix}_" + if method.startswith(prefix): + return method[len(prefix) :] + # Also try the full category as prefix (for single-word categories) + if method.startswith(f"{category}_"): + return method[len(category) + 1 :] + return method def _matches(key, category): - """Check if a caveat key matches a category name.""" - if key == category: - return True - if category.endswith("_" + key): - return True - # Prefix match for sink-wide notes (e.g., "Json" matches "Json_*") - if category.startswith(key + "_"): - return True - return False + """Check if a caveat key matches a category name.""" + if key == category: + return True + if category.endswith("_" + key): + return True + # Prefix match for sink-wide notes (e.g., "Json" matches "Json_*") + if category.startswith(key + "_"): + return True + return False def get_description(key): - """Look up a description text by exact key.""" - entry = DESCRIPTIONS.get(key) - return entry["text"] if entry else None + """Look up a description text by exact key.""" + entry = DESCRIPTIONS.get(key) + return entry["text"] if entry else None def get_code_example(key): - """Look up a Clip code example by exact key.""" - entry = DESCRIPTIONS.get(key) - return entry.get("code") if entry else None + """Look up a Clip code example by exact key.""" + entry = DESCRIPTIONS.get(key) + return entry.get("code") if entry else None def get_excludes(category): - """Return set of logger names to exclude for a category.""" - result = set() - for key, names in EXCLUDES.items(): - if _matches(key, category): - result.update(names) - return result + """Return set of logger names to exclude for a category.""" + result = set() + for key, names in EXCLUDES.items(): + if _matches(key, category): + result.update(names) + return result def get_caveats(category): - """Collect all caveat entries that match a category.""" - result = [] - for key, entries in CAVEATS.items(): - if _matches(key, category): - result.extend(entries) - return result + """Collect all caveat entries that match a category.""" + result = [] + for key, entries in CAVEATS.items(): + if _matches(key, category): + result.extend(entries) + return result -_CAVEAT_LOGGER_ORDER = [ - "Clip", - "ClipZero", - "ClipMEL", - "MEL", - "MELSrcGen", - "Serilog", - "ZLogger", - "NLog", - "Log4Net", - "ZeroLog", -] -_CAVEAT_RANK = {name: i for i, name in enumerate(_CAVEAT_LOGGER_ORDER)} +_CAVEAT_RANK = {name: i for i, name in enumerate(LOGGER_ORDER)} def render_caveats(category): - """Render matching caveats as separate markdown blockquotes. + """Render matching caveats as separate markdown blockquotes. - Returns a list of strings (one per line), or empty list if no - caveats match. Each entry becomes its own blockquote block, - separated by a blank line for visual distinction. - Automatically filters out caveats for excluded loggers. - Logger-specific caveats are sorted by LOGGER_ORDER; general - (no-logger) caveats appear at the end. - """ - excludes = get_excludes(category) - entries = [e for e in get_caveats(category) if e.get("logger") not in excludes] - if not entries: - return [] + Returns a list of strings (one per line), or empty list if no + caveats match. Each entry becomes its own blockquote block, + separated by a blank line for visual distinction. + Automatically filters out caveats for excluded loggers. + Logger-specific caveats are sorted by LOGGER_ORDER; general + (no-logger) caveats appear at the end. + """ + excludes = get_excludes(category) + entries = [e for e in get_caveats(category) if e.get("logger") not in excludes] + if not entries: + return [] - # Sort: logger-specific entries by LOGGER_ORDER, general entries last - entries.sort( - key=lambda e: ( - _CAVEAT_RANK.get(e.get("logger", ""), len(_CAVEAT_LOGGER_ORDER)), - 0 if e.get("logger") else 1, - ) + # Sort: logger-specific entries by LOGGER_ORDER, general entries last + entries.sort( + key=lambda e: ( + _CAVEAT_RANK.get(e.get("logger", ""), len(_CAVEAT_LOGGER_ORDER)), + 0 if e.get("logger") else 1, ) + ) - lines = [] - for i, entry in enumerate(entries): - logger = entry.get("logger") - text = entry["text"] - if i > 0: - # HTML comment forces markdown to create separate
s - lines += ["", ""] - lines.append("") - if logger: - lines.append(f"> **{logger}:** {text}") - else: - lines.append(f"> {text}") + lines = [] + for i, entry in enumerate(entries): + logger = entry.get("logger") + text = entry["text"] + if i > 0: + # HTML comment forces markdown to create separate
s + lines += ["", ""] + lines.append("") + if logger: + lines.append(f"> **{logger}:** {text}") + else: + lines.append(f"> {text}") - return lines + return lines diff --git a/scripts/pdf.py b/scripts/pdf.py index 05f0e63..4c6f9ee 100644 --- a/scripts/pdf.py +++ b/scripts/pdf.py @@ -4,11 +4,11 @@ Usage: python3 scripts/pdf.py -Requires: weasyprint, markdown, Pygments - pip install -r scripts/requirements.txt +Requires: uv sync brew install pango (macOS system dependency for weasyprint) """ +import html as html_mod import re from pathlib import Path @@ -30,17 +30,17 @@ # Match ... inside
 tags
 _CODE_BLOCK_RE = re.compile(
-    r'
(.*?)
', - re.DOTALL, + r'
(.*?)
', + re.DOTALL, ) # Match bare
...
(no language specified) _CODE_BARE_RE = re.compile( - r"
(.*?)
", - re.DOTALL, + r"
(.*?)
", + re.DOTALL, ) CSS = ( - """ + """ @page { size: A4; margin: 18mm 16mm; @@ -143,89 +143,79 @@ height: 0; } """ - + _pygments_css + + _pygments_css ) def _highlight_match(m): - """Replace a
 block with Pygments HTML."""
-    lang = m.group(1)
-    code = m.group(2)
-    code = (
-        code.replace("<", "<")
-        .replace(">", ">")
-        .replace("&", "&")
-        .replace(""", '"')
-    )
-    try:
-        lexer = get_lexer_by_name(lang)
-    except Exception:
-        lexer = TextLexer()
-    return highlight(code, lexer, _formatter)
+  """Replace a 
 block with Pygments HTML."""
+  lang = m.group(1)
+  code = m.group(2)
+  code = html_mod.unescape(code)
+  try:
+    lexer = get_lexer_by_name(lang)
+  except Exception:
+    lexer = TextLexer()
+  return highlight(code, lexer, _formatter)
 
 
 def _highlight_bare(m):
-    """Replace a bare 
 block — guess the language."""
-    code = m.group(1)
-    code = (
-        code.replace("<", "<")
-        .replace(">", ">")
-        .replace("&", "&")
-        .replace(""", '"')
-    )
-    try:
-        lexer = guess_lexer(code)
-    except Exception:
-        lexer = TextLexer()
-    return highlight(code, lexer, _formatter)
+  """Replace a bare 
 block — guess the language."""
+  code = m.group(1)
+  code = html_mod.unescape(code)
+  try:
+    lexer = guess_lexer(code)
+  except Exception:
+    lexer = TextLexer()
+  return highlight(code, lexer, _formatter)
 
 
 _TABLE_RE = re.compile(r".*?
", re.DOTALL) def _strip_tables_after_first_hr(html: str) -> str: - """Strip benchmark data tables but keep tables before the first
(e.g. feature matrix).""" - idx = html.find(" (e.g. feature matrix).""" + idx = html.find("" - '' - f"" - f"{html_body}" - ) - HTML( - string=full_html, - base_url=str(ROOT_DIR), - ).write_pdf(str(pdf_path)) + text = md_path.read_text() + html_body = markdown.markdown( + text, + extensions=["fenced_code", "tables"], + ) + html_body = _CODE_BLOCK_RE.sub(_highlight_match, html_body) + html_body = _CODE_BARE_RE.sub(_highlight_bare, html_body) + if strip_tables: + html_body = _strip_tables_after_first_hr(html_body) + + full_html = ( + "" + '' + f"" + f"{html_body}" + ) + HTML( + string=full_html, + base_url=str(ROOT_DIR), + ).write_pdf(str(pdf_path)) def main(): - PDF_DIR.mkdir(parents=True, exist_ok=True) - for name in MD_FILES: - md_path = DOCS_DIR / name - if not md_path.exists(): - print(f" skip {name} (not found)") - continue - pdf_path = PDF_DIR / name.replace(".md", ".pdf") - strip = name == "COMPARE.md" - convert(md_path, pdf_path, strip_tables=strip) - print(f" {pdf_path}") + PDF_DIR.mkdir(parents=True, exist_ok=True) + for name in MD_FILES: + md_path = DOCS_DIR / name + if not md_path.exists(): + print(f" skip {name} (not found)") + continue + pdf_path = PDF_DIR / name.replace(".md", ".pdf") + strip = name == "COMPARE.md" + convert(md_path, pdf_path, strip_tables=strip) + print(f" {pdf_path}") if __name__ == "__main__": - main() + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt deleted file mode 100644 index 967176f..0000000 --- a/scripts/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -black==25.1.0 -markdown==3.7 -Pygments==2.19.1 -weasyprint==63.1 diff --git a/scripts/usage.py b/scripts/usage.py index 2c49754..d238346 100644 --- a/scripts/usage.py +++ b/scripts/usage.py @@ -22,113 +22,113 @@ OUTPUT_FILE = Path("docs/USAGE.md") SCENARIO_TITLES = { - "NoFields": "No Fields", - "FiveFields": "Five Fields", - "WithContext": "With Context", - "WithException": "With Exception", + "NoFields": "No Fields", + "FiveFields": "Five Fields", + "WithContext": "With Context", + "WithException": "With Exception", } SCENARIO_ORDER = ["NoFields", "FiveFields", "WithContext", "WithException"] def parse(text: str) -> list[dict]: - """Parse delimited output into a list of entry dicts.""" - entries = [] - for block in re.split(r"@@@ end @@@", text): - header = re.search(r"@@@ scenario=(\w+) logger=(.+?) @@@", block) - if not header: - continue + """Parse delimited output into a list of entry dicts.""" + entries = [] + for block in re.split(r"@@@ end @@@", text): + header = re.search(r"@@@ scenario=(\w+) logger=(.+?) @@@", block) + if not header: + continue - entry = { - "scenario": header.group(1), - "logger": header.group(2), - } + entry = { + "scenario": header.group(1), + "logger": header.group(2), + } - sections = re.split(r"^--- (\S+) ---$", block, flags=re.MULTILINE) - for i in range(1, len(sections) - 1, 2): - name = sections[i] - content = sections[i + 1].strip() - entry[name] = content + sections = re.split(r"^--- (\S+) ---$", block, flags=re.MULTILINE) + for i in range(1, len(sections) - 1, 2): + name = sections[i] + content = sections[i + 1].strip() + entry[name] = content - entries.append(entry) + entries.append(entry) - return entries + return entries def prettify_json(text: str) -> str: - """Pretty-print JSON lines in captured output, leaving non-JSON lines as-is.""" - result = [] - for line in text.splitlines(): - stripped = line.strip() - if stripped.startswith("{"): - try: - result.append(json.dumps(json.loads(stripped), indent=2)) - continue - except json.JSONDecodeError: - pass - result.append(line) - return "\n".join(result) + """Pretty-print JSON lines in captured output, leaving non-JSON lines as-is.""" + result = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("{"): + try: + result.append(json.dumps(json.loads(stripped), indent=2)) + continue + except json.JSONDecodeError: + pass + result.append(line) + return "\n".join(result) def render(entries: list[dict]) -> str: - lines = [ - "# Logger Usage Comparison", - "", - f"> Generated: {datetime.now():%Y-%m-%d %H:%M}", - "", - "Code examples and real output for each logger across the benchmark scenarios.", - "Both text and JSON output shown where applicable.", - ] + lines = [ + "# Logger Usage Comparison", + "", + f"> Generated: {datetime.now():%Y-%m-%d %H:%M}", + "", + "Code examples and real output for each logger across the benchmark scenarios.", + "Both text and JSON output shown where applicable.", + ] - by_scenario: dict[str, list[dict]] = {} - for e in entries: - by_scenario.setdefault(e["scenario"], []).append(e) + by_scenario: dict[str, list[dict]] = {} + for e in entries: + by_scenario.setdefault(e["scenario"], []).append(e) - for scenario in SCENARIO_ORDER: - group = by_scenario.get(scenario, []) - if not group: - continue + for scenario in SCENARIO_ORDER: + group = by_scenario.get(scenario, []) + if not group: + continue - title = SCENARIO_TITLES.get(scenario, scenario) - desc_entry = DESCRIPTIONS.get(scenario) - desc = desc_entry["text"] if desc_entry else "" + title = SCENARIO_TITLES.get(scenario, scenario) + desc_entry = DESCRIPTIONS.get(scenario) + desc = desc_entry["text"] if desc_entry else "" - lines += ["", "---", "", f"## {title}", "", desc] + lines += ["", "---", "", f"## {title}", "", desc] - for entry in group: - logger = entry["logger"] - lines += ["", f"### {logger}", ""] + for entry in group: + logger = entry["logger"] + lines += ["", f"### {logger}", ""] - if "code" in entry: - lines += ["```csharp", entry["code"], "```"] + if "code" in entry: + lines += ["```csharp", entry["code"], "```"] - if "console" in entry: - lines += ["", "```", entry["console"], "```"] + if "console" in entry: + lines += ["", "```", entry["console"], "```"] - if "json" in entry: - lines += ["", "```json", prettify_json(entry["json"]), "```"] + if "json" in entry: + lines += ["", "```json", prettify_json(entry["json"]), "```"] - lines.append("") - return "\n".join(lines) + "\n" + lines.append("") + return "\n".join(lines) + "\n" def main(): - if len(sys.argv) < 2: - print("Usage: usage.py (or '-' for stdin)", file=sys.stderr) - sys.exit(1) + if len(sys.argv) < 2: + print("Usage: usage.py (or '-' for stdin)", file=sys.stderr) + sys.exit(1) - path = sys.argv[1] - text = sys.stdin.read() if path == "-" else Path(path).read_text() + path = sys.argv[1] + text = sys.stdin.read() if path == "-" else Path(path).read_text() - entries = parse(text) - if not entries: - print("No entries found in input", file=sys.stderr) - sys.exit(1) + entries = parse(text) + if not entries: + print("No entries found in input", file=sys.stderr) + sys.exit(1) - result = render(entries) - OUTPUT_FILE.write_text(result) - print(f"Written {OUTPUT_FILE} ({len(entries)} entries)") + result = render(entries) + OUTPUT_FILE.write_text(result) + print(f"Written {OUTPUT_FILE} ({len(entries)} entries)") if __name__ == "__main__": - main() + main()