Dev Bench SDK for Go
github.com/pasperry/devbench-sdk/go (package devbench) — standard library
only. The contract is docs/SERVER_SDK_SPEC.md.
Install
Three steps, no sidecar:
go get github.com/pasperry/devbench-sdk/goimport devbench "github.com/pasperry/devbench-sdk/go"
// 1. Wrap your handler.
http.ListenAndServe(":8080", devbench.Middleware(devbench.Handled(mux)))
// 2. Send server log lines to triage (slog; see "Server logs" below).
slog.SetDefault(slog.New(devbench.NewLogHandler(slog.Default().Handler())))# 3. Set the DSN Dev Bench gave you for this environment (`adt dsn create`).
DEVBENCH_DSN=https://<public key>:<secret key>@adt-ingest.onrender.comThat is all: the first request configures the SDK from the environment.
Panics, ReportHandled and CaptureException are counted and flushed every
minute; the log lines of a failing request reach triage when it asks for
them. Optionally defer devbench.Close(context.Background()) in main to
send the last minute on graceful shutdown.
The package is at the module root, so the import path is the module path.
Its last element is go, not devbench; Go accepts that, and the alias
above just makes it explicit for readers and linters.
Configure
One environment variable:
DEVBENCH_DSN=https://<public key>:<secret key>@adt-ingest.onrender.comThe secret part is this environment's server key: the SDK authenticates
with it, and it is what lets ingest ask this process for log lines. Keep the
whole value server-side (the browser SDK refuses a DSN with a secret in it).
A 0.5-style DSN with a single key, https://<key>@host, still works and
sends that key.
Or configure in code — every field is optional and falls back to its environment variable:
devbench.Init(devbench.Options{
DSN: os.Getenv("DEVBENCH_DSN"),
Service: "billing", // default: DEVBENCH_SERVICE, else the main module's last path element, else "app"
Release: gitSHA, // default: see DEVBENCH_RELEASE below
})
defer devbench.Close(context.Background()) // sends the last window on shutdown (bounded to 2 s)| Variable | |
|---|---|
DEVBENCH_DSN (fallback ADT_DSN) |
ingest endpoint and key; set → direct mode. Plain http:// is accepted only for a loopback ingest |
DEVBENCH_SERVICE |
service name |
DEVBENCH_RELEASE |
deployed build. Unset, the first found of: a REVISION file in the working directory (Capistrano writes one), HEROKU_SLUG_COMMIT, KAMAL_VERSION, GITHUB_SHA, GIT_SHA, SOURCE_VERSION, RENDER_GIT_COMMIT, the commit go build stamped into the binary (vcs.revision); else empty. Options.Release wins over all of these |
DEVBENCH_ENABLED=false |
turns everything off |
An invalid DSN logs one line (never either key), turns reporting off, and never
fails the application; Init also returns it. Check a DSN without waiting a
minute:
if err := devbench.Test(ctx); err != nil {
log.Fatal(err) // no DSN, rejected key (401/403), or ingest unreachable
}mux := http.NewServeMux()
mux.HandleFunc("GET /deals/{id}", showDeal)
// Trace propagation, panic capture, and the x-adt-handled header.
http.ListenAndServe(":8080", devbench.Middleware(devbench.Handled(mux)))gin
A separate module, so the core SDK never depends on gin:
go get github.com/pasperry/devbench-sdk/go/ginimport devbenchgin "github.com/pasperry/devbench-sdk/go/gin"
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), devbenchgin.Middleware()) // after RecoveryA panic is reported (with the gin route, e.g. GET /deals/:id, as its
symbol on Go 1.23+) and re-panicked, so gin.Recovery still answers 500.
Log with slog.InfoContext(c.Request.Context(), ...) or
slog.InfoContext(c, ...) — both are keyed by the request's trace.
Use
| What | How | Reported as |
|---|---|---|
| Panics in a handler | automatic in the middleware: reported, then re-panicked with the same value | exception, context request |
| An error worth reporting | devbench.CaptureException(ctx, err) |
exception, context explicit |
| A failure deliberately absorbed | devbench.ReportHandled(ctx, err, "billing.Invoicer.Persist") |
handled_failure (+ the x-adt-handled count) |
| Who was affected | ctx = devbench.WithUser(ctx, devbench.User{Email: u.Email, Account: acct.ID}) |
affected users, never in evidence |
| Outbound trace | &http.Client{Transport: devbench.WrapTransport(nil)} |
x-adt-trace header, hop + 1 |
| Server log lines for triage | slog.New(devbench.NewLogHandler(h)), log with the request's context |
captured per trace; sent redacted when asked (below) |
Set the user once per request, in your auth middleware:
ctx := devbench.WithUser(r.Context(), devbench.User{Email: user.Email, Account: account.ID})
next.ServeHTTP(w, r.WithContext(ctx)) // gin: c.Request = c.Request.WithContext(ctx)if err := deals.Save(ctx, d); err != nil {
devbench.ReportHandled(ctx, err, "deals.Update") // handled: user sees a fallback
renderFallback(w)
return
}Server logs
When Dev Bench triages a failure the browser saw, it asks for the server lines logged under that user action's trace. In direct mode the SDK keeps recent lines in memory for that and needs no sidecar.
slog.SetDefault(slog.New(devbench.NewLogHandler(slog.Default().Handler())))
func charge(w http.ResponseWriter, r *http.Request) {
slog.InfoContext(r.Context(), "charging card", "deal", id) // captured under the request's trace
}Wrapping slog.Default().Handler() is safe: slog's built-in handler writes
through the log package, which slog.SetDefault points back at slog, so
NewLogHandler swaps it for an equivalent (same LEVEL message k=v
format after the log date prefix, same level, same writer) that does not
loop. Any other handler (slog.NewJSONHandler(os.Stdout, nil), …) is
wrapped as is.
What is captured:
- slog records logged with the request's context (
r.Context(), or anything derived from it; under gin alsoc) throughNewLogHandler, in a request whosex-adt-traceheaderMiddlewareaccepted. Rendered as one line: time, level, message,key=valueattributes, the trace. Records below the wrapped handler's level are not captured. - Standard
loglines that carry a trace id in their text, withlog.SetOutput(devbench.LogWriter(os.Stderr)).devbench.Logger(ctx)returns a*log.Loggerthat writes the id for you.
What is not:
- Lines logged without the request's context (
slog.Info(...),context.Background()), and plainlog.Printflines without a trace id. Thelogpackage passes no context and Go has no goroutine-local state a library could read, so there is no honest way to key them. - Requests with no trace (no Dev Bench browser SDK upstream, or a malformed header): nothing could ever ask for their lines.
- Anything in sidecar mode (no DSN): the sidecar reads your logs instead.
- Output of other loggers (zap, zerolog, logrus) unless they log through slog with the context.
Bounds: the most recent 10,000 lines or 4 MiB per process, whichever is smaller, nothing older than 15 minutes, 4 KiB per line, oldest dropped first. Capturing never blocks on I/O, never panics into a log call, and never changes what your handler or writer outputs — your own logs stay exactly as they were.
Lines leave the process only when Dev Bench asks for a trace: the newest 200 lines for it (≤ 64 KiB), redacted with the same strict rules as evidence plus any redaction rules Dev Bench has learned for this tenant, sent to ingest with the secret key from the background goroutine. A process holding no lines for a trace sends nothing. Dev Bench's request rides the response to a flush, so while the process holds captured lines and has nothing to report it sends one empty flush per minute as a poll; a request is answered within one flush interval. A process holding no lines sends nothing at all.
How reports travel
Exactly one transport, decided at start — never both, or every report would be counted twice:
- Direct (a DSN is set; the default install). The SDK does in-process what the sidecar does: fingerprints each report with the same algorithm (so moving between modes keeps every issue), counts repeats, and flushes counts to ingest every 60 s from a background goroutine. Evidence — the redacted message and the stack — is uploaded only when Dev Bench asks for it, redacted in the SDK with the sidecar's strict rules (values templated, emails/cards/tokens/IPs scrubbed, two-word names masked). Identity is never part of evidence.
- Sidecar (no DSN). Reports go as line-delimited JSON to the local
sidecar's unix socket (
$ADT_SIDECAR_SOCKET, default/tmp/adt-sidecar.sock), which does the rest.
The sidecar is optional: direct mode captures and ships log lines itself (above). Run one only for logs the SDK cannot see (another process, another logging library). Learned redaction rules arrive on every flush response and are applied in the SDK exactly as the sidecar applies them, to log lines and evidence alike; no rule can lift the strict base redaction.
Never harming the host: callers never wait on I/O (a bounded queue drops and
counts — see devbench.Stats()); network calls time out within 5 s on the
background goroutine, are retried once with jitter, then discarded; nothing
panics into the application.
Migrating from .../adt/sdk/server/go/adt (0.4.x)
- Remove the vendored copy and its
replace:go mod edit -dropreplace=github.com/pasperry/adt/sdk/server/go -droprequire=github.com/pasperry/adt/sdk/server/go go get github.com/pasperry/devbench-sdk/go- Change the import to
devbench "github.com/pasperry/devbench-sdk/go"andadt.todevbench.(same API:Middleware,Handled,ReportHandled,CaptureException,WithUser,WrapTransport,NewLogHandler,Close, …). - Set
DEVBENCH_DSNto switch to direct mode, or leave it unset to keep reporting through your sidecar. Fingerprints are the same either way.
Notes
- Error type names.
erroris the first named type in theUnwrapchain, skipping*fmt.wrapError,*errors.errorStringand the like, sofmt.Errorf("save: %w", pgErr)groups as*pgconn.PgError. - Symbol for a panic is the
ServeMuxroute pattern (GET /deals/{id}) or gin's route; with another router it is empty, never the raw path. - Goroutines. Only the request goroutine is covered: a panic in a goroutine the handler starts is beyond any middleware, as with Sentry.
- Go 1.22+. Route symbols need 1.23+.
Generated from sdk/server/go/README.md, the README the install test follows.