One canonical event per request.
Built for Go services: gather context through middleware and handlers, then emit one structured request event through your existing logger.
One Event
Replace noisy middleware and handler logs with one request-scoped payload.
Full Context
Capture HTTP details, user metadata, and business data in one searchable record.
Router Scoped
Add fields as the request travels through net/http, gin, echo, or fiber.
Adapter Friendly
Keep your existing logger stack and switch only the sink integration.
Why I Built This
Most production logs are noisy but still missing context.
You can have thousands of lines and still not answer the basic question: what happened in this one request? That pain is exactly what posts like loggingsucks.com and evlog’s introduction to wide events call out.
I wanted a small Go library that keeps the good parts of request logging while reducing fragmentation.
happycontext does that by emitting one structured, request-scoped event at the end of each request lifecycle.
The core idea: one canonical event per request, with predictable fields and optional sampling.
What “Wide Logging” Means Here
Instead of emitting 6 to 20 lines through middleware and handlers, happycontext accumulates request context in-memory and writes one final event.
What are Wide Events?
Instead of scattering logs throughout your code:
Traditional logging
logger.info('Request started')
logger.Info("user authenticated", "user_id", userID)
logger.Info("fetching cart", "cart_id", cartID)
logger.Info("processing payment")
logger.Info("payment successful")
logger.Info("request completed")Wide Event (Go)
// server/api/checkout.post.ts
func checkout(w http.ResponseWriter, r *http.Request) {
hc.Add(r.Context(), "user_id", "u_8472", "feature", "checkout")
hc.Add(r.Context(), "cart_id", "c_42", "items", 3, "total_cents", 9999)
hc.Add(r.Context(), "payment_method", "card", "payment_status", "success")
w.WriteHeader(http.StatusOK)
}One log, all context, emitted once at request completion.
package main
import (
"errors"
"log/slog"
"net/http"
"os"
hc "github.com/happytoolin/happycontext"
slogadapter "github.com/happytoolin/happycontext/adapter/slog"
stdhc "github.com/happytoolin/happycontext/integration/std"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := slogadapter.New(logger)
mw := stdhc.Middleware(hc.Config{
Sink: sink,
SamplingRate: 1.0,
Message: "request_completed",
})
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
hc.Add(r.Context(), "user_id", "u_8472", "feature", "checkout")
if r.URL.Query().Get("fail") == "1" {
hc.Error(r.Context(), errors.New("checkout failed"))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
_ = http.ListenAndServe(":8080", mw(mux))
}Typical final fields include:
http.methodhttp.pathhttp.routehttp.statusduration_ms- your custom fields like
user_id,tenant_id,feature,trace_id
This structure makes filtering and alerting much easier in Loki, Datadog, ELK, or any JSON log pipeline.
Wide Events, in Practice
A good wide event usually combines four layers:
- Request context: method, path, route, request/trace IDs
- User context: who made the call (user, tenant, plan)
- Business context: cart/order/payment/domain fields
- Outcome: status, duration, and error details (if any)
In Go terms, the pattern is simple: add context incrementally with hc.Add(...) as you learn more during the request, and let middleware emit the final event once.
hc.Add(ctx, "user_id", userID, "tenant_id", tenantID)
hc.Add(ctx, "order_id", orderID, "total_cents", totalCents)
if err != nil {
hc.Error(ctx, err)
}Prefer clear key names and grouped domain fields over generic blobs. That keeps queries readable and makes incidents faster to debug.
For a deeper walkthrough of the wide-event model, see: evlog.dev/core-concepts/wide-events.
Quick Start (net/http + slog)
package main
import (
"errors"
"log/slog"
"net/http"
"os"
hc "github.com/happytoolin/happycontext"
slogadapter "github.com/happytoolin/happycontext/adapter/slog"
stdhc "github.com/happytoolin/happycontext/integration/std"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
sink := slogadapter.New(logger)
mw := stdhc.Middleware(hc.Config{
Sink: sink,
SamplingRate: 1.0,
Message: "request_completed",
})
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
hc.Add(r.Context(), "user_id", "u_8472", "feature", "checkout")
if r.URL.Query().Get("fail") == "1" {
hc.Error(r.Context(), errors.New("checkout failed"))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
_ = http.ListenAndServe(":8080", mw(mux))
}Integrations and Adapters
happycontext intentionally separates router middleware from logger adapters.
Router + Adapter Matrix
Router integrations
- integration/std (net/http)
- integration/gin
- integration/echo
- integration/fiber (v2)
- integration/fiberv3 (v3)
Logger adapters
- adapter/slog
- adapter/zap
- adapter/zerolog
15 combinations without rewriting domain handlers.
Router integrations
integration/std(net/http)integration/ginintegration/echointegration/fiber(Fiber v2)integration/fiberv3(Fiber v3)
Logger adapters
adapter/slogadapter/zapadapter/zerolog
That gives you 15 router/logger combinations without changing your domain handlers.
Start
Middleware captures method, path, route, and a request timestamp.
Enrich
Handlers add user, tenant, feature, and domain payload as context arrives.
Finalize
Status, duration, and error fields are computed once the request is done.
Emit
The adapter writes one canonical event to slog, zap, or zerolog.
Sampling Without Losing Incidents
Wide events are richer, so sampling strategy matters.
The built-in path keeps failures and 5xx responses, then applies sampling to healthy traffic. You can also provide your own sampler chain.
mw := stdhc.Middleware(hc.Config{
Sink: sink,
Sampler: hc.ChainSampler(
hc.RateSampler(0.05),
hc.KeepErrors(),
hc.KeepPathPrefix("/admin", "/checkout"),
hc.KeepSlowerThan(500*time.Millisecond),
),
})Tail Sampling Rules
Always keep
- 5xx status or panic
- error field is present
- /admin and /checkout paths
- slow requests above threshold
Sample healthy traffic
- Apply base rate to successful requests
- Use per-level overrides when needed
- Keep events query-friendly and compact
- Tune rates after dashboard validation
Keep incidents at 100%, control cost on happy-path traffic.
Benchmark Snapshot
From the benchmark report in the repo (Apple M4, February 10, 2026):
zerologadapter write (small): ~155 ns/op, 0 allocs/opzapadapter write (small): ~553 ns/op, 0 allocs/opslogadapter write (small): ~742 ns/op, 7 allocs/op- Standard-library router middleware (
net/http) with sink-noop: ~525 ns/op
Numbers will vary by machine and sink setup, but the trend is stable: adapters are lightweight and the middleware overhead is predictable.
Migration Plan for Existing Services
If your codebase already logs in many places, migrate in layers:
- Wrap one router with
happycontextmiddleware first. - Keep your existing logger and only add the matching sink adapter (
slog,zap, orzerolog). - Add only core fields at first:
request_id,user_id,tenant_id, andfeature. - Keep
SamplingRate=1.0for the first rollout so you can validate full payload shape. - Verify every request emits one canonical event with consistent key names.
- Add sampling rules only after dashboards and alerts are stable.
- Roll out integration-by-integration until all services follow the same schema.
Closing
happycontext is my attempt to make wide logging practical in Go without forcing a logger rewrite.
If you want to try it:
go get github.com/happytoolin/happycontext
go get github.com/happytoolin/happycontext/adapter/slog
go get github.com/happytoolin/happycontext/integration/stdRepo: github.com/happytoolin/happycontext
Related Articles
Best Dockerfile for Golang, Optimize Your Dockerfile
Create best Dockerfile for Golang, optimize your Dockerfile for Golang and make it blazingly fast! 🔥
postgresPostgreSQL UUIDv7 Performance Benchmark: Native vs Custom Implementations
Comprehensive analysis of modern time-ordered identifiers in PostgreSQL: UUIDv7, ULID, and TypeID implementations with real performance benchmarks, PostgreSQL 18 native support, and practical recommendations for choosing the right identifier for your project.