← Back to the section

In languages like Java there is thread-local storage: put request_id at the start of a request and it's available in any method of that same thread without passing it explicitly. Go deliberately has no such mechanism. The only way to carry data through a call chain is context.Context, passed as the first argument of every function.

This looks verbose, but it gives precise control: observability fields propagate exactly where the programmer explicitly passed ctx.

What context.Context is

context.Context is an interface from the standard library. It has three jobs:

  • Cancellation: if the client closed the connection or a timeout expired, ctx.Done() is closed and work can be stopped.
  • Deadline: the time after which the work is pointless.
  • Values: small request metadata — request_id, user_id, the tracing span.

In an HTTP server every request already arrives with its own ctx via r.Context(). Middleware adds data to it, the handler reads it.

RequestID middleware — the entry point

The very first thing to do with an incoming request is to assign it an identifier. This lets you tie together all the logs and events of a single request, even if they're scattered across several services.

The middleware reads the X-Request-Id header (if the client sent it) or generates a new UUID, puts the value in ctx, and returns the header in the response — the client can reference it when reporting an incident.

// internal/platform/middleware/reqid.go
package middleware

import (
    "context"
    "net/http"

    "github.com/google/uuid"
)

type ctxKey string

const (
    CtxRequestID ctxKey = "request_id"
    CtxUserID    ctxKey = "user_id"
)

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 = uuid.NewString()
        }
        ctx := context.WithValue(r.Context(), CtxRequestID, id)
        w.Header().Set("X-Request-Id", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

This middleware is mounted first in the chain — before tracing and the logger — so that all subsequent records already see the request_id:

r := chi.NewRouter()
r.Use(middleware.RequestID)                 // first — before everything else
r.Use(otelhttp.Middleware("order-service")) // the OTel span sees request_id
r.Use(chiMW.Logger)                         // access log with request_id

trace_id and span_id — automatically via OTel

When OpenTelemetry is used, you don't need to put trace_id and span_id into ctx by hand. The OTel-slog bridge (go.opentelemetry.io/contrib/bridges/otelslog) does this automatically: it pulls the active span from ctx and adds its identifiers to every slog record.

If the bridge is not wired up, trace_id is read from the span once at the start of the handler and used to enrich the logger:

func enrichLog(ctx context.Context, log *slog.Logger) *slog.Logger {
    span := trace.SpanFromContext(ctx)
    sc := span.SpanContext()
    args := []any{}
    if sc.HasTraceID() {
        args = append(args, slog.String("trace_id", sc.TraceID().String()))
        args = append(args, slog.String("span_id", sc.SpanID().String()))
    }
    if id, ok := ctx.Value(CtxRequestID).(string); ok {
        args = append(args, slog.String("request_id", id))
    }
    return log.With(args...)
}

A common mistake is to put trace_id in manually via context.WithValue. This creates a duplicate: OTel already manages the span in ctx, and the manual value alongside it will go stale with nested spans.

userId after JWT validation

The user identifier appears in the context only after the token has been successfully verified. A separate Auth middleware handles this — it sits after RequestID but before the business routes:

// internal/platform/middleware/auth.go
func Auth(verifier TokenVerifier) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            claims, err := verifier.Verify(r.Header.Get("Authorization"))
            if err != nil {
                httperr.Write(w, r, &apperr.UnauthorizedError{})
                return
            }
            ctx := context.WithValue(r.Context(), CtxUserID, claims.Subject)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

The request handler only reads user_id from ctx — it doesn't know where the value came from, and it doesn't write it itself:

func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
    ctx, span := otel.Tracer("order").Start(ctx, "CreateOrder")
    defer span.End()

    customerID, _ := ctx.Value(CtxUserID).(string)
    log := enrichLog(ctx, h.log)

    order, err := h.orders.Create(ctx, cmd, customerID)
    if err != nil {
        span.RecordError(err)
        log.ErrorContext(ctx, "order_create_failed", slog.String("error", err.Error()))
        return nil, fmt.Errorf("create order: %w", err)
    }

    log.InfoContext(ctx, "order_created",
        slog.String("order_id", order.ID),
        slog.String("customer_id", customerID),
    )
    return order, nil
}

Goroutines: ctx as an argument, not captured from the closure

A goroutine is a separate thread of execution. If you start it without explicitly passing ctx, it loses its connection to the current request: the trace breaks, the cancellation signal never arrives.

A typical mistake:

// AVOID — context.Background() breaks the trace and ignores request cancellation
go func() {
    h.notify(context.Background(), order.CustomerID)
}()

The correct way is to pass ctx as an explicit argument. If the operation may outlive the original request, add a separate timeout:

// PREFER
notifyCtx, notifyCancel := context.WithTimeout(ctx, 5*time.Second)
defer notifyCancel()
go func(ctx context.Context) {
    if err := h.notifications.Send(ctx, order.CustomerID, order.ID); err != nil {
        h.log.WarnContext(ctx, "notification_send_failed",
            slog.String("customer_id", order.CustomerID),
            slog.String("error", err.Error()),
        )
    }
}(notifyCtx)

Capturing ctx from the closure is dangerous for another reason: if the outer context is already cancelled by the time the goroutine starts, it will immediately begin work with a cancelled ctx. An explicit argument removes this ambiguity.

For parallel tasks with sync.WaitGroup the principle is the same — every goroutine receives ctx as an argument:

func (h *FulfillOrderHandler) notifyParties(ctx context.Context, order *Order) {
    var wg sync.WaitGroup
    parties := []string{order.CustomerID, order.SellerID}

    for _, id := range parties {
        wg.Add(1)
        go func(ctx context.Context, partyID string) {
            defer wg.Done()
            if err := h.notifications.Send(ctx, partyID, order.ID); err != nil {
                h.log.WarnContext(ctx, "party_notify_failed",
                    slog.String("party_id", partyID),
                    slog.String("error", err.Error()),
                )
            }
        }(ctx, id)
    }
    wg.Wait()
}

Where to write context.WithValue, and where to only read

context.WithValue is appropriate only in middleware — when a request enters the system. In handlers and domain services ctx is only read and passed further. This separation matters: if every layer starts adding its own values to ctx, it becomes impossible to tell where anything came from.

The domain layer (ProductService, ProductRepository) knows nothing about request_id or user_id as strings at all. It accepts ctx and passes it into the OTel span and the database queries — that's it.

func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    log := enrichLog(ctx, h.log) // extract fields once, on entering the handler
    productID := chi.URLParam(r, "productID")

    product, err := h.products.GetByID(ctx, productID)
    if err != nil {
        log.WarnContext(ctx, "product_not_found", slog.String("product_id", productID))
        httperr.Write(w, r, err)
        return
    }
    log.InfoContext(ctx, "product_fetched", slog.String("product_id", productID))
    render.JSON(w, r, product)
}

Middleware order in chi

Order matters: the OTel span must start after request_id is already in ctx, so that the bridge correctly ties them together into a single log record.

RequestID → OTel → Auth → Logger → business routes
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(otelhttp.Middleware("order-service",
    otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
        return fmt.Sprintf("%s %s", r.Method, chi.RouteContext(r.Context()).RoutePattern())
    }),
))
r.Use(middleware.Auth(verifier))
r.Use(chiMW.Logger)

otelhttp.Middleware automatically reads the traceparent header from the incoming request (the W3C Trace Context standard) and propagates the span into ctx. All calls below see a single distributed trace.

In short

  • Go has no thread-local. context.Context is the only mechanism for passing request metadata through a call chain.
  • The RequestID middleware is mounted first: it reads or generates request_id, puts it in ctx, returns it in the response header.
  • trace_id and span_id are added by the OTel-slog bridge automatically — they're not placed manually via context.WithValue.
  • user_id is placed by the Auth middleware after JWT validation. The handler only reads it.
  • context.WithValue — only in middleware. Handlers and domain services only read and pass ctx further.
  • Goroutines receive ctx as an explicit argument. context.Background() in a goroutine breaks the trace and ignores cancellation.
  • chi middleware order: RequestIDOTelAuthLogger → routes.
  • Logging in Go — how enrichLog with request_id/user_id fits into the slog pipeline.
  • Tracing in Go — otelhttp, otelpgx, a manual span with defer span.End().
  • Metrics in Go — RED middleware on chi and the chi route pattern as a low-cardinality label.
  • Health checks in Go — /health/ready with a TTL cache and a database check through ctx.