Table of contents
Go’s log/slog gave the ecosystem a standard structured-logging interface, which is exactly what you want to code against: your libraries speak slog and stay portable. Teams that care about logging overhead often reach for zerolog
, whose reputation is allocation-free structured output. The logsrv
package in nurago
refuses to choose: it implements a native slog.Handler that writes each record’s attributes directly onto zerolog events. The interesting part is that the two APIs disagree about fundamentals: what a level is, where attributes may nest, who owns rendering decisions, and who reports errors. Each section below is one of those impedance mismatches: what the slog contract requires, how a naive bridge gets it wrong, and what this handler does instead.
Wiring it in
import (
"github.com/tecnickcom/nurago/pkg/logsrv"
"github.com/tecnickcom/nurago/pkg/logutil"
)
logger := logsrv.NewLogger(logutil.DefaultConfig()) // *slog.Logger, installed as the slog default
logger.Info("user login", "user_id", 42, "ip", clientIP)
NewLogger builds a standard *slog.Logger and installs it as the process default; NewHandler returns just the handler if you would rather not touch global state. Configuration comes from the shared logutil.Config model (format, level, common attributes, trace ID function, hook, caller source), so this backend and logutil’s standard-library one are interchangeable behind the same slog API. The rest of this post is what happens between a logger.Info call and the line zerolog writes.
Nine severities against a fixed level set
nurago’s logging model uses the extended syslog scale: emergency, alert, critical, error, warning, notice, info, debug, trace. slog levels are open-ended integers, so expressing those is easy on the slog side. zerolog has a small fixed vocabulary, and a naive bridge that maps each record onto the nearest zerolog level collapses the extra severities (critical becomes error, notice becomes info) and lets zerolog’s own level gate silently drop trace records.
logsrv takes zerolog out of both decisions. Every record is written at zerolog.NoLevel, so zerolog contributes no level field of its own, and the handler writes the full syslog name itself:
e := h.logger.WithLevel(zerolog.NoLevel) // zerolog writes no level field of its own
e.Str(zerolog.LevelFieldName, logutil.LevelName(record.Level))
The underlying zerolog logger is constructed at TraceLevel, taking zerolog’s own gate out of the way; enablement is decided entirely in the handler’s Enabled method against the configured minimum, which means trace is honoured exactly as configured. One escape hatch remains: NoLevel events are still subject to the process-global zerolog level, so zerolog.SetGlobalLevel(zerolog.Disabled) anywhere in the binary drops every record. Any less drastic global level leaves the output untouched.
Pre-baked attributes against open groups
WithAttrs attaches standing attributes (service name, version, region) that repeat on every subsequent line. zerolog’s Context is the perfect tool for them: attributes added there are serialised once into a buffer that is copied wholesale onto each event, never re-encoded. A naive bridge that stores WithAttrs attributes in a slice and replays them per record throws away exactly the property you adopted zerolog for.
But WithGroup breaks the trick. It opens a namespace, and everything added afterwards, both later WithAttrs calls and the record’s own attributes, must nest inside it. The zerolog context can bake a completed nested object, but it only appends finished bytes at the root: it cannot represent a group that is still open. So the handler splits cleanly. With no group open, WithAttrs bakes into the context and the per-record work is a memcpy. Under an open group, it keeps a small stack of frames, one per WithGroup, each holding the attributes added within it, and builds the nesting at Handle time into a zerolog.Dict drawn from zerolog’s pool. The grouped path does more work per record, but in the package’s benchmarks it too adds zero heap allocations.
There is a correctness wrinkle on that path: slog’s handlers elide a group that ends up with no fields, while the naive nested-dictionary build emits a bare {}. The handler tracks whether anything was actually written into each dictionary and drops the empty ones, which raises a lifecycle question covered below.
Keeping the trace ID at the root
A distributed-tracing identifier is only useful if it sits at a known place in every line, so aggregation queries can filter on it. Under an open group a naive bridge sweeps trace_id into the group’s nested object, invisible to a root-level query. logsrv writes it natively at the root of every record, even when groups are open, resolving it per record through the configured function so a dynamic identifier reflects the current request rather than being frozen at construction.
Deduplication is the subtle half. A caller-supplied root-level trace_id must win over the injected one, and the current code decides that from what actually renders: an attribute that is elided (a typed-nil error logged under the key, an empty group) does not count, so the injected value stays and the record still carries its trace ID. The trap is WithAttrs: once attributes are baked into the zerolog context they are opaque bytes, and Handle can no longer see whether one of them was a trace_id. So the baking step reports it as it serialises, and the handler remembers the flag. Without that, the injected value (empty by default) would be written after the caller’s baked one, and any last-wins JSON consumer would silently resolve trace_id to the wrong field.
Pooled events that must not leak, and values that panic
zerolog stays allocation-free by pooling its Event objects, and a Dict is a pooled event too, normally returned to the pool when sent. The empty-group elision above therefore creates a leak hazard: a dictionary that was built and then dropped would never go back. The handler recycles it explicitly:
if !wrote {
// Recycle the unused pooled event rather than leaking it: Send on a detached dict
// (nil writer) emits nothing and returns the event to zerolog's pool.
d.Send()
return nil, false
}
Send on a detached dictionary writes nothing anywhere but hands the pooled object back. Forgetting it produces no visible bug, just a slow erosion of the allocation-free property.
The harsher mismatch is panic behaviour. Rendering a value runs caller code (Error, MarshalJSON, MarshalText, MarshalZerologObject), and slog’s handlers recover a panic there and write a !PANIC: ... sentinel, on the reasoning that the failure path is precisely where logging gets called. zerolog does not recover, and its Event.Object makes recovery impossible after the fact: it appends the key and the opening brace onto the event’s unexported buffer before invoking the marshaler, so a mid-marshal panic leaves an object that is never closed, swallowing every later field (the message and the trace ID included) into an unparseable line. Through a baked With(...) that corruption would land in the context and replay on every subsequent record. logsrv therefore renders every LogObjectMarshaler into a detached dictionary and attaches it only once the marshaler has returned; on a panic the dictionary is recycled whole and the sentinel is written as a plain string field.
Rendering ruled by process-global state
A slog.Handler is expected to render a given attribute deterministically, the way the standard library’s handlers do. zerolog instead defers several rendering decisions to process-global variables that any other zerolog user in the same binary can change. Two of the defaults are actively wrong for slog output: TimeFieldFormat defaults to whole-second RFC 3339, truncating every timestamp, and DurationFieldUnit defaults to milliseconds where slog writes nanoseconds. Rather than inherit those, the handler writes timestamps explicitly (RFC 3339 with nanosecond precision, formatted onto a stack buffer and emitted as raw JSON) and durations as a nanosecond count, matching the standard library byte for byte and making both fields independent of the globals.
ErrorMarshalFunc gets the opposite treatment: it is a deliberate customisation point, so the handler respects it, but it mirrors zerolog’s dispatch itself rather than delegating, so the hook runs at most once per error attribute. The obvious delegating implementation, asking AnErr what it would produce and then letting it produce it, invokes the hook twice: costly for a hook that captures a stack, and wrong for a stateful one, whose second result would reach the wire. A typed-nil error is dropped before the hook is ever consulted, so both nurago backends, including the one that cannot see zerolog’s interfaces at all, agree on whether an attribute writes a field.
Handle returns an error, zerolog reports none
The last mismatch is small but real: slog.Handler.Handle returns an error, while zerolog’s event API returns nothing and falls back to printing a diagnostic on stderr when a write fails. The handler wraps the destination in a writer that records the most recent write failure, and Handle returns and clears it. Under concurrent logging it is a failure signal rather than exact per-call attribution, and slog.Logger itself discards the value, but a wrapping handler or a direct Handle caller can finally observe that the log destination is broken.
What the numbers say
The package’s benchmarks pin the cost claims down. Logging through the handler measures 0 allocs/op for the ungrouped path, for baked common attributes, for records logged under an open group, and for the elided empty group. Records that carry a group-valued or LogValuer attribute do allocate a few objects per call, but the same record costs the same against a no-op handler: those allocations happen inside slog while constructing the attribute values, before the handler is ever invoked. In other words, on every benchmarked path the handler itself adds nothing per record to what slog already costs.
That is the theme of the whole package. Bridging slog to zerolog is easy to do badly, because the seams are invisible until production: a collapsed severity, a truncated timestamp, a trace ID swallowed by a group, a pooled object that never comes home, one panicking marshaler corrupting every subsequent line. Resolving each mismatch explicitly is what turns a quick adapter into a handler you can leave in place.