README
¶
llingr-logger-zap
A nexus.Logger implementation
backed by Uber's zap.
llingr-nexus ships a zero-dependency DefaultLogger (slog-based) for
convenience. For production deployments the recommendation is to plug in an
established logging library; this module is one such binding (zap). The
interface is small, so a zerolog, logrus, or bespoke binding is equally valid;
this is just the zap one.
- Module:
github.com/llingr/llingr-logger-zap - Go: 1.24+
- Licence: Apache-2.0
- Depends on:
llingr-nexus,go.uber.org/zap(no other third-party deps)
Call-site transparency
The wrapper is call-site transparent: the file and line zap records belong to the application code that called the log method, never to this package. Each log method adds one stack frame between your code and zap's caller capture, so every constructor enables caller capture and applies a matching caller-skip to chop that frame off. You get the call site of the invocation, not the call site of the logger.
You do not need to build the underlying logger with zap.AddCaller(); the
constructor turns caller capture on for you (and re-enabling it on the host
logger is harmless). The one thing outside this package's control is the
encoder: the caller field is only rendered if the encoder config has a
CallerKey (zap's NewProduction / NewDevelopment presets set one by
default).
If your host has made a deliberate caller decision the adapter should not
override (caller turned off for cost or privacy, say), pass
zaplogger.PreserveHostCaller():
log := zaplogger.New(z, zaplogger.PreserveHostCaller())
The wrapper frame is still skipped, so if the host has caller capture on you still get the application's call site; if the host turned it off, it stays off.
Sync and console errors
Sync() flushes buffered entries; call it before the process exits. zap's
Sync runs fsync on the sink, and some sinks cannot be synchronized: a
console, pipe, FIFO, tty, or socket. The kernel rejects the call, but no data
was lost, so by default this wrapper treats those errors as success and a clean
shutdown reports nothing. Two shapes are recognised:
ENOTTY("inappropriate ioctl for device"): the darwin/BSD form.EINVALon asyncoperation: the Linux form. Perfsync(2),EINVALmeans the descriptor type does not support synchronization (a console, a pipe in a container, a tty, a/dev/fd/Nalias, a FIFO). Suppression keys on the operation, not the path, so it covers every such sink rather than only/dev/stdoutand/dev/stderr. Genuine flush failures come back asEIO,ENOSPC, orEDQUOTand are returned.
One consequence: a log file on a filesystem that does not implement fsync
(some FUSE mounts) also returns sync-EINVAL and is suppressed. That hides
"this file cannot be made durable", though not "data was lost", which is the
same condition the suppression exists for.
Pass zaplogger.PreserveHarmlessSyncErrors() to get zap's error back verbatim:
log := zaplogger.New(z, zaplogger.PreserveHarmlessSyncErrors())
Genuine flush failures are always returned. With a multi-sink logger
(zapcore.NewTee) zap combines the per-core Sync errors into one;
suppression requires that every constituent is a harmless sync error, so if
the console reports ENOTTY and a file sink reports a real fsync failure in the
same call, the combined error is returned, not swallowed.
Usage
This package wires an existing logger; it does not create one. The host
application supplies its own zap logger, configured however it likes, and you
wrap it (New panics if handed a nil logger):
import (
"github.com/llingr/llingr-logger-zap/zaplogger"
"go.uber.org/zap"
)
z := app.Logger() // the host's own *zap.Logger, their config
log := zaplogger.New(z) // wrap it as a nexus.Logger
consumer, _ := builder.
WithLogger(log).
Build()
Already holding a *zap.SugaredLogger? Use zaplogger.NewSugared(s); it
desugars back to the typed logger for you.
There are deliberately no NewProduction/NewDevelopment helpers: building a
logger is zap's job (zap.NewProduction() etc.), and forcing a configuration is
exactly what this package avoids.
Arguments
The message is logged verbatim: this package never runs fmt.Sprintf on it,
so a literal % is safe. The variadic arguments become structured fields, in
either of two forms, which may be mixed:
// Native zap fields: the typed, allocation-light path:
log.Info(ctx, "processed", zap.String("key", msg.Key), zap.Int("partition", msg.Partition))
// Loose key/value pairs: slog / SugaredLogger style:
log.Info(ctx, "processed", "key", msg.Key, "partition", msg.Partition)
Because native zap.Field values are accepted directly, the package wraps the
non-sugared *zap.Logger. There is no SugaredLogger in the call path, so
the typed fast path is preserved and the common zero-argument call (the dominant
pattern in the llingr engine, which pre-formats its messages) costs nothing
beyond zap's own work.
A bare error passed where a key is expected becomes a standard "error"
field (zap.Error), matching zap's SugaredLogger:
log.Error(ctx, "drain failed", err) // => {... "error": "broker gone"}
An error in value position stays the value of its key
(log.Error(ctx, "drain failed", "cause", err) logs it under cause). A
trailing key with no value, or any other non-string where a key is expected, is
recorded under !BADKEY, matching slog and zap's SugaredLogger.
The context.Context argument is accepted to satisfy the interface; this
package does not read from it.
With returns a child logger with fields bound to every subsequent line,
preserving call-site transparency:
topicLog := log.With("topic", "payments")
topicLog.Warn(ctx, "retry scheduled", "attempt", 2)
Context fields
nexus.Logger threads a context.Context into every call, and most
implementations discard it. Register a ContextExtractor to pull ambient
correlation data (trace IDs, request IDs, tenant, ...) out of that context and
have it added to every line:
log = log.WithContextExtractor(func(ctx context.Context) []zap.Field {
if id, ok := ctx.Value(traceKey).(string); ok {
return []zap.Field{zap.String("trace_id", id)}
}
return nil
})
log.Info(ctx, "handled") // => {... "trace_id": "abc123"}
The extractor is wired through zap.Inline, so:
- Lazy. It runs only when an entry clears the level gate and is actually
encoded. A suppressed
Debugline costs no extraction. - Flat. The fields land at the top level of the entry, not nested under a key.
Which keys to read is application-specific, which is why it is a plugged-in
function rather than built in. Pass nil to disable it on a child logger.
API
| Constructor | Use |
|---|---|
New(*zap.Logger, ...Option) *Logger |
Wrap an existing zap logger |
NewSugared(*zap.SugaredLogger, ...Option) *Logger |
Wrap an existing sugared logger |
| Option | Effect |
|---|---|
PreserveHostCaller() |
Do not force zap.AddCaller(); keep the host's caller setting (wrapper skip still used) |
PreserveHarmlessSyncErrors() |
Return zap's raw Sync error instead of swallowing the harmless fsync errors |
Methods: Error, Warn, Info, Debug (the nexus.Logger interface), plus
With(args ...any) *Logger,
WithContextExtractor(ContextExtractor) *Logger, and Sync() error.