Stick attributes on a context.Context, get them on every log line under that context. No threading request_id through nine function signatures to get it onto one log call at the bottom.
Stdlib plus ctxerrors. That's the whole dependency list, and it stays that way — see why.
Status: active. Extracted from common-go and stable — the API has not changed since, only the package name.
- What the fuck does it do?
- Two tiers, and the difference matters
- Getting them onto the line — pick one
- Crossing a process boundary
- Wiring it at the edges
- Why it imports nothing
- The full surface
- Design notes
- Was this in common-go?
- Dev
- License
You set an attribute once, at the boundary:
ctx = ctxscope.Set(ctx, ctxscope.Attr("request_id", requestID))Every log line emitted under that ctx — anywhere, however deep — carries request_id. Nothing in between has to know it exists.
go get github.com/psyb0t/ctxscope| call | for | crosses a process hop? |
|---|---|---|
SetGlobal |
commit, service, region — facts about the binary | never |
Set |
request_id, user_id — facts about the work | yes, via ToJSON/FromJSON |
Putting a process fact in Set's tier isn't a style slip, it's a bug: it would ride along to the next service and overwrite that service's own value, and now its logs name the wrong deploy. The tiers are split precisely so that can't happen.
Both get merged when a line is logged. The context tier wins collisions.
Install it once at startup and you're done:
base := slog.NewJSONHandler(os.Stdout, nil)
slog.SetDefault(slog.New(ctxscope.NewHandler(base)))Now plain slog works:
slog.InfoContext(ctx, "order placed", "order_id", id)
// {"level":"INFO","msg":"order placed","order_id":"x","request_id":"abc","service":"api"}This is the one nobody can forget, and the only one that reaches code which has never heard of this package — a library logging through slog.InfoContext gets your request_id for free.
Use the Context-suffixed calls. slog.Info hands the handler a background context, so the line still gets the global tier — that never came from a context anyway — but none of the per-context tier. Your service shows up, your request_id doesn't. That's slog's contract, not ours.
Or skip the handler and pull a logger with the attributes already baked on:
logger := ctxscope.GetLogger(ctx)
logger.Info("order placed", "order_id", id)Call it where you log, not once at the top — a logger is a value, so one fetched before a later Set doesn't have what you added.
These two are alternatives, not layers. GetLogger applies the scope itself, so calling it under an installed handler emits every attribute twice. Pick one per project.
A *slog.Logger can't cross a process boundary. Data can — which is the entire reason the scope is a map:
data, err := ctxscope.ToJSON(ctx) // outbound; context tier only, never globals
ctx, err := ctxscope.FromJSON(ctx, data) // inbound, far sideOne call re-seeds the whole map — not a Set per key. Works for an HTTP header, a NATS message header, a Temporal ContextPropagator, or a subprocess env var.
Two things worth knowing before they surprise you:
ToJSONserializes the context tier only. The receiving process keeps its owncommit/service. That's the point of the split.- JSON has one number type, so an int sent as
42comes back asfloat64(42). Fine for log and wire material; a trap only if you type-assert it.
Set attributes once where work enters the process. Everything downstream inherits them.
HTTP server — stamp the request id, then hand the enriched context onward:
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = newID()
}
ctx := ctxscope.Set(r.Context(), ctxscope.Attr("request_id", id))
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Outbound to a queue — the map goes on the wire, not the logger:
data, err := ctxscope.ToJSON(ctx)
if err != nil {
return ctxerrors.Wrap(err, "marshal scope")
}
msg.Header.Set("x-scope", string(data))Receiving side — re-seed the whole map in one call, and don't drop the message if it's malformed:
ctx, err := ctxscope.FromJSON(context.Background(), []byte(msg.Header.Get("x-scope")))
if err != nil {
ctx = context.Background() // a bad header is not a reason to lose the work
}Startup — process facts go in the global tier, so they never travel:
ctxscope.SetGlobal(
ctxscope.Attr("service", "api"),
ctxscope.Attr("commit", commitSHA),
)Transport adapters — the Temporal propagator, the NATS injector, HTTP middleware — live next to their transport and depend on this package. Never the other way around.
If this package imported the Temporal SDK, every consumer would drag a workflow engine in behind it. Stdlib-only means importing it costs nothing, and that's a constraint, not a coincidence.
| function | does |
|---|---|
Set(ctx, ...Attribute) context.Context |
add attributes to the context tier |
Remove(ctx, ...string) context.Context |
drop keys from it |
Get(ctx) Scope |
read it back, as a copy |
SetGlobal(...Attribute) |
add to the process tier |
RemoveGlobal(...string) |
drop from it |
GetGlobal() Scope |
read it back, as a copy |
GetLogger(ctx) *slog.Logger |
a logger with both tiers applied |
NewHandler(slog.Handler) *Handler |
the handler alternative to GetLogger |
ToJSON(ctx) ([]byte, error) |
context tier out to the wire |
FromJSON(ctx, []byte) (context.Context, error) |
and back in on the far side |
Attr[T Value](key, value) Attribute |
build one attribute |
Four exported types: Handler (implements slog.Handler), Scope (map[string]any), Attribute, Value (the type constraint).
That is the entire exported API. If you're reaching for a helper not listed above, it doesn't exist.
Three properties worth knowing:
Attris generic over strings, bools, ints and floats. Anything wider has no sane rendering as either a log attribute or JSON, so it won't compile. The constraint sits onAttrrather than onAttribute's field because a Go constraint interface can't be used as a field type, and one variadic call can't mixAttribute[string]withAttribute[int].GetandGetGlobalhand back copies. Mutating what you get back cannot corrupt the context or the process tier.- Concurrency is handled. The global tier is an atomic pointer to an immutable map — readers never lock, writers copy-and-swap. The context tier needs no locking at all: a
context.Contextis immutable, soSetreturns a new one rather than mutating.
A few decisions that look arbitrary until they aren't:
- The map is the only state; the logger is derived when you ask. Writing a logger onto the context instead would make
Removeimpossible — slog has no way to un-Withan attribute — and setting a key twice would emit it twice. - Scope attributes land at the record's top level, even under
WithGroup. Arequest_idnested inside a group is not therequest_idyour log queries match on.NewHandlerreplaysWithAttrs/WithGroupcalls after applying the scope to make that true. - Attributes are sorted by key, so field order is stable across lines and diffs cleanly.
Yes — this was github.com/psyb0t/common-go/scope. It moved out because it's a foundational primitive that shouldn't share a release cadence with a module that also carries gorm, echo, NATS and the Temporal SDK.
The API is unchanged apart from the package name, plus the new handler:
// before
import "github.com/psyb0t/common-go/scope"
scope.Set(ctx, scope.Attr("request_id", id))
// after
import "github.com/psyb0t/ctxscope"
ctxscope.Set(ctx, ctxscope.Attr("request_id", id))make test # go test -race ./...
make test-coverage # + coverage gate
make lint-fix # go fix + golangci-lint --fixmake help lists the rest.
MIT. See LICENSE.
See CHANGELOG.md for release notes.