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))
}
Output:
Index ¶
- Constants
- func Middleware(opts ...MWOption) func(http.Handler) http.Handler
- func Scope(ctx context.Context) (context.Context, func())
- func Trip(ctx context.Context)
- type Handler
- func New(downstream slog.Handler, opts ...Option) *Handler
- func NewJSON(w io.Writer, opts ...Option) *Handler
- func NewJSONWith(w io.Writer, ho slog.HandlerOptions, opts ...Option) *Handler
- func NewText(w io.Writer, opts ...Option) *Handler
- func NewTextWith(w io.Writer, ho slog.HandlerOptions, opts ...Option) *Handler
- type MWOption
- type Option
Examples ¶
Constants ¶
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.
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 ¶
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 ¶
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")
}
Output:
func Trip ¶
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 ¶
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 ¶
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 ¶
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")
}
Output:
func NewText ¶
NewText returns a Handler writing text records to w. It is NewJSON with slog's text encoding.
func NewTextWith ¶
NewTextWith is NewJSONWith with slog's text encoding.
func (*Handler) Enabled ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
WithBufferFloor sets the lowest level a scope buffers. Records below it are dropped even inside a scope. Defaults to slog.LevelDebug.
func WithCapacity ¶
WithCapacity sets how many records a scope buffers before evicting the oldest. A value <= 0 selects the default of 256.
func WithLevel ¶
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 ¶
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 ¶
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 ¶
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. |


