ctxscope: Stop Threading request_id Through Every Function Signature

Here’s the code everybody writes. A request comes in, you generate an id, and something a few calls down needs to log it:

func handleOrder(ctx context.Context, requestID string, o Order) error {
	return chargeCard(ctx, requestID, o)
}
func chargeCard(ctx context.Context, requestID string, o Order) error {
	return callGateway(ctx, requestID, o.Amount)
}
func callGateway(ctx context.Context, requestID string, amount int) error {
	slog.Info("charging", "request_id", requestID, "amount", amount)
	...
}

Two of those three functions have no interest in requestID whatsoever. They carry it because the bottom one prints it. Add user_id next month and you touch all three again.
The other version of this mistake is stashing a *slog.Logger on the context, which works right up until one layer forgets to pull it out, uses the package-level slog.Info, and emits a line with nothing attached — silently, and only in the path nobody tested.
ctxscope deletes the parameter. You set the attribute once, where the work enters:

ctx = ctxscope.Set(ctx, ctxscope.Attr("request_id", requestID))

and the signatures go back to what they were about:

func handleOrder(ctx context.Context, o Order) error
func chargeCard(ctx context.Context, o Order) error
func callGateway(ctx context.Context, amount int) error

What actually comes out

That’s the pitch; here’s the proof. Set a build id once at startup and four attributes at the request boundary:

ctxscope.SetGlobal(ctxscope.Attr("commit", "deadbeef"))
ctx = ctxscope.Set(ctx,
	ctxscope.Attr("request_id", "req-01HXR9"),
	ctxscope.Attr("user_id", 42),
	ctxscope.Attr("retry", true),
	ctxscope.Attr("upload_ratio", 0.75),
)

Then log from anywhere underneath, passing nothing:

ctxscope.GetLogger(ctx).Info("request received")
// msg="request received" commit=deadbeef request_id=req-01HXR9
//     retry=true upload_ratio=0.75 user_id=42

Deeper still, a function overwrites one key and drops another — no plumbing, just the context it was handed:

ctx = ctxscope.Set(ctx, ctxscope.Attr("retry", false))  // replaced, not duplicated
ctx = ctxscope.Remove(ctx, "upload_ratio")              // gone from here down
ctxscope.GetLogger(ctx).Debug("working")
// msg=working commit=deadbeef request_id=req-01HXR9 retry=false user_id=42

Setting a key twice replaces it rather than emitting it twice, because the attributes live in a map and get applied once at read time. And logger.With still does what it always did, for the transient stuff that has no business propagating:

logger := ctxscope.GetLogger(ctx).With("attachment", "avatar.png")
logger.Warn("attachment skipped")
// ...attachment=avatar.png  — and NOT in ctxscope.Get(ctx)

That whole sequence is .example/main.go in the repo — go run ./.example and you get those lines.

Two ways onto the line, and you must pick exactly one

Above I used GetLogger because it’s explicit. The other way installs a handler once at startup and then you never mention the package again:

base := slog.NewJSONHandler(os.Stdout, nil)
slog.SetDefault(slog.New(ctxscope.NewHandler(base)))
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 to reach for. It’s the one nobody can forget, and it’s the only one that reaches code which has never heard of ctxscope — a third-party library logging through slog.InfoContext picks up your request_id for free.
They are alternatives, not layers. GetLogger applies the scope itself, so calling it underneath an installed handler prints every attribute twice. Pick one per project.
Two more things that bite, in descending order of how long they’ll cost you:

  • Use the Context-suffixed calls. slog.Info hands the handler a background context, so the line still gets your global tier — that never came from a context — but none of the per-context one. Your service appears, your request_id silently doesn’t. That’s slog’s contract, not this package’s.
  • If you use GetLogger, call it where you log, not once at the top of the function. A logger is a value; one fetched before a later Set doesn’t have what you added.

Two tiers, and putting a value in the wrong one is a bug

You’ve now seen both Set and SetGlobal. The difference isn’t cosmetic:

SetGlobal   commit, service, region   facts about the BINARY   never travels
Set         request_id, user_id       facts about the WORK     travels

Put service in the context tier and it rides to the next service on the next hop and overwrites that service’s own value. Its logs now name your deploy. You find out during an incident, reading logs that confidently lie about which binary produced them. The split exists so that can’t happen.
Both tiers merge when a line is written; the context tier wins collisions.

A logger can’t cross a process boundary. A map can.

That’s the whole reason the state is data rather than a logger:

data, _ := ctxscope.ToJSON(ctx)
// {"request_id":"req-01HXR9","retry":false,"user_id":42}
//  ...note what is NOT there: commit stayed behind
ctx, err := ctxscope.FromJSON(ctx, data)   // far side, one call, whole map

One call re-seeds everything — not a Set per key. Same bytes work for an HTTP header, a NATS message header, a Temporal ContextPropagator, or a subprocess env var.
Two gotchas. ToJSON serializes the context tier only, so the receiving process keeps its own commit — the tier split doing its job. And JSON has one number type, so an int sent as 42 returns as float64(42): fine as log or wire material, a trap only if you type-assert it.
On the receiving side, don’t lose work over a bad header:

ctx, err := ctxscope.FromJSON(context.Background(), []byte(msg.Header.Get("x-scope")))
if err != nil {
	ctx = context.Background() // a malformed header is not a reason to drop the message
}

The entire API

Eleven functions, and you’ve already met six of them:

Set / Remove / Get                context tier
SetGlobal / RemoveGlobal / GetGlobal   process tier
GetLogger / NewHandler            get it onto a line
ToJSON / FromJSON                 get it across a hop
Attr                              build one attribute

Four types: Handler, Scope, Attribute, Value. If you’re reaching for a helper that isn’t in that list, it doesn’t exist, deliberately.
Attr is generic over strings, bools, ints and floats and nothing wider — anything else has no sane rendering as a log attribute or as JSON, so it fails to compile instead of surprising you at runtime. Get and GetGlobal hand back copies, so mutating what you got can’t corrupt either tier. Attributes come out sorted by key, so field order is stable and diffs cleanly.

Three decisions worth knowing if you’re deciding whether to trust it

Scope attributes land at the record’s top level even under WithGroup. A request_id nested inside a group is not the request_id your log queries match on. The handler replays WithAttrs/WithGroup after applying the scope specifically to keep that true.
Concurrency needs almost no machinery. 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, because a context.Context is immutable and Set returns a derived one.
It imports nothing. Runtime dependencies: ctxerrors. That’s a constraint, not an accident — transport adapters like a Temporal propagator or a NATS injector depend on this package, never the reverse. If ctxscope imported the Temporal SDK, every consumer would drag a workflow engine in behind it just to put a string on a log line.
That last one is also why it left common-go, where it lived as common-go/scope: a foundational primitive shouldn’t share a release cadence with a module that also carries gorm, echo, NATS and the Temporal SDK. The API came across unchanged apart from the package name.


28 tests against a 90% floor, stdlib plus one dependency, eleven functions. Set it at the edge, delete the parameter from three signatures, and stop passing a string around so the bottom of the stack can print it.
github.com/psyb0t/ctxscope