Опирается на правила:
R-OBS-CTX-1…R-OBS-CTX-4иR-OBS-CTX-X1…R-OBS-CTX-X3из Observability Style Guide → раздел 6. Context propagation.
Важно знать
- В Go нет thread-local. Единственный механизм propagation —
context.Context, передаваемый явным аргументом.RequestID-middleware первым в chi-цепочке читаетX-Request-Idheader или генерирует 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 Service | R-OBS-CTX-X2 | только в middleware; Handler читает, не пишет |
Захват ctx из замыкания в долгоживущей горутине | R-OBS-CTX-X1 | явный аргумент в каждую горутину |
Ручной ctx.Value("trace_id") и запись в slog | R-OBS-CTX-2 | OTel-slog bridge или trace.SpanFromContext |
userId в RequestID-middleware до JWT-валидации | R-OBS-CTX-4 | отдельный Auth-middleware после JWT |
OTel-middleware до RequestID | R-OBS-CTX-1 | RequestID — первым в цепочке |
Куда дальше
- Конфигурация — management-порт и
APP_ENVдля выбора JSON/Text handler. - Health checks —
/health/readyс TTL-кешем и pgxPingчерез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().