← назад к разделу

В языках вроде Java есть thread-local хранилище: положил request_id в начале запроса — он доступен в любом методе этого же потока без явной передачи. В Go такого механизма нет намеренно. Единственный способ пронести данные через цепочку вызовов — context.Context, передаваемый первым аргументом каждой функции.

Это выглядит многословно, но даёт точный контроль: observability-поля распространяются ровно туда, куда программист явно передал ctx.

Что такое context.Context

context.Context — это интерфейс из стандартной библиотеки. У него три задачи:

  • Отмена: если клиент закрыл соединение или истёк таймаут, ctx.Done() закрывается и можно прекратить работу.
  • Дедлайн: время, после которого работа бессмысленна.
  • Значения: небольшие метаданные запроса — request_id, user_id, span трассировки.

В HTTP-сервере каждый запрос уже приходит со своим ctx через r.Context(). Middleware добавляет в него данные, хендлер читает.

RequestID-middleware — точка входа

Самое первое, что нужно сделать с входящим запросом — назначить ему идентификатор. Это позволяет объединить все логи и события одного запроса, даже если они разбросаны по нескольким сервисам.

Middleware читает заголовок X-Request-Id (если клиент передал его) или генерирует новый UUID, кладёт значение в ctx и возвращает заголовок в ответе — клиент может сослаться на него при поддержке инцидента.

// 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 монтируется первым в цепочке — до трассировки и логгера, чтобы все последующие записи уже видели request_id:

r := chi.NewRouter()
r.Use(middleware.RequestID)                 // первым — до всего остального
r.Use(otelhttp.Middleware("order-service")) // OTel span видит request_id
r.Use(chiMW.Logger)                         // access-log с request_id

trace_id и span_id — автоматически через OTel

Когда используется OpenTelemetry, trace_id и span_id не нужно класть в ctx вручную. OTel-slog bridge (go.opentelemetry.io/contrib/bridges/otelslog) делает это автоматически: он достаёт активный span из ctx и добавляет его идентификаторы в каждую slog-запись.

Если bridge не подключён, trace_id читают из span-а один раз при старте хендлера и обогащают логгер:

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

Частая ошибка — положить trace_id вручную через context.WithValue. Это создаёт дубликат: OTel уже управляет span-ом в ctx, а ручное значение рядом будет устаревать при вложенных span-ах.

userId после JWT-валидации

Идентификатор пользователя появляется в контексте только после успешной проверки токена. Этим занимается отдельный Auth-middleware — он стоит после RequestID, но до бизнес-роутов:

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

Хендлер запроса только читает user_id из ctx — он не знает, откуда то значение взялось, и не пишет его сам:

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 аргументом, не захватом из замыкания

Горутина — это отдельный поток выполнения. Если запустить её без явной передачи ctx, она потеряет связь с текущим запросом: trace оборвётся, сигнал отмены не дойдёт.

Типичная ошибка:

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

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

Для параллельных задач с 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()
}

Где писать context.WithValue, а где только читать

context.WithValue уместен только в middleware — при входе запроса в систему. В хендлерах и доменных сервисах ctx только читается и передаётся дальше. Это разделение важно: если каждый слой начнёт добавлять в ctx свои значения, потом невозможно понять, откуда что взялось.

Доменный слой (ProductService, ProductRepository) вообще ничего не знает о request_id или user_id как строках. Он принимает ctx и передаёт его в OTel-span и запросы к базе данных — и всё.

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

Порядок middleware в chi

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

RequestID → OTel → 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 из входящего запроса (стандарт W3C Trace Context) и пробрасывает span в ctx. Все вызовы ниже видят единый распределённый trace.

Коротко

  • В Go нет thread-local. context.Context — единственный механизм передачи метаданных запроса через цепочку вызовов.
  • RequestID-middleware монтируется первым: читает или генерирует request_id, кладёт в ctx, возвращает в заголовке ответа.
  • trace_id и span_id добавляет OTel-slog bridge автоматически — вручную через context.WithValue их не кладут.
  • user_id кладёт Auth-middleware после JWT-валидации. Хендлер только читает.
  • context.WithValue — только в middleware. Хендлеры и доменные сервисы только читают и передают ctx дальше.
  • Горутины получают ctx явным аргументом. context.Background() в горутине обрывает trace и игнорирует отмену.
  • Порядок chi-middleware: RequestIDOTelAuthLogger → роуты.

Что почитать дальше

  • Logging в Go — как enrichLog с request_id/user_id встраивается в slog-pipeline.
  • Tracing в Go — otelhttp, otelpgx, ручной span с defer span.End().
  • Metrics в Go — RED-middleware на chi и chi route pattern как метка с низкой мощностью.
  • Health checks в Go — /health/ready с TTL-кешем и проверкой базы через ctx.