Skip to content

Releases: semihalev/zlog

v2.0.8 — Restore Info(...any) backward compatibility, add InfoF for zero-alloc typed

25 Apr 19:15

Choose a tag to compare

Walks back the v2.0.7 API break on the global helpers. v2.0.6 untyped call sites compile and run again with no source changes. v2.0.7 typed call sites continue to work too. The zero-alloc typed path moves to new *F entrypoints that opt in by name.

What changed

zlog.Debug / Info / Warn / Error / Fatal once again accept ...any and route correctly:

zlog.Info("user logged in", "username", "alice", "user_id", 12345)              // 0 allocs (KV path)
zlog.Info("user logged in", zlog.String("username", "alice"))                    // 3 allocs (Field via ...any)

zlog.DebugF / InfoF / WarnF / ErrorF / FatalF are new typed-only entrypoints that take ...Field directly:

zlog.InfoF("user logged in", zlog.String("username", "alice"), zlog.Int("user_id", 12345))  // 0 allocs

zlog.InfoKV / DebugKV / WarnKV / ErrorKV / FatalKV are kept as explicit aliases for the untyped path. Anyone who already migrated to them in v2.0.7 keeps working.

Why the rollback

v2.0.7's switch to Info(msg, fields ...Field) was strictly a perf decision — it shaved 3 allocs from the typed-via-global path by avoiding Field-into-...any boxing. But it was the wrong call for a non-major release: every existing v2.0.6 caller of the untyped style (zlog.Info("msg", "k", v)) became a compile error.

The structurally-correct design is two names — one for each variadic shape — added additively. v2.0.8 does that:

Style API Allocations
Untyped key/value pairs zlog.Info("msg", "k", v) 0
Typed Fields (compat) zlog.Info("msg", String(...)) 3 (Field→any boxing at callsite)
Typed Fields (zero-alloc) zlog.InfoF("msg", String(...)) 0

The 3-alloc cost on Info(...any) with typed Fields is structural: passing a 56-byte Field through a ...any parameter heap-boxes each argument at the Go callsite, before the function runs. No compiler trick on the callee side eliminates that. The InfoF entrypoint sidesteps it by taking ...Field directly.

Compatibility matrix

Source style v2.0.6 v2.0.7 v2.0.8
zlog.Info("msg", "k", v) (untyped) 0 allocs compile error 0 allocs
zlog.Info("msg", String(...)) (typed) silent drop bug 0 allocs 3 allocs
zlog.InfoF("msg", String(...)) n/a n/a 0 allocs
zlog.InfoKV("msg", "k", v) n/a 0 allocs 0 allocs
Default().Info("msg", String(...)) 0 allocs 0 allocs 0 allocs

Migration

  • Coming from v2.0.6: bump the dependency, no code changes. (You also pick up every v2.0.7 fix: the silent-drop bug, the Bytes(empty) panic, the Logger.log 65535 truncation, the proper MPMC ring buffer.)
  • Coming from v2.0.7: bump the dependency, no code changes. Typed zlog.Info(...) calls now cost 3 allocs each — rename to zlog.InfoF(...) to keep zero-alloc behavior. The compiler doesn't catch this; it's a perf migration, not a correctness one.

Bench (Apple M5)

zlog.Info("msg", "k", v)              32 ns/op    0 allocs   (KV)
zlog.InfoF("msg", String(...))        34 ns/op    0 allocs   (typed, no boxing)
zlog.Info("msg", String(...))         87 ns/op    3 allocs   (typed via ...any)
Default().Info("msg", String(...))    34 ns/op    0 allocs   (typed, instance)

Other changes

Carries forward all v2.0.7 fixes:

  • Bytes(nil) and Bytes([]byte{}) no longer panic.
  • Logger.log clamps long messages to 65535 to match the uint16 header.
  • Logger.Fatal and StructuredLogger.Fatal always exit, even when filtered.
  • LogfmtWriter.Write is zero-alloc on long records (one-pass size estimate).
  • LogfmtWriter caches the formatted RFC3339 timestamp by Unix second.
  • AsyncWriter uses an LMAX Disruptor MPMC ring buffer (per-slot sequence numbers) — 212 ns/op end-to-end, race-clean.

Tests

Coverage for all three global-helper paths (KV via Info, typed via Info, typed via InfoF) plus the Fatal-when-filtered subprocess test. Full race-detector run is green; long-record alloc test gated //go:build !race because the race detector triggers GC frequently enough to clear sync.Pool's localcache for the 1 MiB-class buffers (a -race property, not a production bug).

v2.0.7 — Typed global helpers, KV direct path, lock-free MPMC ring buffer

25 Apr 18:15

Choose a tag to compare

Polish + correctness release on top of v2.0.6. Highlights: typed-Field global helpers are now genuinely zero-alloc, the untyped KV path got a direct-text shortcut, several real correctness bugs fixed, and the AsyncWriter ring buffer was rewritten as a proper LMAX Disruptor MPMC.

Breaking change

Global helpers zlog.Debug / Info / Warn / Error / Fatal now take typed Field varargs:

func Info(msg string, fields ...Field)

The previous signature accepted ...any and tried to handle both typed and untyped calls. With Field at 56 bytes (won't fit inline in interface storage), every call boxed each Field on the heap — three allocs/op even when the caller meant the typed path. Splitting the API removes that cost.

Migration: untyped key/value style moves to the explicit *KV helpers (zlog.InfoKV, DebugKV, etc.) — same signature as before, just renamed:

// Before:
zlog.Info("user logged in", "user_id", 12345)
// After:
zlog.InfoKV("user logged in", "user_id", 12345)

The Go compiler catches every old call site as a type mismatch on the variadic, so migration is mechanical. Anyone already using typed Fields through the global helper gets a 3× speedup for free — same call site, zero allocations.

Performance

Bench (Apple M5) v2.0.6 v2.0.7
zlog.Info("msg", String(..), Int(..)) global 88 ns / 3 allocs 34 ns / 0 / 0
zlog.InfoKV("msg", "k", v) global 53 ns 32 ns / 0 / 0
StructuredLogger → TerminalWriter 27 ns 27 ns / 0 / 0
LogfmtWriter binary decode 76 ns 65 ns / 0 / 0
LogfmtWriter direct structured 89 ns 72 ns / 0 / 0
AsyncWriter 234 ns 212 ns / 0 / 0

All paths zero-alloc. Direct-text KV path on *TerminalWriter and *LogfmtWriter skips the binary encode + decode round-trip — that's where the 40 % KV speedup comes from.

Correctness fixes

  • Bytes(nil) and Bytes([]byte{}) no longer panic. &val[0] was unconditional; now an empty slice produces an empty Bytes field.
  • Logger.log clamps long messages to 65535 bytes (the uint16 header limit). Previously a 70 KB message produced a record where the header msgLen wrapped via uint16(70000) = 4464 but all 70 KB were copied — decoders would read the wrong length and treat the rest as garbage.
  • Logger.Fatal and StructuredLogger.Fatal always exit, even when the level filters the message out. Previously SetLevel(>Fatal) could log nothing AND skip the exit.
  • LogfmtWriter.Write (binary decode path) is now zero-alloc on long records. It pre-sizes the buffer with a one-pass scan over the field section, mirroring what writeStructured already did.
  • LogfmtWriter caches the formatted timestamp by Unix second so AppendFormat runs once per second, not once per log.

AsyncWriter ring buffer rewritten

The previous lock-free ring buffer had a real correctness bug under -race: once a consumer's CAS-tail succeeded, the producer's full check immediately allowed a Put at head = oldTail + size, which targets the same slot the consumer is about to read. The consumer's post-read Store(nil) would clobber a freshly-stored item from the next generation, causing a nil-deref in workers. Wrapping the head/tail counters via mask additionally exposed the design to ABA.

v2.0.7 replaces the design with the LMAX Disruptor pattern (per-slot sequence numbers): each slot carries an atomic seq counter that doubles as a generation marker. Producers claim only when seq == head; consumers consume only when seq == tail+1. After a Get, the slot's seq is set to tail + size, explicitly marking it ready for the producer's next generation. No silent overwrite, no ABA, no spin-wait — fully non-blocking. Holds size items rather than size-1 since the seq counter distinguishes empty from full.

The corrected lock-free design ends up faster than the original broken version (212 ns/op vs 234 ns/op).

Other changes

  • Long-record alloc test (long_alloc_test.go) is now //go:build !race. Race detector triggers GC frequently enough to clear sync.Pool's localcache for 1 MiB-class buffers, leaking ~1 alloc/op into the measurement window. That's a property of -race, not the production hot path. Production code without -race keeps the pool warm and runs at 0 allocs/op as advertised.
  • Inlined the field encoder into formatStructuredMessage (saves a function call per field).
  • StructuredLogger.logKV now detects *TerminalWriter and *LogfmtWriter and dispatches to the direct-text path, skipping the binary encode + decode round-trip.
  • Comprehensive regression test coverage: Bytes(empty), LogfmtWriter.Write long-record alloc, plain Logger long-message header/payload match, KV direct-path output on terminal + logfmt, Fatal-with-filtered-level subprocess test.
  • Doc fixes: root.go init() comment matches the code, Any() documents that fmt.Sprint allocates, ultimate_zero ns claim corrected to match measured M5 number.

Compatibility

  • Go 1.23+ (unchanged).
  • Public surface of Logger, StructuredLogger, UltimateLogger, all Field constructors and writers behaves identically apart from the Info/Debug/Warn/Error/Fatal global helpers, which now take ...Field (KV style → *KV helpers).
  • Binary log format unchanged from v2.0.6.

v2.0.6 — True zero-allocation, 2-3× faster, format unified

25 Apr 13:26

Choose a tag to compare

Major correctness + performance pass. Every logging path is now genuinely zero-alloc, parallel scales near-linearly, and the binary log format is unified across logger types.

Highlights

Bench (Apple M5) v2.0.5 v2.0.6
UltimateLogger 45 ns / 128B / 1 alloc 16 ns / 0 / 0
UltimateLogger parallel 56 ns / 128B / 1 alloc 5 ns / 0 / 0
StructuredLogger 100 ns / 512B / 1 alloc 36 ns / 0 / 0
StructuredLogger parallel 54 ns / 512B / 1 alloc 10 ns / 0 / 0
Structured + 5 fields 113 ns / 512B / 1 alloc 45 ns / 0 / 0
Structured + 10 fields 145 ns / 512B / 1 alloc 77 ns / 0 / 0
Structured → TerminalWriter (real-world) ~150 ns 27 ns / 0 / 0

Parallel is now faster than serial on several benches because the cross-core contention point was removed.

Bug fixes

  • Zero-alloc claim was not actually true on any path. Two unrelated bugs combined:

    1. The "small message stack buffer" optimization in Logger.log, StructuredLogger.logFields, and UltimateLogger.log declared var stackBuf [N]byte and passed it to writer.Write. Because Write is an interface method, escape analysis forced the array to the heap on every call (confirmed via -gcflags=-m=2). The "fast path" was strictly slower than the pool path it tried to skip.
    2. The custom leadingZeros64 in buffer_pool_generic.go was broken — it summed eight 6-bit lookup values where seven of eight always returned 64, so it returned 512 for any input. The pool's Put rejected every buffer because of the bogus index calculation, so every Get allocated fresh.

    Both fixed; all logger paths now hit the pool's reuse path.

  • Wall-clock timestamps were wrong. runtime.nanotime() (monotonic since process start) was being treated as Unix nanoseconds in the binary header, so rendered timestamps came out as e.g. 1970-01-01T21:35:59. Switched to a once-captured wall+mono offset (~5 ns/log, portable across darwin/linux/windows).

  • AsyncWriterV2 was racy under concurrent writers. RingBuffer.Put was single-producer-only, but AsyncWriterV2.Write is the package's io.Writer and gets called from many goroutines. Two producers could overwrite the same slot. Fixed with CAS on head.

  • MMapWriter had a wrap-around race. offset.Add followed by offset.Store could interleave across writers and corrupt entries near the end of the file. Fixed with a CAS-loop on offset.

  • Goroutine-per-flush in mmap writers removed. MS_ASYNC (Linux/macOS) and FlushViewOfFile (Windows) are non-blocking; the previous go w.syncRange(...) was both racy and a goroutine fan-out hazard.

  • LogfmtWriter corrupted UTF-8. appendQuoted ranged over runes then wrote byte(c), truncating any multi-byte sequence to its low byte. Fixed; full UTF-8 round-trips.

  • Float formatting was lossy. Hand-rolled formatter only emitted three decimals, mishandled NaN/±Inf, overflowed at 2⁶⁴. Replaced with strconv.AppendFloat.

Performance

  • Atomic sequence counter dropped. It was never read by any writer in the package, but every Add(1) was the dominant cross-core cache-line contention point — the entire reason parallel was 2.5× slower than serial. Removed.
  • Direct-text fast path in StructuredLogger. When the writer is *TerminalWriter, format text directly into the pooled buffer and skip the binary encode → re-decode round trip. Type assertion is ~1 ns; saves ~30-40 ns. The most common case (humans reading colored logs) is now also the fastest.
  • Native byte order in field encoding. Each int/float was eight individual byte stores. Now a single *(*uint64)(unsafe.Pointer(&buf[pos])) = f.num per numeric field. The binary form is internal — only this package's writers consume it — so big-endian bought nothing.
  • TerminalWriter timestamp cache. time.AppendFormat runs once per second, not once per log. Manual digit formatting via shift+mask.
  • Pre-built level prefix LUTs. [6][]byte{"\x1b[32mINFO \x1b[0m", ...} so the line prefix is one append, not three.
  • 256-byte classifier table for escape detection. Branchless OR-loop; Go compiles byte-LUT loads to NEON tbl / SSSE3 pshufb on supported architectures.
  • defer mu.Unlock in TerminalWriter.Write replaced with explicit unlock (~6 ns/log).

Breaking changes

The on-disk / on-the-wire binary log format changed:

old structured: magic(4) ver(1) lvl(1) seq(8) ts(8) msgLen(1) msg ...
new (unified):  magic(4) ver(1) lvl(1) ts(8) msgLen(2) msg [fieldCount(1) fields...]
  • 16-byte header instead of 22 (no sequence counter).
  • msgLen widened from uint8 to uint16.
  • Field values are stored in native byte order. Anything reading binary logs across machines with different endianness needs to handle this; in practice only TerminalWriter and LogfmtWriter decode the binary form, both in-package.

If you've persisted binary logs from a previous version and want to render them, do it before upgrading.

Other changes

  • README rewritten — fresh benchmark numbers, accurate description of how zero-alloc actually works (the previous "stack-allocated buffers" claim was the bug we just fixed).
  • runtime.exit replaced by os.Exit for cross-compilation support (carried over from earlier release).

Compatibility

  • Go 1.23+ (unchanged from v2.0.5).
  • Public API surface is unchanged — Logger, StructuredLogger, UltimateLogger, all Field constructors, all writers behave identically. Only the on-the-wire binary format changed.

v2.0.5 — Default log level: Info

25 Apr 13:25

Choose a tag to compare

Small release that flips the default log level from Debug to Info.

Breaking

  • The default level for New() / NewStructured() / NewUltimateLogger() is now LevelInfo. Apps relying on Debug messages appearing without an explicit SetLevel(LevelDebug) will now see them filtered out.

Why

Production deployments almost always want Info-and-above. Defaulting to Debug meant every new caller had to remember to bump the level or pay for verbose output and the small cost of formatting it.

Migration

If you want the old behavior:

log := zlog.NewStructured()
log.SetLevel(zlog.LevelDebug)

v2.0.4

27 Jul 15:30

Choose a tag to compare

Release v2.0.4

🐛 Cross-Compilation Fix

Fixed Windows Cross-Compilation

Replaced runtime.exit with standard os.Exit to enable cross-compilation support.

🔧 What Was Fixed

The library was using an internal Go runtime function runtime.exit which caused the following error when cross-compiling:

link: github.com/semihalev/zlog/v2: invalid reference to runtime.exit

This has been fixed by using the standard os.Exit(1) function instead.

✅ Now Works

# Cross-compile for Windows from Linux/macOS
GOOS=windows GOARCH=amd64 go build

# Cross-compile for Linux from Windows/macOS  
GOOS=linux GOARCH=amd64 go build

# All cross-compilation targets now work

📦 Installation

go get github.com/semihalev/zlog/v2@v2.0.4

This fix ensures zlog can be cross-compiled to any platform from any platform.

v2.0.3

26 Jul 12:56

Choose a tag to compare

Release v2.0.3

🐛 Critical Bug Fix

Fixed Terminal Writer Format Detection

The terminal writer now correctly handles both logger formats:

  • Basic Logger: 16-byte header with 2-byte message length
  • Structured Logger: 22-byte header with 1-byte message length

🔧 What Was Fixed

The terminal writer was only parsing the basic logger format, causing garbled output when used with structured loggers (including the default global logger).

This fix ensures all log messages display correctly regardless of which logger type you use.

v2.0.2 - Better Default Logger

26 Jul 12:50

Choose a tag to compare

Release v2.0.2

🎯 Better Out-of-Box Experience

Default Logger Now Uses Terminal Output

The global default logger now automatically uses colored terminal output instead of binary format. This provides a much better user experience, especially for new users and during development.

🐛 Bug Fixes

  • Fixed early logging showing binary format - Logs that happen before logger configuration (like "Register middleware") now display properly
  • Default logger initialization - Changed from binary stderr to terminal stdout for human-readable output

💡 What Changed

Before v2.0.2:

// Early logs would show as: GOLZ...binary data...
zlog.Info("Starting application")  // Binary format by default

After v2.0.2:

// Now shows as: INFO [01-25|20:45:00] Starting application
zlog.Info("Starting application")  // Beautiful colored output by default

🔧 Usage

No code changes required! The default logger now "just works" with terminal output.

You can still customize as before:

// Use binary format if needed
zlog.SetWriter(os.Stderr)  

// Or use your own configuration
logger := zlog.NewStructured()
logger.SetWriter(zlog.StderrTerminal())
zlog.SetDefault(logger)

📦 Installation

go get github.com/semihalev/zlog/v2@v2.0.2

This release improves the developer experience by making zlog work beautifully out of the box without any configuration.

v2.0.1 - Module Path Fix

26 Jul 12:34

Choose a tag to compare

Release v2.0.1

🐛 Bug Fix

Fixed Module Path for Go Modules v2

  • Updated go.mod to use github.com/semihalev/zlog/v2 module path
  • Updated all import statements in examples and documentation
  • This fix ensures proper Go modules v2 compatibility

📦 Installation

go get github.com/semihalev/zlog/v2

🔄 Migration from v2.0.0

If you already installed v2.0.0, update your imports:

// Old (incorrect)
import "github.com/semihalev/zlog"

// New (correct) 
import "github.com/semihalev/zlog/v2"

For full v2 features and changes, see the v2.0.0 release notes.

v2.0.0 - Major Performance Overhaul

26 Jul 12:05

Choose a tag to compare

Release v2.0.0

🚀 Major Performance Overhaul

This release represents a complete performance rewrite leveraging Go 1.23+ features to achieve unprecedented speed and true zero allocations.

⚡ Performance Highlights

  • 2x faster than v1.x - Reduced from ~40 ns/op to 21.88 ns/op
  • True zero allocations - Direct writer operations now have 0 B/op
  • 45.7 million logs/second - Industry-leading throughput
  • Removed 64MB upfront allocation - More memory efficient

🔄 Breaking Changes

  • Removed redundant logger implementations:
    • NanoLogger - Use UltimateLogger instead
    • SimpleLogger - Use Logger instead
    • ZeroAllocLogger - Use UltimateLogger instead
  • Changed internal buffer pool implementation
  • Minimum Go version is now 1.23

✨ New Features

Generic Buffer Pool

  • Type-safe buffer management with zero interface{} boxing
  • Tiered allocation strategy (64B to 1MB+)
  • Automatic size class detection

Zero-Copy String Operations

  • StringToBytes() - Convert strings without allocation
  • BytesToString() - Convert bytes without allocation
  • Uses unsafe.StringData from Go 1.20+

Lock-Free Async Writer

  • Generic ring buffer with atomic.Pointer[T]
  • Multiple worker goroutines for parallel writes
  • Reduced allocations from 542 B/op to 262 B/op

Windows Color Support

  • Automatic ANSI color detection for Windows 10+
  • Virtual terminal processing enablement
  • NO_COLOR environment variable support
  • Manual color control via SetColorEnabled()

🐛 Bug Fixes

  • Fixed terminal writer magic header mismatch
  • Fixed buffer allocation issues for large messages
  • Fixed string escaping performance with loop unrolling
  • Fixed Windows terminal color escape sequences

📊 Benchmark Improvements

BenchmarkUltimateLogger-10       56M    21.88 ns/op    0 B/op    0 allocs/op
BenchmarkAsyncWriterDirect-10     8M   158.0 ns/op    0 B/op    0 allocs/op
BenchmarkZeroCopyString-10     2000M    0.497 ns/op    0 B/op    0 allocs/op

🔧 Migration Guide

For NanoLogger users:

// Old
logger := zlog.NewNanoLogger()

// New
logger := zlog.NewUltimateLogger()

For Windows users seeing escape codes:

// Option 1: Disable colors
tw := zlog.NewTerminalWriter(os.Stdout).(*zlog.TerminalWriter)
tw.SetColorEnabled(false)
logger.SetWriter(tw)

// Option 2: Use plain writer
logger.SetWriter(zlog.StdoutWriter())

// Option 3: Set environment variable
// SET NO_COLOR=1

Full changelog: v1.2.5...v2.0.0

v1.2.5

08 Jun 09:49
ab642f8

Choose a tag to compare

What's New in v1.2.5

🚀 Major Performance Improvements

Zero-Allocation Terminal Writer

  • Completely redesigned TerminalWriter with zero allocations during operation
  • Performance improvements:
    • Simple messages: 2x faster (203ns → 101ns)
    • With fields: 4.1x faster (703ns → 171ns)
    • Processes 7-8 million logs/second

✨ Key Changes

  1. Pre-allocated Buffers

    • Increased buffer size from 1KB to 2KB to handle complex logs without reallocation
    • Reuses buffers across writes with mutex protection
  2. Optimized String Operations

    • Direct byte slice operations instead of string conversions
    • Stack-based number formatting
    • Inline field decoding
  3. Pre-computed Lookup Tables

    • Level strings and colors are pre-allocated
    • Padding spaces pre-allocated

📊 Benchmark Results

BenchmarkTerminalWriterWithFields-10    
  Before: 203.7 ns/op    56 B/op    4 allocs/op
  After:  101.1 ns/op     0 B/op    0 allocs/op

BenchmarkTerminalWriterManyFields-10
  Before: 703.2 ns/op   120 B/op   14 allocs/op  
  After:  171.2 ns/op     0 B/op    0 allocs/op

🔧 Other Improvements

  • Fixed NetBSD platform support
  • Updated demo applications
  • Maintained full API compatibility