В языках вроде 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:
RequestID→OTel→Auth→Logger→ роуты.
Что почитать дальше
- 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.