

grlog
“Zero-dependency structured logging for Go”
A production-ready structured logging library for Go built entirely on the standard library — zero third-party dependencies. Typed zero-allocation fields, pooled-buffer formatting, async logging with three overflow policies, file rotation with atomic compression, deterministic JSON output, and a log/slog adapter — verified by 158 tests including a dedicated race suite and per-bug regression tests.
Tech Stack
Technologies & tools used
Backend
3Go
v1.21+
The entire library — one package, standard library only, no go.sum because there is nothing to sum
log/slog
vstdlib
Interoperability target — NewSlogHandler implements slog.Handler so slog-based code routes through grlog sinks
sync/atomic + sync.Pool
vstdlib
The hot path — lock-free level filtering via atomic.Int32 and pooled formatting buffers for zero-allocation writes
DevOps
2Features & Highlights
Key capabilities and achievements
Typed zero-allocation fields
CoreStructured data uses a typed Field struct (String, Int, Float64, Duration, Time, Err, …) instead of map[string]interface{} — construction is 0 allocs/op and formatting avoids reflection. Err(nil) is safe and simply omitted from output.
Logger views over one shared core
CoreWith(fields) and WithContext(ctx) return lightweight views sharing sinks, queue, and worker. Closing any view closes the logger exactly once; sink changes are copy-on-write and visible everywhere.
Async logging with three overflow policies
CoreA buffered channel and single worker goroutine take writes off the caller's path. When the queue fills you choose: sync-fallback (never lose, default), block (strict ordering), or drop (never block — drops counted in Stats()).
Self-healing file rotation
CoreSize-based rotation with nanosecond-plus-sequence backup names that cannot collide, background gzip compression written atomically via a temp file, age- and count-based cleanup — and a sink that reopens and keeps writing if rotation ever fails.
Deterministic, collision-safe JSON
CoreReserved keys first, custom fields in sorted order, entry fields in call order. A user field named "message" becomes "fields.message" instead of clobbering metadata; NaN, Inf, and invalid UTF-8 all produce valid JSON.
log/slog adapter
CoreNewSlogHandler implements slog.Handler — levels map onto grlog levels, slog groups flatten to dotted keys, WithAttrs attributes become typed fields, and caller info is recovered from the record's program counter.
Composable sink layer
Writer/stdout, file, multi (fan-out with errors.Join), leveled (per-destination severity routing — everything to stdout, ERROR+ to file), and custom callback sinks; add and remove at runtime.
Production observability built in
Sampling (keep 1-in-N for noisy low-severity levels), Stats() exposing dropped and sampled counts, a pluggable sink-error handler, and rate-limited stderr reporting so a failing sink can't flood the process output.
System Architecture
Logger → LogEntry → Formatter → LogSink, with all shared state in one core
grlog is one Go package structured around a pipeline: a Logger builds LogEntry values, a Formatter turns entries into bytes, and a LogSink delivers them. All mutable state — sinks, the async queue, the worker goroutine, the closed flag, the level — lives once in an internal loggerCore; every Logger the caller holds is a cheap view (a core pointer plus its own permanent fields), which is what makes With/WithContext derivation nearly free and double-close impossible. The hot path is lock-free: level filtering reads an atomic.Int32, and formatting borrows pooled buffers filled via an append-style API with strconv fast paths instead of reflection or encoding/json. Async mode adds a buffered channel between dispatch and the sinks with pluggable overflow behavior, and Close drains everything before closing sinks. The slog bridge sits on top, translating slog records into the same LogEntry pipeline.
Components
Logger views + loggerCore
ServiceThe ownership model: state exists once, handles are disposable.
- Hold sinks, async queue, worker, level, and closed flag exactly once per NewLogger call
- Derive per-request/per-component views via With and WithContext without copying machinery
- Close exactly once from any view via CompareAndSwap, then drain the queue and close sinks
- Mutate the sink list copy-on-write so concurrent readers never see a partial slice
Field system
ServiceTyped key-value pairs replacing map[string]interface{} — the zero-allocation foundation.
- Construct String/Int/Int64/Uint64/Float64/Bool/Duration/Time/Err/Any fields at 0 allocs
- Carry a FieldType tag so formatters switch on type instead of reflecting
- Skip Err(nil) fields entirely in every formatter
- Merge view fields with call-site fields only when an entry is actually written
Formatters
ServicePlain text and deterministic JSON, both with pooled-buffer append paths.
- AppendFormat into caller-provided buffers — zero allocations on the common path
- Hand-rolled JSON writing with byte-wise escaping; invalid UTF-8 delegates to encoding/json
- Emit reserved keys first, custom fields sorted, entry fields in call order
- Rename colliding user keys to fields.<key>; render NaN/Inf as strings; degrade unmarshalable values to %v instead of dropping the entry
Sink layer
ServicePluggable destinations behind a two-method interface: Write(entry) and Close().
- WriterSink: mutex-guarded writes to any io.Writer (stdout default)
- FileSink: size-based rotation, collision-proof backup names, atomic background gzip, MaxAge/BackupCount cleanup, reopen-on-failed-rotation
- MultiSink and LeveledSink: fan-out with errors.Join and per-destination severity routing
- CustomSink: user callbacks for databases, queues, or metrics pipelines
Async pipeline
ServiceA buffered channel and one worker goroutine between dispatch and the sinks.
- Enqueue entries without blocking the caller in the common case
- Apply the configured overflow policy when the queue is full: sync-fallback, block, or drop
- Count drops and sampler skips in atomic counters exposed via Stats()
- Drain the queue completely on Close — including entries enqueued in the shutdown race window
slog bridge
ServiceA slog.Handler implementation that feeds the same pipeline.
- Map slog levels onto grlog levels and honor the logger's filter, closed state, and sampler
- Flatten slog groups into dotted key prefixes and resolve attrs to typed fields by Kind
- Recover caller info from the record's program counter via runtime.CallersFrames
- Support slog.SetDefault so all stdlib logging routes through grlog sinks
Challenges & Solutions
Problems solved and lessons learned
The double-close panic and the view architecture
TechnicalEarly versions copied logger state when deriving context loggers — so a WithContext logger and its parent each believed they owned the shutdown channel. Closing both panicked with 'close of closed channel'. Worse, RemoveSink through a derived logger mutated a shared backing array in place, leaving sibling loggers with ghost or duplicated sinks.
Solution
Restructured Logger into a lightweight view over a single shared loggerCore that holds sinks, queue, worker, and the closed flag exactly once. Close uses atomic CompareAndSwap so exactly one caller performs shutdown, and all sink mutations are copy-on-write under one lock so in-flight readers never observe a partial slice.
Outcome
Any number of views can be derived, logged through, and closed in any order from any goroutine — pinned by TestClose_ParentAndChildView_NoPanic and a concurrent-views regression test.
Closing an async logger without stranding entries
TechnicalAsync shutdown has a subtle race: a goroutine can pass the closed-flag check inside log(), get descheduled, and enqueue its entry after Close has already stopped the worker — the entry sits in the queue forever and is silently lost.
Solution
Close signals the worker, waits for it to drain and exit (WaitGroup), and then performs one final synchronous drain of the queue to catch anything enqueued inside that race window before closing sinks.
Outcome
No entry that passed the closed-check is ever lost, verified by TestClose_AsyncDrain_NoLostEntries counting delivered entries under concurrent close.
Zero-allocation JSON without encoding/json
Technicalencoding/json cost 1412 ns and 15 allocations per entry — the map-building alone dominated the logging hot path. But hand-rolled JSON is notorious for correctness holes: string escaping, NaN/Inf, invalid UTF-8, and user field names colliding with metadata keys.
Solution
Wrote a direct append-style serializer using strconv fast paths and a byte-wise escaper, then handled every exotic case explicitly: invalid UTF-8 strings delegate to encoding/json's sanitizer, NaN/Inf render as quoted strings, unmarshalable values degrade to their %v form (the entry is never dropped), and colliding keys are emitted under a fields. prefix.
Outcome
82 ns/0 allocs for JSON formatting — 17× faster — with regression tests for escaping, UTF-8, NaN/Inf, and reserved-key collisions guarding every edge case that made hand-rolling risky.
Being a correct slog.Handler, not just a compatible one
Technicalslog's handler contract has real semantics: WithGroup must prefix subsequent keys, WithAttrs must pre-resolve attributes, group values nest recursively, and caller information arrives as a raw program counter — none of which maps one-to-one onto grlog's flat typed fields.
Solution
The handler accumulates group names and flattens them into dotted key prefixes ('server.request.id'), recursively resolves group-valued attrs, converts each slog.Kind to the matching typed field constructor, and formats caller info from the record's PC via runtime.CallersFrames to match grlog's own file:line:function layout.
Outcome
slog.SetDefault(slog.New(grlog.NewSlogHandler(logger))) routes all stdlib logging — including third-party dependencies that log via slog — through grlog's sinks with structure intact.
Key Learnings
What making a logger production-ready teaches about ownership, durability, and performance
Shared state needs exactly one owner
TechnicalEvery v0.1.0 concurrency bug — the double-close panic, the sink-slice corruption, the stranded queue entries — traced back to the same root cause: multiple handles believing they owned shared state.
Derived loggers copied structs; each copy carried the same channel and slice headers with no coordination.
One loggerCore owns everything; handles are views; ownership transitions (close) go through CompareAndSwap; shared collections mutate copy-on-write.
When an API hands out multiple handles to one resource, decide explicitly which one owns teardown — or make teardown idempotent at the core.
Every fixed bug becomes a named regression test
Processv0.1.0 added a dedicated regression suite where each of the 26 tests corresponds to a specific fixed bug — TestFileSink_RapidRotation_NoBackupOverwrite is the rotation-collision bug, permanently.
Fixes were verified manually and by the general unit suite, which had missed these exact cases.
The changelog's Fixed section and the regression file mirror each other one-to-one; a reintroduced bug fails a test named after it.
A bug isn't fixed until its reproduction is a permanent, named part of the suite.
Performance claims need enforcement, not measurement
TechnicalBenchmarks that print numbers rot; benchmarks that assert fail. Several grlog benchmarks assert 0 allocs/op, so an accidental allocation on the hot path fails CI instead of quietly shipping.
Perf numbers lived in the README and drifted from reality as code changed.
The claims are executable: ~49 benchmarks run in CI (non-blocking job), the critical ones assert allocation counts.
Turn every performance property you advertise into an assertion a machine checks.
Key Takeaways
- •Zero dependencies is a feature you can only claim by design: JSON, gzip, and atomics all live in Go's standard library if you're willing to write the glue.
- •The overflow policy is the honest API: forcing callers to choose between never-lose, strict-order, and never-block is better than picking silently for them.
- •Rotation must be self-healing — a logging failure that disables future logging is the worst failure mode a logger can have.
- •Adopting the platform's interface (slog.Handler) beats asking users to migrate: interop is a growth strategy.
Roadmap
Known limitations and planned improvements
Planned
OpenTelemetry exporter sink
WithContextExtractor can already surface OTel span IDs into fields, but a first-party OTLP-exporting sink would close the loop for tracing-native stacks — likely as a separate module to preserve the zero-dependency core.
Per-key sampling
Sampling is currently per-level (keep 1-in-N DEBUG entries). Real deployments often want per-message-key sampling so one noisy call site can't consume the budget of every DEBUG log.
Ideas & Backlog
Multiple async workers
LowThe async pipeline is a single worker goroutine — simple and ordered, but one slow sink caps throughput. A worker pool with per-sink queues would isolate slow destinations.
Structured error chains
LowErr() stores the error string; unwrapping errors.Join chains and %w wraps into structured sub-fields would make error logs machine-queryable.
AI-readable content
Learn more →This content is available via the AI Content API as JSON or token-efficient Markdown. Feed it directly into LLM workflows.
/api/content/projects/grlogRelated Content
Explore related articles, projects, and tools.