Опирается на правила: R-OBS-CTX-1R-OBS-CTX-4 и R-OBS-CTX-X1R-OBS-CTX-X3 из Observability Style Guide → раздел 6. Context propagation.

Важно знать

  • В Go нет thread-local. Единственный механизм propagation — context.Context, передаваемый явным аргументом.
  • RequestID-middleware первым в chi-цепочке читает X-Request-Id header или генерирует UUID и кладёт id в ctx.
  • traceId/spanId подставляются автоматически через OTel-slog bridge (go.opentelemetry.io/contrib/bridges/otelslog). Не добавлять руками через ctx.Value(...).
  • userId кладёт Auth-middleware после JWT-валидации; UseCase Handler только читает из ctx, не пишет.
  • Горутины получают ctx явным аргументом — не захватывают из замыкания и не используют context.Background().
  • context.WithValue только в middleware. В Handler и Domain Service — запрещено.
  • Разрыв trace на горутине без ctx — потеря связки лог → trace; расследование инцидента становится слепым.

В Java Spring MDC хранит поля в thread-local и автоматически попадает в каждый log-record. В Go этого механизма нет: context.Context проходит через все вызовы явно. Это не недостаток, а идиома языка — observability-поля распространяются ровно туда, куда программист явно передаёт ctx.

RequestID-middleware

R-OBS-CTX-1: один middleware на entry-point читает или генерирует request_id и кладёт в context.Context.

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

Middleware монтируется первым в chi-цепочке, до OTel и Logger, чтобы все следующие span-ы и log-записи уже видели request_id:

// cmd/server/main.go
r := chi.NewRouter()
r.Use(middleware.RequestID)                            // R-OBS-CTX-1 — первым
r.Use(otelhttp.Middleware("order-service"))            // OTel span видит request_id
r.Use(chiMW.Logger)                                   // access-log с request_id

X-Request-Id возвращается в response — клиент может залогировать и предоставить при инциденте.

traceId/spanId автоматически

R-OBS-CTX-2: подключаем OTel-slog bridge — он автоматически добавляет trace_id/span_id из активного span-а в каждую slog-запись.

// internal/platform/log/setup.go
import (
    "go.opentelemetry.io/contrib/bridges/otelslog"
    "log/slog"
)

func New(env string) *slog.Logger {
    var handler slog.Handler
    if env == "production" {
        handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
    } else {
        handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
    }
    return otelslog.NewLogger("order-service", otelslog.WithLoggerProvider(otel.GetLoggerProvider()),
        otelslog.WithSource(false))
}

Если используется чистый slog без bridge — trace_id читается из span-а вручную только в edge-слое:

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

Вручную context.WithValue для trace_id не кладём — bridge или trace.SpanFromContext сделают это правильнее.

userId после JWT

R-OBS-CTX-4: Auth-middleware кладёт user_id в ctx после успешной проверки токена.

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

UseCase Handler читает user_id из ctx, не кладёт сам:

// internal/order/usecase/create_order.go
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
}

Горутины: ctx аргументом, не замыканием

R-OBS-CTX-3: горутина, запущенная без проброса ctx, разрывает trace и теряет cancel-сигнал.

// AVOID — context.Background() обрывает trace, игнорирует отмену
go func() {
    h.notify(context.Background(), order.CustomerID)
}()

Канонический паттерн — ctx аргументом с явным таймаутом:

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

Для fan-out с sync.WaitGroup — каждая горутина получает ctx аргументом:

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

Захват ctx из замыкания опасен: если внешний ctx отменён раньше, чем горутина стартовала, горутина начинает работу с уже отменённым контекстом. Явный аргумент — явная точка входа.

Извлечение полей из ctx в edge-слое

Поля request_id / user_id извлекаются один раз — в хендлере или в enrichLog, не в доменном слое:

// internal/product/handler/product_handler.go
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    log := enrichLog(ctx, h.log)
    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)
}

Доменный слой (ProductService, ProductRepository) принимает ctx как аргумент и передаёт дальше — в OTel span и в sqlc/pgx-запросы. Он ничего не знает про request_id или user_id как строки.

Порядок middleware в chi

Порядок критичен: OTel-span должен стартовать после того, как request_id уже в ctx, чтобы bridge корректно связал их:

RequestID → OTel (otelhttp.Middleware) → Auth → Logger → бизнес-роуты
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 автоматически читает traceparent из входящего header (W3C Trace Context) — R-OBS-TRC-2 — и пробрасывает span в ctx. Все вызовы ниже видят единый span.

Что запрещено

АнтипаттернПравилоЧто взамен
go func() { h.notify(context.Background(), ...) }()R-OBS-CTX-X3передать ctx аргументом с context.WithTimeout
context.WithValue в UseCase Handler или Domain ServiceR-OBS-CTX-X2только в middleware; Handler читает, не пишет
Захват ctx из замыкания в долгоживущей горутинеR-OBS-CTX-X1явный аргумент в каждую горутину
Ручной ctx.Value("trace_id") и запись в slogR-OBS-CTX-2OTel-slog bridge или trace.SpanFromContext
userId в RequestID-middleware до JWT-валидацииR-OBS-CTX-4отдельный Auth-middleware после JWT
OTel-middleware до RequestIDR-OBS-CTX-1RequestID — первым в цепочке

Куда дальше

  • Конфигурация — management-порт и APP_ENV для выбора JSON/Text handler.
  • Health checks — /health/ready с TTL-кешем и pgx Ping через ctx.
  • Logging — как enrichLog с request_id/user_id встраивается в slog-pipeline.
  • Metrics — RED-middleware на chi и chi route pattern как label низкой cardinality.
  • SLO и алерты — SLO recording rules и multi-window burn-rate alerts.
  • Tracing — otelhttp.NewHandler, otelpgx, manual span с defer span.End().