Most application logs are high-volume but low-context.
unolog helps Go services emit one structured, canonical event per request, so debugging and analysis start from a complete record instead of scattered lines.
Formerly happycontext. Pre-1.0 releases remain available under the old
github.com/happytoolin/happycontextmodule path; this repository is their renamed continuation. SeeMIGRATION.md.
- Cleaner logs with one canonical event per request
- Consistent fields across handlers, middleware, and frameworks
- Built-in sampling for healthy traffic
- Error and panic events are always preserved
- Works with
slog,zap, andzerolog - Integrates with
net/http,gin,echo,fiber,fiber v3, and worker jobs
Design principle:
go get github.com/happytoolin/unolog
go get github.com/happytoolin/unolog/adapter/slog
go get github.com/happytoolin/unolog/integration/stdInstall only the adapter and integration packages you use. The modules need
Go 1.25 or later, except integration/gin, integration/echo, and
integration/fiberv3, which need Go 1.26 because their dependencies do.
Install the repository's unolog skill in a supported coding agent:
npx skills add happytoolin/unolog --skill unologThen ask the agent to use $unolog when it adds or reviews request and job logging in a Go service.
Compile the runtime once, wrap the handler, annotate with unolog.Add:
package main
import (
"errors"
"log/slog"
"net/http"
"os"
"github.com/happytoolin/unolog"
uslog "github.com/happytoolin/unolog/adapter/slog"
"github.com/happytoolin/unolog/integration/std"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := uslog.New(logger)
rt := unolog.MustCompile(unolog.Config{
Sink: sink,
SamplingRate: 1.0,
})
mw := std.Middleware(rt)
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
unolog.Add(r.Context(), "user_id", "u_8472", "feature", "checkout")
if r.URL.Query().Get("fail") == "1" {
unolog.SetMessage(r.Context(), "checkout_failed")
unolog.Error(r.Context(), errors.New("checkout failed"))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
unolog.SetMessage(r.Context(), "checkout_succeeded")
w.WriteHeader(http.StatusOK)
})
_ = http.ListenAndServe(":8080", mw(mux))
}Other quick starts:
net/http + zapandnet/http + zerologare in## More Examplesgin,echo,fiber v2, andfiber v3(withslog) are in## More Examples- Runnable reference apps are in
cmd/examples - Zero-dependency output:
unolog.NewJSONSink(os.Stdout)needs no logger at all
func runImport(ctx context.Context, rt *unolog.Runtime) (err error) {
op := unolog.Start(ctx, rt, unolog.OperationStart{
Domain: unolog.DomainJob,
Name: "import",
ID: "job_8472",
Attempt: 2,
MaxAttempts: 3,
})
defer op.End(&err) // captures errors AND panics; re-panics
unolog.Add(op.Context(), "rows", 42, "source", "queue")
return doImport(ctx)
}One event, always: header fields, handler fields in attach order, and completion fields — deterministic by construction. Failures are never sampled away.
Compile once at startup; *unolog.Runtime is immutable and shared by all
requests. Invalid configuration is a construction-time error
(unolog.ErrInvalidRate, unolog.ErrInvalidLevel, unolog.ErrInvalidOutcome) —
use unolog.Compile for config from files, unolog.MustCompile for literals.
unolog.Config gives you the core controls:
Sink: destination logger adapter (required to emit events)SamplingRate:0drops healthy events,1keeps all healthy eventsLevelSamplingRates: optional level-specific sampling overridesSampler: optional custom sampling function (full control)OperationPolicies: optional per-domain level/sampling policy for all lifecycle domains, including HTTP and background operations; domain sampling overrides generic level/default sampling- Precedence when both are set: a domain policy's
SamplingRateoverridesLevelSamplingRatesfor that domain (v0 behavior);LevelSamplingRatesoverrides the globalSamplingRate Message: final log message (defaults tounolog.DefaultMessagefor HTTP andunolog.DefaultOperationMessagefor non-HTTP)MaxFields: fields one event can hold (defaultunolog.DefaultMaxFields, 256;-1removes the limit)MaxValueBytes: bytes per string value (defaultunolog.DefaultMaxValueBytes, 32 KiB;-1removes the limit)ErrorHandler: receives contained sink and sampler panics and failed sink writes (default: stderr, at most once per second)
Notes:
- Sampling is automatically bypassed for errors and server failures.
- If no sink is configured, requests still run; logging is skipped.
- Sampling behavior is consistent across all integrations (
net/http,gin,echo,fiber, andfiber v3). unolog.SetMessage(ctx, "...")overridesConfig.Messagefor a single event.
The defaults bound the two common ways an event grows, so a loop or a large
value cannot make one request hold unbounded memory or write an unbounded
line. They do not keep a line under every pipeline limit: an event at both
defaults can encode to megabytes, and Docker's json-file log driver, for
example, splits lines at 16 KiB. Set the limits to match your pipeline:
- Field limit.
MaxFieldsbounds the distinct keys of an event. Repeated writes to one key do not use it up: when the event is full, a write to a key the event holds replaces its value in place, and a write with a new key first removes the older writes of repeated keys. A new key that still does not fit is dropped and counted inunolog.dropped_fields. Canonical keys (theunolog.Key*constants, such ashttp.status,op.outcome,error, andpanic, except the twounolog.*counter keys) are never dropped. Other keys with the same prefix, such ashttp.user_agent, are ordinary keys. - Value limit.
MaxValueBytesbounds string values: string fields, the string members of amap[string]anyfield (so the structurederrorandpanicfields), and theSetMessagetext. A longer value is cut at a UTF-8 boundary and counted inunolog.truncated_values. The text of an error value added withAddis cut when the canonical line is encoded (JSONSink,AsyncJSONSink, the zerolog adapter'sNewCanonical, andRecord.Encoded); that cut is not counted. The slog and zap adapters and the zerolog adapter'sNewwrite the error text without the cut. - Not covered. Field keys, nested maps, slices, structs, other
anyvalues, and theOperationStartfields are not limited. Keep them small, or convert them to strings before you add them.
The two counter fields appear only when a limit changed the event. Treat them as a signal to fix the code or to raise the limit. A negative limit removes that limit.
Logging never crashes the request. A panicking Sink or Sampler does not
escape End: unolog contains it and reports a *unolog.PanicError (the
recovered value and its stack; errors.Is matches unolog.ErrSinkPanic or
unolog.ErrSamplerPanic) to ErrorHandler. A panic in ErrorHandler itself
is recovered, and the report goes to stderr. A sampler panic keeps the event.
A sink panic makes End return false, but delivery can be partial: a sink
that writes to several destinations may have written to some of them before
it panicked, so do not retry the write. The application's own panic still
re-panics with its original value. A failed or short write from JSONSink
(or any sink that implements unolog.FallibleSink) also goes to
ErrorHandler, matching unolog.ErrSinkWrite and the writer's error, and
End returns false. After a short write, JSONSink and AsyncJSONSink
write a newline, so the next line starts on its own line. A type that
embeds a FallibleSink and overrides Write must also override WriteErr,
or End calls the embedded WriteErr and skips the override. A type that
embeds *unolog.JSONSink is an ordinary sink: End calls its Write.
rt, err := unolog.Compile(unolog.Config{
Sink: sink,
SamplingRate: 1,
MaxValueBytes: 8 << 10, // match an 8 KiB pipeline field limit
ErrorHandler: func(err error) {
internalErrors.Inc() // export as a metric instead of stderr
},
})Use unolog.SetMessage when a route or handler should emit a more specific final message than the integration-wide default:
func checkoutHandler(w http.ResponseWriter, r *http.Request) {
if err := processCheckout(r.Context()); err != nil {
unolog.SetMessage(r.Context(), "checkout_failed")
unolog.Error(r.Context(), err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
unolog.SetMessage(r.Context(), "checkout_succeeded")
w.WriteHeader(http.StatusOK)
}Passing an empty string leaves the event on the configured default message.
Errors are recorded as structured metadata:
{
"error": {
"message": "checkout failed",
"type": "*errors.errorString"
}
}Per-level sampling:
mw := std.Middleware(unolog.MustCompile(unolog.Config{
Sink: sink,
SamplingRate: 0.05, // default for healthy traffic
LevelSamplingRates: map[unolog.Level]float64{
unolog.LevelWarn: 1.0, // keep all warns
unolog.LevelDebug: 0.01,
},
}))Custom sampler (route/user/latency rules):
mw := std.Middleware(unolog.MustCompile(unolog.Config{
Sink: sink,
Sampler: func(in unolog.SampleInput) bool {
// Always keep failures and slow requests.
if in.HasError || in.StatusCode >= 500 {
return true
}
if in.Duration > 2*time.Second {
return true
}
// Keep checkout requests.
if in.Path == "/api/checkout" {
return true
}
// Keep enterprise requests based on event fields.
tier, _ := in.Lookup("user_tier")
return tier == "enterprise"
},
}))unolog.Add accepts one or more key/value pairs:
unolog.Add(ctx, "k1", v1, "k2", v2, "k3", v3).
Add boxes each value into an interface{}, which allocates for most
dynamic values. On hot paths, use typed fields instead: AddFields does not
allocate.
unolog.AddFields(ctx,
unolog.String("user_id", userID),
unolog.Int("items", len(cart.Items)),
unolog.Duration("db_time", dbTime),
)The constructors are String, Int, Int64, Uint64, Float64, Bool,
Time, Duration, and Any (the Add type mapping). Both forms produce the
same wire output.
unolog.SampleInput.Method, Path, and StatusCode are HTTP compatibility fields.
For non-HTTP operations use Domain, Operation, Outcome, and Code.
Built-in sampler chain:
mw := std.Middleware(unolog.MustCompile(unolog.Config{
Sink: sink,
Sampler: unolog.ChainSampler(
unolog.RateSampler(0.05), // base sampler
unolog.KeepErrors(), // always keep errors
unolog.KeepPathPrefix("/admin"), // always keep admin paths
unolog.KeepSlowerThan(500*time.Millisecond),
),
}))Sampler building blocks:
unolog.ChainSampler(base, middlewares...): composes one finalSamplerfrom middleware rules.unolog.AlwaysSampler(): base sampler that keeps every event.unolog.NeverSampler(): base sampler that drops every event.unolog.RateSampler(rate): base probabilistic sampler (0drops all,1keeps all).unolog.KeepErrors(): middleware that keeps errored requests (HasErroror5xx).unolog.KeepPathPrefix("/checkout", "/admin"): middleware that keeps matching path prefixes.unolog.KeepSlowerThan(minDuration): middleware that keeps requests at/above a duration threshold.
For non-HTTP flows, use unolog.Start with the compiled runtime:
func runJob(ctx context.Context, rt *unolog.Runtime) (err error) {
op := unolog.Start(ctx, rt, unolog.OperationStart{
Domain: unolog.DomainJob,
Name: "invoice.reconcile",
ID: "job_1001",
Source: "nightly",
})
defer op.End(&err) // direct defer: captures errors and panics
unolog.Add(op.Context(), "account_id", "acct_42")
return nil
}op.End(&err) is the only completion path: one-shot, returning whether
the event was emitted. The worker integration wraps this idiom for
queue consumers.
integration/std(net/http)integration/ginintegration/echointegration/fiber(Fiber v2)integration/fiberv3(Fiber v3)integration/worker(background jobs/non-HTTP operations)
adapter/slogadapter/zapadapter/zerolog
Adapters expose New. The zerolog adapter also exposes
NewWithLoggerTimestamp for loggers configured with
.With().Timestamp(), because zerolog does not expose hook inspection.
NewCanonical writes the canonical line directly when zerolog context,
hooks, sampling, and rendering customization are not needed.
Fields arrive in insertion order, deterministically, as typed
constructors.
A user field whose key the logger writes itself is renamed to
fields.<key>, as the canonical line renames message, time, and
level, so no output has two members with one key. The slog adapter
renames time, level, and msg. It does not rename source: the
handler writes its own source only with AddSource, so do not use a
source field with AddSource. The zap adapter's New
renames the keys of zap.NewProductionEncoderConfig() (ts, level,
logger, caller, msg, stacktrace); for an encoder with other keys,
use uzap.NewWithEncoderConfig(logger, encoderConfig).
unolog.NewJSONSink(w io.Writer) emits the same canonical event shape as the
zerolog adapter — lowercase level, RFC3339 time, your fields, message
last — as one JSON line per event, with zero dependencies beyond the
standard library (the module's only non-stdlib require is
go.uber.org/goleak — a test-only dependency used by the package's
goroutine-leak check, never linked into consumers):
sink := unolog.NewJSONSink(os.Stdout)The wire format matches zerolog.New(w).With().Timestamp().Logger()
through adapter/zerolog, so existing pipelines ingest it unchanged.
Field order is insertion order — deterministic by construction, identical across every sink.
A failed or short write goes to Config.ErrorHandler, and End returns
false.
JSONSink writes on the request goroutine, so a slow writer (a pipe, a
network connection, a busy disk) makes every request wait.
unolog.NewAsyncJSONSink writes the same lines from one background
goroutine: the request only copies its line into a buffer.
sink := unolog.NewAsyncJSONSink(os.Stdout, unolog.AsyncJSONSinkConfig{
BufferBytes: 1 << 20, // the default; memory use is at most twice this
})
defer sink.Close() // writes the remaining lines and stops the goroutineThe sink never blocks a request. When the buffer is full, it drops the
line, counts it in sink.Dropped(), and reports the drops to its
ErrorHandler (unolog.ErrSinkFull). Write errors and writer panics go to
the same handler. The handler runs on the writer goroutine, so it must not
call Close, or Flush with a context that is never done: both wait for
that goroutine. Flush(ctx) waits until the buffered lines are written.
Use JSONSink instead when every line must be written.
Per-line loggers charge you per line: format, allocate, write — on every
request, whether the line is kept or not. unolog has a different cost shape.
Fields accumulate in a typed write-ahead log while the request runs; one record
is materialized at End, sampled once, and handed to every sink. Nothing is
formatted per line, and a sampled-out request never builds a record at all.
The route benchmark runs the same checkout flow across net/http, Gin, Echo,
Fiber v2, and Fiber v3 with slog, Zap, and Zerolog. The workload uses 95%
successful requests and 5% failed requests.
| 95/5 request mix | unolog vs direct logging |
|---|---|
| Request time | 32% faster |
| OS log writes | 1.00 vs 5.95 per request |
| Log output | 50% fewer bytes |
| Heap bytes | 39% more |
| Allocations | 32% more |
These values are the geometric mean across all 15 router and logger pairs.
They use the median of 1,000 samples per case on an Apple M4 with Go 1.27.
Each sample has a 10 ms measurement window. Each JSON event is encoded and
written to the OS null device, so the benchmark includes system call cost but
excludes disk and network delay. Error-only routes were 2.1%
slower overall because structured error data costs more to build. See the
BenchmarkRealisticRoutes
source for the full matrix.
Logging the same 12 fields to a discarded output — each logger alone, and the same logger end to end through unolog (Apple M4 / Go 1.27, medians of 25 runs in one session):
| Logger | alone | + unolog | added | allocs |
|---|---|---|---|---|
slog JSON |
866 ns | 1611 ns | +745 ns (+86%) | 1 → 8 |
zap JSON |
667 ns | 1415 ns | +748 ns (+112%) | 1 → 8 |
zerolog canonical output |
202 ns | 748 ns | +546 ns (+270%) | 0 → 3 |
That added slice is the entire unolog cost — Start, 12 Adds, one sampling
decision, bridge, encode — and it buys a single reusable record that every sink
and adapter shares. Your logger keeps doing its own work; unolog just never
formats per line. With routing in the picture the shape flips: one unolog event
through the std middleware costs 362 ns, less than one slog JSON line at
504 ns, and a sampled-out empty lifecycle costs 204 ns with two allocations.
Compare pairs within a session: host-logger floors drift a few percent between recordings, so re-measure both rows together rather than mixing runs.
Then the unolog paths alone (Start → Add ×12 → End → sink, no router):
| Path | ns/op | allocs/op |
|---|---|---|
| core only, discard sink | 400 | 2 |
| first-party JSON sink, 12 fields | 746 | 3 |
| zerolog canonical output, 12 fields | 748 | 3 |
| zap adapter, 12 fields | 1415 | 8 |
| slog adapter, 12 fields | 1611 | 8 |
What that means:
- The core pipeline is ~0.20–0.40 µs with two allocations; everything to its right is the sink or host logger doing its own work.
NewCanonicalpreserves the measured zerolog row without private logger access.Newuses zerolog's public field API, honors its customization, and costs about 0.2 µs more end to end while removing one lifecycle allocation.- A sampled-out empty lifecycle costs 204 ns and two allocations — less than most loggers spend formatting a single line.
- Sampling runs before any sink work, and errors/panics bypass sampling structurally — the cheap path can never hide a failure.
- Twelve fields ride on one typed WAL slice:
Adddoes not box per field, the record is encoded once, and every sink and adapter reads that same record. - Adapter rows include the bridge and host logger allocations; they are end-to-end totals.
Run the suites yourself:
just bench # everything
just bench-core # lifecycle, sampler, WAL, encoder
just bench-routers # std, gin, echo, fiber, fiberv3
just bench-adapters # slog, zap, zerolog bridgesThe head-to-head runs the logger directly in the handler and unolog through its
std middleware on the same route with the same fields, both discarding output —
no strawman baselines. just bench reproduces every suite.
1. net/http + slog
package main
import (
"log/slog"
"net/http"
"os"
"github.com/happytoolin/unolog"
uslog "github.com/happytoolin/unolog/adapter/slog"
"github.com/happytoolin/unolog/integration/std"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := uslog.New(logger)
mw := std.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1}))
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
unolog.Add(r.Context(), "router", "net/http")
w.WriteHeader(http.StatusOK)
})
_ = http.ListenAndServe(":8101", mw(mux))
}2. gin + slog
package main
import (
"log/slog"
"os"
"github.com/gin-gonic/gin"
"github.com/happytoolin/unolog"
uslog "github.com/happytoolin/unolog/adapter/slog"
ugin "github.com/happytoolin/unolog/integration/gin"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := uslog.New(logger)
r := gin.New()
r.Use(ugin.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
r.GET("/users/:id", func(c *gin.Context) {
unolog.Add(c.Request.Context(), "router", "gin")
c.Status(200)
})
_ = r.Run(":8105")
}3. fiber v2 + slog
package main
import (
"log/slog"
"os"
"github.com/gofiber/fiber/v2"
"github.com/happytoolin/unolog"
uslog "github.com/happytoolin/unolog/adapter/slog"
ufiber "github.com/happytoolin/unolog/integration/fiber"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := uslog.New(logger)
app := fiber.New()
app.Use(ufiber.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
app.Get("/users/:id", func(c *fiber.Ctx) error {
unolog.Add(c.UserContext(), "router", "fiber-v2")
return c.SendStatus(200)
})
_ = app.Listen(":8107")
}4. fiber v3 + slog
package main
import (
"log/slog"
"os"
"github.com/gofiber/fiber/v3"
"github.com/happytoolin/unolog"
uslog "github.com/happytoolin/unolog/adapter/slog"
ufiberv3 "github.com/happytoolin/unolog/integration/fiberv3"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := uslog.New(logger)
app := fiber.New()
app.Use(ufiberv3.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
app.Get("/users/:id", func(c fiber.Ctx) error {
unolog.Add(c.Context(), "router", "fiber-v3")
return c.SendStatus(200)
})
_ = app.Listen(":8108")
}5. echo + slog
package main
import (
"log/slog"
"os"
"github.com/happytoolin/unolog"
uslog "github.com/happytoolin/unolog/adapter/slog"
uecho "github.com/happytoolin/unolog/integration/echo"
"github.com/labstack/echo/v4"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := uslog.New(logger)
e := echo.New()
e.Use(uecho.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
e.GET("/users/:id", func(c echo.Context) error {
unolog.Add(c.Request().Context(), "router", "echo")
return c.NoContent(200)
})
_ = e.Start(":8106")
}6. net/http + zap
package main
import (
"net/http"
"github.com/happytoolin/unolog"
uzap "github.com/happytoolin/unolog/adapter/zap"
"github.com/happytoolin/unolog/integration/std"
"go.uber.org/zap"
)
func main() {
logger := zap.NewExample()
sink := uzap.New(logger)
mw := std.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1}))
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
unolog.Add(r.Context(), "example", "adapter-zap")
w.WriteHeader(http.StatusOK)
})
_ = http.ListenAndServe(":8102", mw(mux))
}7. net/http + zerolog
package main
import (
"net/http"
"os"
"github.com/happytoolin/unolog"
uzerolog "github.com/happytoolin/unolog/adapter/zerolog"
"github.com/happytoolin/unolog/integration/std"
"github.com/rs/zerolog"
)
func main() {
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
sink := uzerolog.NewWithLoggerTimestamp(&logger)
mw := std.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1}))
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
unolog.Add(r.Context(), "example", "adapter-zerolog")
w.WriteHeader(http.StatusOK)
})
_ = http.ListenAndServe(":8103", mw(mux))
}Runnable commands are also available in cmd/examples:
cd cmd/examples
go run ./adapter-slog
go run ./adapter-zap
go run ./adapter-zerolog
go run ./router-std
go run ./router-gin
go run ./router-echo
go run ./router-fiber
go run ./router-fiberv3
go run ./sampling-inbuilt
go run ./sampling-custom
go run ./worker-job- CI:
.github/workflows/ci.yml - Release automation:
.github/workflows/release.yml - Go proxy sync:
.github/workflows/go-proxy-sync.yml - Root module releases must be tagged as
vX.Y.Z. - Nested Go modules must be tagged as
<subdir>/vX.Y.Zsogo list -m -versionscan discover them. - To backfill historical tags created with the old
unolog-vX.Y.Zformat, run./scripts/backfill-go-tags.shand push the generated tags.
Published nested modules:
adapter/slogadapter/zapadapter/zerologintegration/echointegration/fiberintegration/fiberv3integration/ginintegration/stdintegration/worker
Coming from a 0.x release? MIGRATION.md maps every
removed or changed symbol, the outcome-precedence change, and the wire
format differences.
- Framing inspiration: "Logging Sucks - Your Logs Are Lying To You" by Boris Tane: https://loggingsucks.com/
- See
CONTRIBUTING.mdbefore you open a pull request. - Report vulnerabilities as described in
SECURITY.md.
MIT