ctxscope

package module
v1.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 8, 2026 License: MIT Imports: 7 Imported by: 0

README

ctxscope

Go Reference CI coverage version license imported by

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.

Contents

What the fuck does it do?

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

Two tiers, and the difference matters

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.

Getting them onto the line — pick one

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.

GetLogger

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.

Crossing a process boundary

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 side

One 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:

  • ToJSON serializes the context tier only. The receiving process keeps its own commit/service. That's the point of the split.
  • JSON has one number type, so an int sent as 42 comes back as float64(42). Fine for log and wire material; a trap only if you type-assert it.

Wiring it at the edges

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),
)

Why it imports nothing

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.

The full surface

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:

  • Attr is 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 on Attr rather than on Attribute's field because a Go constraint interface can't be used as a field type, and one variadic call can't mix Attribute[string] with Attribute[int].
  • Get and GetGlobal hand 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.Context is immutable, so Set returns a new one rather than mutating.

Design notes

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 Remove impossible — slog has no way to un-With an attribute — and setting a key twice would emit it twice.
  • 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. NewHandler replays WithAttrs/WithGroup calls after applying the scope to make that true.
  • Attributes are sorted by key, so field order is stable across lines and diffs cleanly.

Was this in common-go?

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))

Dev

make test           # go test -race ./...
make test-coverage  # + coverage gate
make lint-fix       # go fix + golangci-lint --fix

make help lists the rest.

License

MIT. See LICENSE.

See CHANGELOG.md for release notes.

Documentation

Overview

Package ctxscope carries attributes on a context.Context and stamps them onto every line logged under that context, so the values travel without anyone passing them around.

Two tiers, split by whether an attribute describes the PROCESS or the WORK:

SetGlobal   commit, service, region   never travels
Set         request_id, user_id       travels, via ToJSON/FromJSON

Putting a process fact in Set's tier is a bug rather than a style slip: it would cross a hop and overwrite the receiving service's own value, whose logs would then name the wrong deploy.

There are two ways to get the attributes onto a line. Install NewHandler once at startup and plain slog.InfoContext(ctx, ...) carries them everywhere, including inside libraries that never heard of this package; or call GetLogger(ctx) at the log site. The handler is the one nobody can forget.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func FromJSON

func FromJSON(ctx context.Context, data []byte) (context.Context, error)

FromJSON returns a new context carrying ctx's scope plus what ToJSON wrote on the other side of a hop. Incoming keys win.

Numbers come back as float64, JSON having one number type, so an int sent as 42 returns 42 but is no longer an int. Only matters if you type-assert.

func GetLogger

func GetLogger(ctx context.Context) *slog.Logger

GetLogger returns slog.Default() with both tiers applied, sorted by key, the context tier winning collisions. The context carries attributes, never a logger — where output goes is slog's business, configured once at startup.

Call it where you log rather than holding the result: a logger is a value, so one fetched before a Set or Remove keeps the attributes it was built with.

func Remove

func Remove(ctx context.Context, keys ...string) context.Context

Remove returns a new context with the named keys dropped. Unset keys are ignored; no keys returns ctx unchanged.

func RemoveGlobal

func RemoveGlobal(keys ...string)

RemoveGlobal drops the named keys from the process-wide scope. Unset keys are ignored.

func Set

func Set(ctx context.Context, attrs ...Attribute) context.Context

Set returns a new context carrying ctx's scope plus every attribute given. A key already set is replaced; no attributes returns ctx unchanged.

ctx = scope.Set(ctx,
    scope.Attr("request_id", requestID),
    scope.Attr("user_id", userID),
)

func SetGlobal

func SetGlobal(attrs ...Attribute)

SetGlobal adds attributes to every line this process logs, under any context. Call it at startup, with the facts that describe the binary rather than the work — nothing set here is ever serialized by ToJSON.

scope.SetGlobal(
    scope.Attr("commit", commitSHA),
    scope.Attr("service", serviceName),
)

func ToJSON

func ToJSON(ctx context.Context) ([]byte, error)

ToJSON marshals ctx's scope for an outbound header, queue message or subprocess env. FromJSON reads it back on the far side. Empty marshals to {}.

Types

type Attribute

type Attribute struct {
	// contains filtered or unexported fields
}

Attribute is one key/value pair headed into a scope. The fields are unexported so Attr is the only way to build one, which is what keeps the Value constraint despite the field being an any — a variadic Set cannot hold mixed instantiations of a generic type, so the field has to be one.

func Attr

func Attr[T Value](key string, value T) Attribute

Attr builds a scope attribute, rejecting at compile time anything Value does not cover.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler stamps the scope carried by a context onto every record passing through it, then delegates to an inner handler.

Installing one at startup is what makes plain slog.InfoContext(ctx, ...) carry request_id — including from code that has never heard of this package, which GetLogger by definition cannot reach:

slog.SetDefault(slog.New(ctxscope.NewHandler(base)))

Handler and GetLogger are ALTERNATIVES, not layers. GetLogger applies the scope itself, so calling it under an installed Handler emits every attribute twice. Install the Handler and log with the Context-suffixed slog calls, or install nothing and go through GetLogger — one or the other, per project.

Only the Context-suffixed calls (InfoContext, ErrorContext, ...) carry a context to the handler. Plain slog.Info hands it a background context, so the line still gets the global tier — which never came from a context — but none of the per-context tier. That is slog's contract, not this package's choice.

func NewHandler

func NewHandler(inner slog.Handler) *Handler

NewHandler wraps inner so that records handled through it carry the scope of the context they were logged with.

func (*Handler) Enabled

func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool

Enabled reports whether inner handles this level. Scope never changes the answer — it adds attributes, it does not gate them.

func (*Handler) Handle

func (h *Handler) Handle(ctx context.Context, record slog.Record) error

Handle merges both tiers onto the record's handler, the context tier winning collisions, and delegates. The merge happens per record because the scope is read from the context at THIS moment — a handler built earlier still sees attributes set later.

func (*Handler) WithAttrs

func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler

WithAttrs records the call rather than applying it, so Handle can replay it after the scope has gone on. See the ops field.

func (*Handler) WithGroup

func (h *Handler) WithGroup(name string) slog.Handler

WithGroup records the call rather than applying it. This is the reason ops exists at all: a group opened here must not swallow the scope attributes.

type Scope

type Scope map[string]any

Scope is the attribute map carried on a context.

func Get

func Get(ctx context.Context) Scope

Get returns a copy of the scope carried by ctx, safe to mutate.

func GetGlobal

func GetGlobal() Scope

GetGlobal returns a copy of the process-wide scope, safe to mutate.

type Value

type Value interface {
	~string | ~bool |
		~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
		~float32 | ~float64
}

Value is what a scope attribute may hold. Values become slog attributes and JSON, and anything wider has no sane rendering in either.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL