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 buffers below-level records in a bounded per-operation ring, so a successful
operation stays as quiet as your logger at Info while a failed one replays the
Debug records that led to it, with their original timestamps.
dllog is not a logging library and does not replace yours: it plugs into the
one you already use. It works with log/slog and zap today, even mixed in
the same service, and other libraries can be supported the same way.
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. |
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.
Pick the entry point that matches the code you are instrumenting:
Middleware().Scope, and call Trip when you fail.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.
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.
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.
| 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.
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: released as v0.1.x, and the API may still change before v1. Releases are tagged and listed in the CHANGELOG.
Report a vulnerability by email rather than a public issue: see SECURITY.md.
MIT, see LICENSE.
48 commits
Go
93.3%
Shell
4.9%
Makefile
1.4%
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 buffers below-level records in a bounded per-operation ring, so a successful
operation stays as quiet as your logger at Info while a failed one replays the
Debug records that led to it, with their original timestamps.
dllog is not a logging library and does not replace yours: it plugs into the
one you already use. It works with log/slog and zap today, even mixed in
the same service, and other libraries can be supported the same way.
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. |
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.
Pick the entry point that matches the code you are instrumenting:
Middleware().Scope, and call Trip when you fail.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.
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.
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.
| 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.
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: released as v0.1.x, and the API may still change before v1. Releases are tagged and listed in the CHANGELOG.
Report a vulnerability by email rather than a public issue: see SECURITY.md.
MIT, see LICENSE.
48 commits
Go
93.3%
Shell
4.9%
Makefile
1.4%