Трассировка запросов в Go: рабочие приёмы из практики
Как протянуть контекст с trace_id через горутины, HTTP-клиенты и логи в Go, не превращая сигнатуры функций в помойку. Разбор рабочих подходов и их цены.

Запрос приходит на API, идёт в три микросервиса, по дороге падает в одном из них, а в логах — россыпь строк без единой связи. Найти, что именно обработалось не так, невозможно: нет способа сказать «покажи всё, что относилось к этому запросу». В Go проблема острее, чем в языках с thread-local хранилищем: горутина не помнит, откуда её запустили, а глобальные переменные тут не спасают. Разберём, как протягивать идентификатор запроса через весь путь и что вы платите за каждый из подходов. Речь про Go 1.21+ и подход с context.Context, который в стандартной библиотеке считается основным с 1.7.
Почему очевидное решение ломается
Первое, что приходит в голову: сгенерировать trace_id в middleware и положить в глобальную переменную или в поле структуры логгера. Оба варианта разваливаются под конкурентной нагрузкой. Глобальная переменная одна на весь процесс — два одновременных запроса перезапишут её друг другу. Поле в общем логгере — та же гонка данных, которую go test -race подсветит красным.
В Go нет аналога ThreadLocal из Java или contextvars из Python, доступного из любой точки. Runtime намеренно не даёт доступа к ID горутины: авторы языка не хотят, чтобы вы строили логику на неявном состоянии. Значит, идентификатор нужно передавать явно — и единственный штатный механизм для этого context.Context.
Контекст в Go — не свалка для произвольных данных, а канал для request-scoped значений, которые живут ровно один запрос и умирают вместе с ним. Класть туда конфигурацию или зависимости — антипаттерн, класть trace_id — ровно то, для чего он придуман.
Подход 1: trace_id в контексте с типизированным ключом
Базовый приём. Кладём идентификатор в контекст на входе, достаём где нужно. Ключ обязательно должен быть неэкспортируемым типом, а не строкой — иначе два пакета с ключом "trace_id" затрут друг друга.
package tracing
import (
"context"
"github.com/google/uuid"
)
// приватный тип ключа исключает коллизии между пакетами
type ctxKey struct{}
var traceIDKey ctxKey
// WithTraceID кладёт идентификатор в контекст
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey, id)
}
// TraceID достаёт идентификатор; второе значение — был ли он там
func TraceID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(traceIDKey).(string)
return id, ok
}
// NewTraceID генерирует новый идентификатор
func NewTraceID() string {
return uuid.NewString()
}
Пакет github.com/google/uuid — внешний, но общепринятый; если тянуть зависимость не хочется, сгенерируйте 16 случайных байт через crypto/rand и закодируйте в hex.
HTTP middleware, который либо принимает, либо генерирует
Идентификатор либо приходит от вышестоящего сервиса в заголовке, либо рождается здесь. Единый заголовок по всему парку сервисов — обязательное условие, иначе сквозной трассировки не будет.
func TraceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Trace-Id")
if id == "" {
id = tracing.NewTraceID()
}
ctx := tracing.WithTraceID(r.Context(), id)
// отдаём наружу, чтобы клиент видел, по какому id искать
w.Header().Set("X-Trace-Id", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Подход 2: связка контекста со structured-логгером через slog
Класть trace_id в контекст мало — его нужно автоматически подставлять в каждую строку лога, иначе разработчики забудут это делать вручную. С Go 1.21 в стандартной библиотеке есть log/slog, а с 1.21 же — slog.Handler с методом Handle(ctx context.Context, r slog.Record), куда контекст приходит сам.
Приём: обёртка над хендлером, которая достаёт trace_id из контекста и добавляет как атрибут ко всем записям.
type traceHandler struct {
slog.Handler
}
// Handle вызывается на каждую запись лога; ctx уже содержит trace_id
func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := tracing.TraceID(ctx); ok {
r.AddAttrs(slog.String("trace_id", id))
}
return h.Handler.Handle(ctx, r)
}
func NewLogger() *slog.Logger {
base := slog.NewJSONHandler(os.Stdout, nil)
return slog.New(traceHandler{base})
}
Ключевой момент: логировать нужно через методы с контекстом — logger.InfoContext(ctx, ...), а не logger.Info(...). Обычные методы передают в хендлер context.Background(), и trace_id туда не попадёт. Это самая частая ошибка при такой схеме: хендлер написан правильно, а вызовы — без контекста, и лог снова без идентификатора.
Подход 3: прокидывание в исходящие HTTP-запросы
Трассировка обрывается на границе сервиса, если не передать заголовок дальше. Ручное req.Header.Set в каждом месте вызова забывается. Надёжнее обернуть http.RoundTripper у клиента — тогда заголовок добавится ко всем исходящим запросам автоматически.
type traceRoundTripper struct {
base http.RoundTripper
}
func (t traceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if id, ok := tracing.TraceID(req.Context()); ok {
req.Header.Set("X-Trace-Id", id)
}
return t.base.RoundTrip(req)
}
func NewClient() *http.Client {
return &http.Client{
Transport: traceRoundTripper{base: http.DefaultTransport},
}
}
Работает это только если вы создаёте исходящий запрос через http.NewRequestWithContext(ctx, ...) и передаёте тот самый ctx, в котором лежит trace_id. Запрос через http.NewRequest без контекста заголовок не получит — req.Context() вернёт Background.
Горутины: главная ловушка
Контекст не наследуется автоматически при запуске горутины. Если внутри обработчика вы делаете go doWork(), новая горутина ничего не знает про trace_id. Передавайте контекст явно параметром.
// правильно: контекст уходит в горутину аргументом
go func(ctx context.Context) {
logger.InfoContext(ctx, "фоновая обработка началась")
// ...
}(ctx)
Отдельная тонкость: если горутина переживает HTTP-запрос (фоновая задача, запущенная из обработчика), нельзя тащить в неё r.Context() — он отменится, как только клиент отключится. Скопируйте нужные значения в новый контекст через context.WithoutCancel(ctx) (доступно с Go 1.21) или соберите свежий контекст и перенесите trace_id вручную.
Когда что выбирать
| Задача | Приём | Чем платите |
|---|---|---|
| Связать логи одного запроса внутри сервиса | context + slog-хендлер | Дисциплина: только методы с Context |
| Сквозная трассировка между сервисами | единый заголовок + RoundTripper | Договорённость о заголовке во всём парке |
| Полная картина со временем каждого шага, спаны | OpenTelemetry | Зависимости, коллектор, инфраструктура |
Самописный trace_id хорош, пока вам нужно связать записи. Как только вопрос становится «где именно ушло 400 мс» — самопал не отвечает, и пора смотреть в сторону OpenTelemetry с его спанами и распределёнными трейсами. Это отдельная большая тема; здесь мы сознательно остаёмся на уровне идентификатора запроса, которого хватает большинству сервисов для отладки по логам.
Что ломается на практике
- Логирование без контекста.
logger.Infoвместоlogger.InfoContext— trace_id молча теряется, а хендлер вроде бы правильный. Линтерsloglintумеет ловить такие вызовы. - Строковый ключ контекста.
context.WithValue(ctx, "trace_id", id)компилируется, ноgo vetвыдаёт предупреждение про non-static key. Используйте приватный тип. - Отменённый контекст в фоне. Горутина с
r.Context(), пережившая запрос, получает отменённый контекст и падает на первом же вызове сcontext deadline exceededилиcontext canceled. - Забытый заголовок на исходящих. Запрос через
http.Getилиhttp.NewRequestбез контекста не унесёт trace_id — трасса обрывается на границе.
Что делать дальше
Соберите базовую связку из трёх кусков: middleware на входе, slog-хендлер для логов, RoundTripper для исходящих. Прогоните тесты с флагом -race — он сразу покажет, если где-то trace_id попал в общее изменяемое состояние вместо контекста. Добавьте линтер sloglint в CI, чтобы вызовы без контекста не проходили ревью. Когда логов станет мало и понадобится тайминг каждого шага между сервисами — переходите на OpenTelemetry Go SDK, сохранив тот же заголовок для совместимости.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Пакет context — документация Go, Пакет log/slog — документация Go
Частые вопросы
А если у меня Go старше 1.21 и нет log/slog?
Почему нельзя просто использовать ID горутины как ключ?
Не сработало: trace_id в логах пустой, хотя middleware стоит.
Замедляет ли context.WithValue обработку запроса?
Что за подводные камни в проде при фоновых горутинах?
Стоит ли сразу ставить OpenTelemetry вместо самописного trace_id?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.