llingr-logger-zap

module
v0.13.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 15, 2026 License: Apache-2.0

README

llingr-logger-zap

Go Reference Pipeline Coverage Tag License Go Version

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.
  • EINVAL on a sync operation: the Linux form. Per fsync(2), EINVAL means the descriptor type does not support synchronization (a console, a pipe in a container, a tty, a /dev/fd/N alias, a FIFO). Suppression keys on the operation, not the path, so it covers every such sink rather than only /dev/stdout and /dev/stderr. Genuine flush failures come back as EIO, ENOSPC, or EDQUOT and 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 Debug line 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.

Directories

Path Synopsis
Package zaplogger wires an existing go.uber.org/zap logger into a nexus.Logger.
Package zaplogger wires an existing go.uber.org/zap logger into a nexus.Logger.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL