logutil

logutil builds configuration-driven log/slog loggers with syslog-range severities, a hook interface, and a sanitizing handler that repairs two slog encoding faults.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/logutil"

Builds log/slog loggers from typed configuration: format, level, common attributes, trace-ID injection and an optional hook, with parsing and validation for values that arrive as strings from a config file.

Two slog encoding faults it repairs

The handlers from Config.SlogHandler write through the standard library’s JSON or text handler, but filter the record first. Two of the differences are corrections to output the standard library gets wrong.

An all-empty group produces invalid JSON. When every member of a group renders to nothing, slog rolls the buffer back past the group without closing it. The next attribute is then written with no separator. In JSON that is a malformed document; in text format it is worse, because the field is silently renamed with the dead group’s prefix and the line still parses. The sanitizing handler drops such a group entirely.

A far-future or far-past timestamp is double-encoded. For a time.Time whose year falls outside 0 to 9999, slog’s JSON encoder writes an !ERROR: string and then writes the value as well, putting two JSON strings under one key. The handler rewrites the value as an RFC 3339 string instead.

Both are the kind of fault that surfaces as a log pipeline rejecting records, at which point the logs you need to diagnose it are the ones that were dropped. Neither is likely in ordinary traffic; both are reachable from a zero-value time field or a conditionally-empty group.

The third difference keeps this backend byte-compatible with logsrv, so the two are interchangeable.

Severity beyond slog’s four levels

slog ships debug, info, warn and error. The severity model here is the full syslog range, emergency through debug, plus a trace level below debug.

That matters wherever logs are shipped to something that expects syslog severities, and it gives the distinction between “this request failed” and “this process cannot continue” that collapsing everything into error loses. LevelName renders the full names; ParseLevel and ValidLevel convert and check a configured string.

ParseFormat and ValidFormat do the same for the output format: JSON, text console, or discard.

Measured

Benchmarkns/opB/opallocs/op
SlogHandler_FormatNoneNoHook6.800
SlogWriter_Bridge352241
SlogLogger_Ungrouped89300
SlogLogger_Console90500
SlogLogger_RecordWithLogValuer1,0721443
SlogLogger_GroupedCommonAttr1,1044647
SlogLogger_Grouped1,1143928
SlogLogger_RecordWithGroup1,1523206
SlogLogger_UngroupedWideRecord1,4702882

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/logutil/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

Two things stand out.

Discard mode costs 6.8 ns and nothing else. That is a fully disabled logger, so leaving instrumentation in place and turning it off by configuration is essentially free, and there is no reason to guard log calls behind a boolean.

A flat record logs with zero allocations at 893 ns. Grouping costs both time and memory: Grouped allocates 392 bytes across 8 allocations for the same data. On a hot path, flat attributes are meaningfully cheaper than nested groups.

Bridging legacy log.Logger

NewSlogWriter and NewLogFromSlog route standard log.Logger output into slog.

This is what lets a service adopt structured logging without rewriting every dependency. Libraries that log through the standard package, including many that take a *log.Logger in their configuration, end up in the same structured stream with the same fields rather than as unstructured lines interleaved with JSON. The bridge costs 352 ns and one allocation.

Hooks

NewSlogHookHandler intercepts records through a HookFunc before they are written. The intended use is driving IncLogLevelCounter on a metrics client, so the count of error-level records is derived from what was actually logged rather than maintained separately.

The hook runs in the logging path, so it must be cheap and must not log.

Dependencies

This package reaches no external module: it uses only the Go standard library.