slogger

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 16 Imported by: 0

README

slogger

tests codecov Go Reference Go Report Card

A custom slog.Handler implementation for Go's log/slog package. Produces a human-readable text format with support for grouped attributes, message placeholders, pluggable stack traces, and context-based overrides.

Zero external dependencies in the core module.

Install

go get github.com/go-devkit/slogger

Usage

handler, err := slogger.New(
    slogger.WithMinLevel(slog.LevelDebug),
    slogger.WithLogFile("/var/log/app.log"),
)
if err != nil {
    log.Fatal(err)
}
defer handler.Close()

slog.SetDefault(slog.New(handler))

slog.Info("server started", "port", 8080)
// 2025-01-15 10:30:00 main.go:42: [info] server started ( port=8080 )

Output Format

timestamp file:line: [level] prefix message ( key=value key2="string value" )
stacktrace
  • Timestamp: 2006-01-02 15:04:05 (optional, configurable via WithTimestampFormat)
  • Source: file:line (optional; base name by default, or module-relative package path via WithSourcePath / abbreviated via WithAbbreviatedSourcePath, WithModulePrefix to keep the full module path)
  • Level: debug, info, warn, error (custom levels preserved, e.g. error+2)
  • Strings and errors are quoted in attribute values
  • Stack traces appear on subsequent lines when configured

Options

slogger.New(
    slogger.WithContext(ctx),                      // context for cancellation-aware error handling
    slogger.WithMinLevel(slog.LevelDebug),         // minimum log level (default: info)
    slogger.WithDefaultOutput(w),                  // additional writer for debug/info/warn
    slogger.WithErrorOutput(w),                    // additional writer for error and above
    slogger.WithLogFile("/path/to/file.log"),       // unbuffered file output (adds to both default and error)
    slogger.WithTimestampFormat(time.RFC3339),      // custom timestamp layout (default: 2006-01-02 15:04:05)
    slogger.WithoutTimestamp(),                     // disable timestamp prefix
    slogger.WithoutSource(),                        // disable file:line source prefix
    slogger.WithSourcePath(),                       // module-relative source path (worker/file.go)
    slogger.WithAbbreviatedSourcePath(),            // abbreviated source dirs (w/file.go)
    slogger.WithModulePrefix(),                     // keep full module prefix (github.com/org/pkg/file.go)
    slogger.WithPrefixFunc(fn),                     // dynamic prefix from context/record
    slogger.WithPlaceholderReplace(),               // enable {key} interpolation in messages
    slogger.WithStackTraceFunc(fn),                 // pluggable stack trace extraction from errors
    slogger.WithReplaceAttr(fn),                    // rename/redact/drop attributes (slog.HandlerOptions.ReplaceAttr)
)
Writer Routing
  • debug, info, warn logs go to outputDefault writers (default: os.Stdout)
  • error and above go to outputError writers (default: os.Stderr)
  • WithLogFile adds an unbuffered writer to both
Placeholder Interpolation

When enabled with WithPlaceholderReplace(), attribute values can be interpolated into the message using {key} syntax. Matched attributes are consumed and won't appear in the attribute list.

handler, _ := slogger.New(slogger.WithPlaceholderReplace())
log := slog.New(handler)

log.Info("user {name} logged in from {ip}",
    "name", "alice",
    "ip", "10.0.0.1",
)
// main.go:10: [info] user alice logged in from 10.0.0.1
Stack Traces

Stack trace extraction is opt-in via WithStackTraceFunc. This keeps the core module free of error library dependencies. Provide a function that extracts a stack trace string from an error, or returns "" if none is available.

Example with github.com/pkg/errors:

func pkgErrorsStackTrace(err error) string {
    type stackTracer interface {
        StackTrace() errors.StackTrace
    }

    var st stackTracer
    for e := err; e != nil; {
        if s, ok := e.(stackTracer); ok {
            st = s
        }
        if u, ok := e.(interface{ Unwrap() error }); ok {
            e = u.Unwrap()
        } else {
            break
        }
    }

    if st == nil {
        return ""
    }

    var b strings.Builder
    for _, f := range st.StackTrace() {
        fmt.Fprintf(&b, "%+v\n", f)
    }
    return b.String()
}

handler, _ := slogger.New(slogger.WithStackTraceFunc(pkgErrorsStackTrace))

Example with github.com/go-errors/errors:

func goErrorsStackTrace(err error) string {
    var e *goerrors.Error
    if goerrors.As(err, &e) {
        return e.ErrorStack()
    }
    return ""
}
Attribute Replacement

WithReplaceAttr mirrors slog.HandlerOptions.ReplaceAttr. The hook runs for every non-group attribute before it is formatted — use it to rename, redact, or drop attributes.

handler, _ := slogger.New(
    slogger.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == "password" {
            return slog.String("password", "***")
        }
        return a
    }),
)

log := slog.New(handler)
log.Info("login", "user", "alice", "password", "hunter2")
// main.go:10: [info] login ( user="alice" password="***" )
  • groups holds the sequence of group names in effect for the attribute; values are already resolved.
  • Return an Attr with an empty Key to drop it.
  • Returning a group inlines its members under the (possibly renamed) key.
  • The hook is not called for the built-in message, level, timestamp, or source fields — this handler formats those directly.
Context-Based Overrides

Override log level or output on a per-request basis via context:

// Temporarily lower the log level for a specific request
ctx = slogger.WithTempLevel(ctx, slog.LevelDebug)

// Redirect logs to a specific writer (only=false adds to existing outputs)
ctx = slogger.WithTempOutput(ctx, &requestBuffer, false)

// Replace all outputs with just this writer (only=true)
ctx = slogger.WithTempOutput(ctx, &requestBuffer, true)
Cancellation-Aware Error Handling

When a handler context is provided via WithContext and that context is cancelled, any context.Canceled errors are automatically downgraded from error to warn level. This prevents noisy error logs during graceful shutdown.

Buffered I/O

WithLogFile writes unbuffered, so log lines survive a process crash. To trade durability for fewer syscalls, wrap your own writer with NewBufferedWriter and pass it via WithDefaultOutput:

bw := slogger.NewBufferedWriter(myWriter, 4096)
handler, _ := slogger.New(slogger.WithDefaultOutput(bw))
// Call bw.Flush() on shutdown
Async Output

There is no built-in mailer. Delivery to any slow or unreliable destination (mail, webhook, remote log API) is a plain io.Writer wrapped in an AsyncWriter: a buffered channel drained by a background goroutine, so logging never blocks. When the buffer is full, messages are dropped and the drop count is reported through the fallback logger (defaults to writing to stderr).

WithAsyncErrorOutput wires it to error-level logs and registers it for drain on handler.Close():

handler, _ := slogger.New(
    slogger.WithAsyncErrorOutput(dst, 100, nil), // dst is any io.Writer
)
defer handler.Close()

Or use NewAsyncWriter directly for finer control:

w := slogger.NewAsyncWriter(dst, 100, func(msg string, args ...any) {
    slog.Warn(msg, args...)
})
handler, _ := slogger.New(slogger.WithErrorOutput(w))
defer w.Close()

Email is then just an io.Writer that sends p over SMTP — the logger stays out of transport, batching, and retry concerns:

type mailWriter struct{ /* smtp config */ }

func (m *mailWriter) Write(p []byte) (int, error) {
    err := smtp.SendMail(
        "smtp.example.com:587",
        smtp.PlainAuth("", "alerts@example.com", "secret", "smtp.example.com"),
        "alerts@example.com",
        []string{"team@example.com"},
        p,
    )
    return len(p), err
}

handler, _ := slogger.New(
    slogger.WithAsyncErrorOutput(&mailWriter{}, 100, nil),
)
defer handler.Close()

The drop/error report defaults to stderr rather than slog.Default, so it never re-enters a handler this writer is attached to (which would deadlock during Close). A custom fallback should keep the same independence.

Thread Safety

The handler is safe for concurrent use. All writes are serialized with a mutex, and Close() acquires the same lock to prevent races during shutdown.

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func WithTempLevel

func WithTempLevel(ctx context.Context, level slog.Level) context.Context

WithTempLevel returns a new context that holds the given log level. The logger will output any log messages with the given level or higher, as long as the context is passed along.

func WithTempOutput

func WithTempOutput(ctx context.Context, out io.Writer, only bool) context.Context

WithTempOutput returns a new context that holds the given log output. Any writes to log/slog will be written to the given writer, as long as the context is passed along. The only flag determines whether the given writer should be the only output or an addition.

Types

type AsyncWriter

type AsyncWriter struct {
	// contains filtered or unexported fields
}

AsyncWriter wraps an io.Writer with a buffered channel and a background goroutine, decoupling the caller from a slow or unreliable destination (mail, webhook, remote log API). Writes never block: when the buffer is full, messages are dropped and the drop count is reported via fallback.

It is transport-agnostic; the destination io.Writer owns delivery.

func NewAsyncWriter

func NewAsyncWriter(dst io.Writer, bufferSize int, fallback func(msg string, args ...any)) *AsyncWriter

NewAsyncWriter returns a started AsyncWriter that drains into dst. A bufferSize <= 0 defaults to 100. fallback receives write failures and overflow reports; a nil fallback defaults to writing them to stderr. Call Close to stop the goroutine and flush remaining messages.

A custom fallback that logs back through a handler this writer is attached to can deadlock during Close; keep the fallback independent of that handler.

func (*AsyncWriter) Close

func (w *AsyncWriter) Close() error

Close stops the background goroutine after draining the buffer. It is idempotent and safe to register with the handler so handler.Close drains it on shutdown.

func (*AsyncWriter) Write

func (w *AsyncWriter) Write(p []byte) (int, error)

Write satisfies io.Writer. It copies p (slog reuses the record buffer) and enqueues it, dropping the message if the buffer is full or the writer is closed. It never sends on a closed channel, so it is safe after Close.

type BufferedWriter

type BufferedWriter struct {
	// contains filtered or unexported fields
}

BufferedWriter wraps an io.Writer with buffering. Pass it to WithDefaultOutput or WithErrorOutput to reduce syscalls. Call Flush() to ensure all buffered data is written.

func NewBufferedWriter

func NewBufferedWriter(w io.Writer, bufferSize int) *BufferedWriter

func (*BufferedWriter) Flush

func (bw *BufferedWriter) Flush() error

func (*BufferedWriter) Write

func (bw *BufferedWriter) Write(p []byte) (int, error)

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

func New

func New(opts ...Option) (*Handler, error)

func (*Handler) Close

func (h *Handler) Close() error

func (*Handler) Enabled

func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool

Enabled reports whether the handler handles records at the given level. The handler ignores records whose level is lower. Enabled is called early, before any arguments are processed, to save effort if the log event should be discarded.

func (*Handler) Handle

func (h *Handler) Handle(ctx context.Context, record slog.Record) error

Handle handles the Record. It will only be called if Enabled returns true. Handle methods that produce output should observe the following rules:

  • If r.Time is the zero time, ignore the time.
  • If an Attr's key is the empty string, ignore the Attr.

func (*Handler) WithAttrs

func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler

WithAttrs returns a new Handler whose attributes consist of both the receiver's attributes and the arguments. The Handler owns the slice: it may retain, modify or discard it.

func (*Handler) WithGroup

func (h *Handler) WithGroup(name string) slog.Handler

WithGroup returns a new Handler with the given group appended to the receiver's existing groups. The keys of all subsequent attributes, whether added by With or in a Record, should be qualified by the sequence of group names.

How this qualification happens is up to the Handler, so long as this Handler's attribute keys differ from those of another Handler with a different sequence of group names.

A Handler should treat WithGroup as starting a Group of Attrs that ends at the end of the log event. That is,

logger.WithGroup("s").LogAttrs(level, msg, slog.Int("a", 1), slog.Int("b", 2))

should behave like

logger.LogAttrs(level, msg, slog.Group("s", slog.Int("a", 1), slog.Int("b", 2)))

type Option

type Option func(*options) error

func WithAbbreviatedSourcePath

func WithAbbreviatedSourcePath() Option

WithAbbreviatedSourcePath renders the module-relative package path with each directory abbreviated to its first rune, keeping the file name intact (e.g. "w/worker.go"). Combine with WithModulePrefix to abbreviate the full module-qualified path instead. Has no effect when WithoutSource is set.

func WithAsyncErrorOutput

func WithAsyncErrorOutput(dst io.Writer, bufferSize int, fallback func(msg string, args ...any)) Option

WithAsyncErrorOutput routes error-level logs to dst through an AsyncWriter, so a slow or unreliable destination never blocks the caller. The writer is registered as a closer, so handler.Close drains it on shutdown. See NewAsyncWriter for the bufferSize and fallback semantics.

func WithContext

func WithContext(ctx context.Context) Option

func WithDefaultOutput

func WithDefaultOutput(out io.Writer) Option

func WithErrorOutput

func WithErrorOutput(out io.Writer) Option

func WithLogFile

func WithLogFile(path string) Option

func WithMinLevel

func WithMinLevel(level slog.Level) Option

func WithModulePrefix

func WithModulePrefix() Option

WithModulePrefix keeps the main module's path prefix on the source path, so e.g. "go.wohnparc.dev/app/moeware/worker/worker.go" is rendered in full rather than the default module-relative "worker/worker.go". It modifies WithSourcePath and WithAbbreviatedSourcePath and has no effect on its own or when WithoutSource is set. The module path is read from the binary's build info, which is always kept when unavailable (e.g. `go run` of a single file).

func WithPlaceholderReplace

func WithPlaceholderReplace() Option

func WithPrefixFunc

func WithPrefixFunc(fn func(context.Context, slog.Record) string) Option

func WithReplaceAttr

func WithReplaceAttr(fn func(groups []string, a slog.Attr) slog.Attr) Option

WithReplaceAttr registers a hook that is called for every non-group attribute before it is formatted, mirroring the semantics of slog.HandlerOptions.ReplaceAttr. Use it to rename or redact attributes.

groups holds the sequence of group names in effect for the attribute. The attribute's value is already resolved. Returning an Attr with an empty Key drops the attribute. If the returned Attr is itself a group, its members are processed under the same group qualification.

The hook is not called for the built-in message, level, timestamp or source fields, which this handler formats directly.

func WithSourcePath

func WithSourcePath() Option

WithSourcePath renders the source as the module-relative package path (e.g. "worker/worker.go") instead of the base file name. The path is derived from the caller's package, so it does not depend on -trimpath. Combine with WithModulePrefix to keep the full module-qualified path. Has no effect when WithoutSource is set.

func WithStackTraceFunc

func WithStackTraceFunc(fn func(error) string) Option

func WithTimestampFormat

func WithTimestampFormat(layout string) Option

WithTimestampFormat sets the layout used to render the leading timestamp, using the reference time "Mon Jan 2 15:04:05 MST 2006" (see the time package). An empty layout is ignored, keeping the default "2006-01-02 15:04:05". Has no effect when WithoutTimestamp is set.

func WithoutSource

func WithoutSource() Option

func WithoutTimestamp

func WithoutTimestamp() Option

Jump to

Keyboard shortcuts

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