dllog

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 7 Imported by: 0

README

dllog

CI Go Reference Go Report Card

Every Go service faces the same choice: Debug floods production, Info hides the context that explains an error. dllog breaks that trade-off per operation. It is a log/slog handler that buffers below-level records in a bounded per-operation ring, so a successful operation stays as quiet as plain slog while a failed one replays the Debug records that led to it, with their original timestamps.

The same failure, three ways

Three checkouts, logged three times by the same program: one succeeds, one ends in a declined payment, one succeeds again. Reproduce any column with go run ./examples/demo [info|debug|dllog].

slog at Info dllog at Info slog at Debug
You know the middle one failed. You do not know why: the card token, the retry, the decline code were all below the level. As quiet as the left column on the two that succeed. On the one that fails, the buffered Debug records replay with their original timestamps, marked replay=true. The full story, but you pay for it on the successful checkouts too, which is why nobody leaves this on.

Install

go get github.com/arhuman/dllog

Requires Go 1.24 or later. The root package has no dependencies: zap is optional and imported only by zapadapter.

Usage

Pick the entry point that matches the code you are instrumenting:

  • HTTP server: wrap your handler in Middleware().
  • Anything else: open a scope with Scope, and call Trip when you fail.
  • Using zap instead of slog: see the zap adapter.
HTTP

NewJSON builds the handler and its output for you. There is nothing else to wire: the service logs at Info, and a failed request also gets the Debug records that led to it.

package main

import (
	"log/slog"
	"net/http"
	"os"

	"github.com/arhuman/dllog"
)

func main() {
	logger := slog.New(dllog.NewJSON(os.Stderr))
	slog.SetDefault(logger)

	mux := http.NewServeMux()
	mux.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Buffered: invisible on a successful request.
		slog.DebugContext(ctx, "loading cart", "user", 42)
		slog.DebugContext(ctx, "applying discount", "code", "SUMMER")

		// Any Error record replays everything buffered above it first.
		slog.ErrorContext(ctx, "payment declined", "provider", "stripe")

		w.WriteHeader(http.StatusInternalServerError)
	})

	// The middleware opens a scope per request, trips on 5xx and on panic.
	http.ListenAndServe(":8080", dllog.Middleware()(mux))
}

A request that fails emits the two buffered Debug records, marked and carrying the time they were logged at, ahead of the error that released them:

{"time":"2026-09-12T10:23:06.226402+02:00","level":"DEBUG","msg":"loading cart","user":42,"replay":true}
{"time":"2026-09-12T10:23:06.226433+02:00","level":"DEBUG","msg":"applying discount","code":"SUMMER","replay":true}
{"time":"2026-09-12T10:23:06.226434+02:00","level":"ERROR","msg":"payment declined","provider":"stripe"}

A request that succeeds emits neither: the buffer is discarded when the scope ends.

Outside HTTP

Manage the scope yourself. Trip covers the common Go case where the error is returned rather than logged:

func process(ctx context.Context, id string) error {
	ctx, done := dllog.Scope(ctx)
	defer done()

	slog.DebugContext(ctx, "fetching record", "id", id)

	if err := doWork(ctx); err != nil {
		dllog.Trip(ctx) // replay the buffer, then return the error as usual
		return err
	}
	return nil // buffer discarded, nothing emitted
}

Scope joins rather than nests: calling it on a context that already carries a scope returns that same scope and a done that does nothing, so only the creator releases the buffer.

Cost

Outside a scope, a Debug call costs 9.1 ns/op and zero allocations against the 4.2 ns/op of a plain slog logger configured at Info: the record is refused before it is built, and the difference is one context lookup. Inside a scope, buffering a record costs about 235 ns and one allocation, the price of having it available if the operation later fails.

The buffer is strictly count-bounded: a fixed preallocated ring per scope, 256 records by default, evicting oldest-first with a synthetic record reporting anything dropped. What each buffered record retains is up to you, since a record keeps references to what you logged until the scope ends: see the caveats. Nothing grows with uptime.

Full tables and methodology: docs/performance.md.

Documentation

Document Contents
Configuration Every option, choosing the output encoding, wrapping a handler you already have
Performance Benchmarks, the memory model, soak results
Caveats Mutated values, buffer retention, WithGroup
zap adapter Driving the same engine from zap, and its binding cost
ADRs Architecture decision records

A buffered record is written now and formatted later, which has consequences worth knowing before you rely on it: read the caveats.

Status

The log/slog handler, the HTTP middleware, and the zap adapter are implemented and tested. Neither adapter is built on the other: both drive internal/core directly, and either one's Scope is visible to the other.

Pre-v1 and untagged: go get resolves to a pseudo-version, and the API may still change.

Security

Report a vulnerability by email rather than a public issue: see SECURITY.md.

License

MIT, see LICENSE.

Documentation

Overview

Package dllog provides a log/slog handler that records below-level entries per operation and replays them when that operation fails.

The name stands for "dynamic level log". A service configured at Info keeps Debug records in a bounded per-operation buffer; when an error is logged or signalled, the buffer is replayed to the downstream handler, so the failure arrives with the context that preceded it. Operations that end without an error discard their buffer, so the verbosity costs nothing in the common case.

The whole setup is one constructor, which builds the downstream handler too:

logger := slog.New(dllog.NewJSON(os.Stderr))

Use New instead to wrap a handler you already have; it carries the rule that such a handler must be opened at the buffer floor, and panics when it is not.

Buffering is bound to a scope carried by a context.Context and released explicitly. Records logged outside any scope are passed to the downstream handler under the configured level, unbuffered, at the cost of an ordinary disabled slog call.

The engine lives in internal/core and does not depend on log/slog, so the same machinery can back adapters for other logging libraries.

Example

Example is the whole setup: one constructor, no downstream handler to wire. The service logs at Info, and a failed operation also gets the Debug records that preceded it.

package main

import (
	"log/slog"
	"net/http"
	"os"

	"github.com/arhuman/dllog"
)

func main() {
	logger := slog.New(dllog.NewJSON(os.Stderr))
	slog.SetDefault(logger)

	mux := http.NewServeMux()
	mux.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Buffered: invisible on a request that succeeds.
		slog.DebugContext(ctx, "loading cart", "user", 42)

		// Replays everything buffered above it, then emits.
		slog.ErrorContext(ctx, "payment declined", "provider", "stripe")
		w.WriteHeader(http.StatusInternalServerError)
	})

	// The middleware opens a scope per request and trips on 5xx or a panic.
	_ = http.ListenAndServe(":8080", dllog.Middleware()(mux))
}

Index

Examples

Constants

View Source
const (
	// DefaultReplayKey is the attr key marking a replayed record.
	DefaultReplayKey = "replay"

	// DroppedKey carries the number of records the ring evicted, on the
	// synthetic record emitted before a replay batch.
	DroppedKey = "dllog.dropped"

	// DroppedMessage is the message of that synthetic record.
	DroppedMessage = "dllog: buffered records dropped"
)

Default configuration values. They are the ones a Handler built with no options uses.

View Source
const AnchorMessage = "dllog: request failed"

AnchorMessage is the message of the record the middleware emits when a request trips, so a flush always has an anchoring error line.

Variables

This section is empty.

Functions

func Middleware

func Middleware(opts ...MWOption) func(http.Handler) http.Handler

Middleware returns net/http middleware that opens a dllog scope per request.

The scope is placed on the request context, the derived request is passed down, and the scope is released when the handler returns. A request that succeeds discards its buffered records; a request that fails replays them.

A request trips when its response status satisfies the trip predicate (default: 5xx) or when the handler panics. It does not trip on 4xx or on context cancellation: 404s and client disconnects would drown the signal.

A panic is recovered only long enough to trip the scope, then re-panicked with the original value. This is a logging tool, not a recovery layer, so an outer recovery middleware still sees exactly the panic it would have seen.

When a request trips, one anchor Error record carrying method, path and status is emitted, so a flush is never a headless pile of Debug lines. It is suppressed when the handler already tripped the scope itself, which keeps a handler that logged its own Error from being anchored twice.

The path is the matched route pattern ("GET /users/{id}"), not the raw URL, so the anchor does not put the ids and tokens interpolated into a path into the error log. A request with no pattern falls back to the raw path; see WithAnchorPath to control this.

func Scope

func Scope(ctx context.Context) (context.Context, func())

Scope opens a buffering scope on ctx and returns the derived context together with the function that releases it. Records logged with the returned context are buffered instead of dropped, and replayed if the scope trips.

The returned done must be called, normally with defer, exactly once per successful call; calling it more than once is safe and does nothing. A scope that ends without tripping discards its buffer: those records are gone, by design.

Scope joins rather than nests. Called on a context that already carries a scope, it returns a context for that same scope and a done that does nothing, so only the creator's done releases the buffer and an inner scope can never cut an outer one short.

The ring itself is not allocated here. It is created by the first Handler that buffers a record, which is what lets the Handler's WithCapacity and WithPostTripLimit govern it. Because the scope lives in internal/core, an adapter for another logging library binds the same ring on the same context.

Example

ExampleScope manages a scope outside HTTP. Trip covers the common Go case where the failure is returned rather than logged.

package main

import (
	"context"
	"errors"
	"log/slog"
	"os"

	"github.com/arhuman/dllog"
)

func main() {
	logger := slog.New(dllog.NewJSON(os.Stderr))

	process := func(ctx context.Context, id string) error {
		ctx, done := dllog.Scope(ctx)
		defer done()

		logger.DebugContext(ctx, "fetching record", "id", id)

		if err := errors.New("not found"); err != nil {
			dllog.Trip(ctx) // replay the buffer, then return the error as usual
			return err
		}
		return nil // buffer discarded, nothing emitted
	}

	_ = process(context.Background(), "order-1")
}

func Trip

func Trip(ctx context.Context)

Trip flushes the scope on ctx immediately, replaying every buffered record to the downstream handler it was logged through. Use it for failures that are returned rather than logged, which is the common Go case.

Trip is idempotent, and a no-op when ctx carries no scope, when the scope has already tripped, or when it has been released. After it returns, records down to the buffer floor pass straight through to the downstream handler until the scope ends, whether or not anything had been buffered yet.

Each replayed record is marked with the replay key of the handler that logged it, so this function honours WithReplayKey without holding a handler.

Types

type Handler

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

Handler is a slog.Handler that buffers below-level records inside a scope and replays them when the scope trips.

Outside a scope it is an ordinary level filter in front of the downstream handler, and a call below the level costs what a disabled slog call costs. Inside a scope, records from the buffer floor up to the effective level are cloned into a bounded ring, records at or above the effective level are emitted immediately, and a record at or above the trip level flushes the ring to the downstream handler, oldest first, before the triggering record is emitted.

A Handler is safe for concurrent use. Build one with New.

func New

func New(downstream slog.Handler, opts ...Option) *Handler

New returns a Handler wrapping downstream.

Prefer NewJSON or NewText unless you already have a downstream handler: they build one correctly opened and cannot be mis-wired.

downstream must be constructed wide open, at or below the buffer floor: dllog owns the effective level, and a downstream that filters would discard exactly the replayed records the package exists to deliver. New panics if downstream.Enabled reports the buffer floor disabled, because that is a wiring mistake in program setup with no sensible runtime recovery, and failing at construction is far kinder than silently losing every replay in production.

New panics if downstream is nil, for the same reason.

func NewJSON

func NewJSON(w io.Writer, opts ...Option) *Handler

NewJSON returns a Handler writing JSON records to w.

It builds the downstream handler itself, opened at the buffer floor, so the caller never has to open one by hand: use this rather than New unless you already have a downstream handler to wrap. Because dllog holds the only reference to that handler, nothing can gate it shut afterwards and the mis-wiring New panics on cannot happen.

The effective level is still WithLevel, defaulting to Info: NewJSON(w) emits Info and above, and replays buffered Debug records when an operation fails.

func NewJSONWith

func NewJSONWith(w io.Writer, ho slog.HandlerOptions, opts ...Option) *Handler

NewJSONWith is NewJSON with control over the rest of the slog.HandlerOptions, for AddSource or a ReplaceAttr hook.

ho.Level must be nil: dllog owns the downstream level, and a caller-set level is the wiring mistake this constructor exists to make impossible. NewJSONWith panics rather than overriding it silently, so the conflict surfaces at startup instead of becoming a question about which level won. ho is copied, so the caller's struct is not modified.

Example

ExampleNewJSONWith keeps the caller's encoder settings while dllog keeps ownership of the level.

package main

import (
	"log/slog"
	"os"

	"github.com/arhuman/dllog"
)

func main() {
	logger := slog.New(dllog.NewJSONWith(os.Stderr, slog.HandlerOptions{
		AddSource: true,
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == "password" {
				return slog.String("password", "REDACTED")
			}
			return a
		},
		// Level stays nil: dllog sets it from WithBufferFloor.
	}, dllog.WithLevel(slog.LevelWarn)))

	logger.Warn("credentials rotated", "password", "hunter2")
}

func NewText

func NewText(w io.Writer, opts ...Option) *Handler

NewText returns a Handler writing text records to w. It is NewJSON with slog's text encoding.

func NewTextWith

func NewTextWith(w io.Writer, ho slog.HandlerOptions, opts ...Option) *Handler

NewTextWith is NewJSONWith with slog's text encoding.

func (*Handler) Enabled

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

Enabled reports whether a record at level should be produced. It is the cost model of the package, in three ordered checks:

  • At or above the effective level: true, in a scope or out of one. Handle may still drop the record when a tripped scope's post-trip budget is spent; Enabled overreporting there is the slog contract's cheap side.
  • Below the buffer floor: false, everywhere. Nothing keeps such a record.
  • In between, the buffer band: true exactly when ctx carries a live scope, because only a scope has somewhere to put it.

The downstream is never consulted. It is deliberately constructed wide open (New requires it, so replayed records survive), which makes its answer useless as a gate: delegating to it made every out-of-scope call in the buffer band build a full record that Handle then threw away. Answering from the handler's own levels lets slog refuse those calls before the record exists, which is what keeps an out-of-scope Debug near the cost of a disabled slog call.

The level checks come first so the common out-of-band calls never pay the context lookup; only the buffer band needs to know whether a scope is there.

func (*Handler) Handle

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

Handle buffers, replays, or forwards r according to the scope on ctx.

Without a scope, r reaches the downstream handler when it is at or above the configured level and is dropped otherwise. Within a scope, a record below the effective level is cloned into the ring, a record at or above it is emitted immediately, and a record at or above the trip level trips the scope, flushing the buffer to the downstream handlers the buffered records were logged through before r itself is emitted. Everything happens synchronously, on the calling goroutine.

func (*Handler) Trip

func (h *Handler) Trip(ctx context.Context)

Trip flushes the scope on ctx exactly as the package-level Trip does.

It is kept because a handler is often in hand at the failure site and this spelling says so. Since a record now carries the replay key of the handler that logged it, the two are equivalent: neither imposes a key on records logged through a different handler. See docs/adr/0001-replay-key-per-entry.md.

func (*Handler) WithAttrs

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

WithAttrs returns a Handler whose records carry attrs, sharing this Handler's configuration and scope pool.

The attrs are applied eagerly to the downstream handler, and each buffered record travels with the downstream it was logged through, so a replay renders exactly the attrs that were in effect at log time. Sibling handlers derived from the same parent stay independent.

func (*Handler) WithGroup

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

WithGroup returns a Handler that nests subsequent attrs under name, following the slog.Handler contract that an empty name returns the receiver unchanged.

The group is opened on the downstream handler eagerly, exactly as WithAttrs applies attrs. One consequence is documented rather than engineered around: the replay marker is added at flush time, so for a record buffered through a grouped handler the marker nests inside that group instead of sitting at the record root.

type MWOption

type MWOption func(*mwConfig)

MWOption configures Middleware. Options are applied in the order given.

func WithAnchorPath

func WithAnchorPath(fn func(*http.Request) string) MWOption

WithAnchorPath sets how the anchor record's path attribute is derived from the request. It defaults to the matched route pattern, falling back to the raw URL path when the request carries no pattern.

Use it when routing with something other than http.ServeMux, which leaves http.Request.Pattern empty and so falls back to the raw path, or to redact the path some other way. Returning "" emits an empty path attribute rather than omitting it.

The function is called on the failure path only, after the handler has returned, and must not panic: it runs inside the deferred anchor, so a panic there would replace the handler's own. A nil argument is ignored.

func WithLogger

func WithLogger(l *slog.Logger) MWOption

WithLogger sets the logger the anchor record is emitted through. It defaults to slog.Default() as of the request. A nil logger is ignored.

The logger should be backed by a dllog Handler; that is what turns the anchor into a replay of the request's buffered records.

func WithTripOn

func WithTripOn(pred func(status int) bool) MWOption

WithTripOn sets the predicate deciding whether a response status trips the scope. It defaults to status >= 500.

Replacing it replaces the default entirely: a predicate that ignores 5xx makes the middleware ignore 5xx. A nil predicate is ignored.

type Option

type Option func(*config)

Option configures a Handler. Options are applied by New in the order given.

func WithBufferFloor

func WithBufferFloor(l slog.Leveler) Option

WithBufferFloor sets the lowest level a scope buffers. Records below it are dropped even inside a scope. Defaults to slog.LevelDebug.

func WithCapacity

func WithCapacity(n int) Option

WithCapacity sets how many records a scope buffers before evicting the oldest. A value <= 0 selects the default of 256.

func WithLevel

func WithLevel(l slog.Leveler) Option

WithLevel sets the effective level: records at or above it are emitted as usual, inside a scope or out of one. Below it, records are buffered inside a scope and dropped outside. Defaults to slog.LevelInfo.

This is the level the Handler owns. The downstream handler must stay wide open at the buffer floor so it cannot swallow replays; New checks that.

func WithPostTripLimit

func WithPostTripLimit(n int) Option

WithPostTripLimit caps how many records pass through after a scope trips, bounding the output of a long-lived scope that keeps logging after its failure. Zero, the default, means unlimited.

func WithReplayKey

func WithReplayKey(k string) Option

WithReplayKey sets the attr key marking replayed records. Defaults to "replay".

The key travels with each buffered record, so it applies however the replay was triggered: by a record at the trip level, by either Trip form, or by the middleware. See docs/adr/0001-replay-key-per-entry.md.

func WithTripLevel

func WithTripLevel(l slog.Leveler) Option

WithTripLevel sets the level at or above which a record trips its scope and replays the buffer. Defaults to slog.LevelError.

Directories

Path Synopsis
examples
demo command
Command demo runs one fixed workload under three logging configurations so the same sequence of events can be compared side by side.
Command demo runs one fixed workload under three logging configurations so the same sequence of events can be compared side by side.
internal
core
Package core implements the buffering engine behind dllog: scope lifecycle, the trip state machine, and the bounded ring that holds pending entries.
Package core implements the buffering engine behind dllog: scope lifecycle, the trip state machine, and the bounded ring that holds pending entries.
Package zapadapter is the zap adapter for dllog: a zapcore.Core that buffers below-level entries inside a dllog scope and replays them when the scope trips.
Package zapadapter is the zap adapter for dllog: a zapcore.Core that buffers below-level entries inside a dllog scope and replays them when the scope trips.

Jump to

Keyboard shortcuts

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