Трассировка запросов в Go: ошибки, из-за которых спаны теряются
Контекст не пробрасывается, спаны не закрываются, трейсы обрываются на границе горутины. Разбираем частые ошибки инструментирования Go-сервисов через OpenTelemetry и как их поймать до прода.

Вы подключили OpenTelemetry к Go-сервису, открыли Jaeger — и видите разрозненные спаны без связей, обрывающиеся трейсы и запросы к базе, которые не привязаны ни к одному HTTP-запросу. Причина почти всегда одна из пяти, и почти все связаны с тем, как в Go передаётся context.Context. Разберём ошибки, которые ломают трассировку, и как их поймать в код-ревью, а не в три часа ночи по алерту.
Материал для OpenTelemetry Go SDK 1.x (пакеты go.opentelemetry.io/otel и смежные). Если у вас всё ещё OpenTracing или устаревший go.opentelemetry.io/otel/api — API отличается, часть примеров не подойдёт.
Ошибка 1. Контекст создаётся заново вместо проброса
Спаны в OpenTelemetry связываются через контекст. Когда вы стартуете спан, трейсер кладёт его в возвращаемый context.Context. Дочерний спан находит родителя, только если ему передали этот контекст. Самая частая ошибка — проигнорировать возвращённый контекст и продолжить со старым.
// НЕПРАВИЛЬНО: возвращённый ctx проигнорирован
func handleOrder(ctx context.Context, id string) error {
_, span := tracer.Start(ctx, "handleOrder")
defer span.End()
// сюда уходит исходный ctx, а не тот, что вернул Start
return fetchUser(ctx, id)
}Спан fetchUser внутри увидит родителем не handleOrder, а того, кто вызвал handleOrder. В трейсе появится «плоская» цепочка вместо дерева. Правильно — переприсваивать контекст:
// ПРАВИЛЬНО
func handleOrder(ctx context.Context, id string) error {
ctx, span := tracer.Start(ctx, "handleOrder")
defer span.End()
return fetchUser(ctx, id) // теперь ctx содержит текущий спан
}Правило, которое ловит 80% проблем на ревью: если функция вызвалаtracer.Start, но не переприсвоилаctx, — это баг. Ищите строки вида_, span := tracer.Startи требуйте обоснования.
Ошибка 2. Спан не закрывается на всех путях выхода
Незакрытый спан не отправится в бэкенд и заблокирует память до конца жизни процесса, если такого кода много. defer span.End() решает проблему для одного спана, но ломается в двух случаях: спан стартует в цикле и спан стартует условно.
// НЕПРАВИЛЬНО: defer в цикле копится до выхода из функции
for _, item := range items {
ctx, span := tracer.Start(ctx, "processItem")
defer span.End() // все End() выполнятся разом в конце функции
process(ctx, item)
}Здесь есть и вторая беда: ctx переприсваивается в цикле, поэтому каждая следующая итерация становится дочерней предыдущей — получается «лесенка» вместо набора братских спанов. Правильно — оборачивать тело итерации в замыкание и не портить внешний контекст:
// ПРАВИЛЬНО
for _, item := range items {
func() {
itemCtx, span := tracer.Start(ctx, "processItem")
defer span.End()
process(itemCtx, item)
}()
}Ошибка 3. Трейс обрывается на границе горутины
Go-разработчик рефлекторно пишет go doWork(). Если внутри стартует спан от контекста, который вы не передали, — новый спан окажется корнем нового трейса, не связанным с исходным запросом.
// НЕПРАВИЛЬНО: горутина не получила контекст
ctx, span := tracer.Start(ctx, "handler")
defer span.End()
go func() {
_, bg := tracer.Start(context.Background(), "async-task")
defer bg.End()
// ...
}()Тут context.Background() — гарантированный разрыв. Контекст запроса надо передать явно. При этом помните: если внешний спан закроется раньше, чем отработает горутина, связь в дереве останется, но родительский спан будет уже завершён — это нормально для fire-and-forget задач.
// ПРАВИЛЬНО: передаём ctx запроса
reqCtx, span := tracer.Start(ctx, "handler")
defer span.End()
go func(ctx context.Context) {
_, bg := tracer.Start(ctx, "async-task")
defer bg.End()
// ...
}(reqCtx)Отдельный случай: спан переживает запрос
Если горутина живёт дольше HTTP-обработчика, а вы отменяете контекст по завершении запроса, дочерний спан унаследует отмену. Для долгоживущих фоновых задач стартуйте отдельный корневой контекст, но переносите связь через span link, а не через родительство — так трейс не окажется искусственно привязан к давно завершённому запросу. Детали связей через links — отдельная тема, здесь только обозначаем.
Ошибка 4. Ошибки и статусы не записываются в спан
Спан, который завершился без записи ошибки, в трейсе выглядит зелёным, даже если функция вернула err. Когда через месяц ищете, где падают запросы, вы смотрите на красные спаны — а их нет, потому что никто не вызвал RecordError и SetStatus.
import (
"go.opentelemetry.io/otel/codes"
)
func fetchUser(ctx context.Context, id string) (err error) {
ctx, span := tracer.Start(ctx, "fetchUser")
defer span.End()
user, err := repo.Get(ctx, id)
if err != nil {
span.RecordError(err) // добавит событие с ошибкой
span.SetStatus(codes.Error, err.Error()) // пометит спан красным
return err
}
_ = user
return nil
}Разница между RecordError и SetStatus: первый добавляет к спану событие с деталями исключения, второй меняет итоговый статус спана. Нужны оба — только событие без статуса не покрасит спан в бэкенде.
Ошибка 5. Атрибуты с высокой кардинальностью
Соблазн положить в спан всё подряд: ID пользователя, полный URL с query-параметрами, тело запроса. Технически это работает, но кардинальность атрибутов бьёт по стоимости хранения и по производительности бэкенда. ID пользователя в атрибуте — нормально (вы по нему ищете), а вот полный URL с уникальными параметрами в каждом запросе раздувает индекс.
- Кладите в атрибуты: маршрут-шаблон (
/users/{id}, а не/users/42), код ответа, имя операции БД, размер выборки. - Не кладите: полные тела запросов и ответов, секреты и токены, сырые SQL с подставленными значениями, персональные данные без необходимости.
- Для маршрута с параметрами используйте шаблон пути — большинство инструментирующих оберток (например,
otelhttp) умеют брать его из роутера, если роутер это поддерживает.
Как поймать это до прода
| Ошибка | Симптом в бэкенде | Как ловить |
|---|---|---|
| Контекст не проброшен | Плоские трейсы, спаны без родителя | Grep по _, span := tracer.Start; проверка дерева на стенде |
| Спан не закрыт | Спаны не появляются / рост памяти | Линтер на пропущенный defer span.End() |
| Разрыв на горутине | Отдельные короткие трейсы «async-task» | Запрет context.Background() внутри обработчиков |
| Ошибки не записаны | Зелёные спаны при реальных сбоях | Сверка error rate из логов и из трейсов |
| Высокая кардинальность | Медленный поиск, рост расходов | Аудит атрибутов, ограничение через процессор |
Что настроить дальше
После того как связность трейсов починена, займитесь двумя вещами. Первое — сэмплирование: на проде трассировать 100% запросов дорого; настройте ParentBased с нужной долей, чтобы решение о выборке принималось на корне трейса и не рвало цепочку. Второе — распространение контекста между сервисами: убедитесь, что зарегистрирован пропагатор (обычно W3C TraceContext) и заголовки traceparent реально уходят в исходящие HTTP- и gRPC-вызовы. Без пропагатора все ошибки выше повторятся уже на границе сервисов.
Меряйте не «есть трейсы или нет», а долю трейсов с корректным деревом и долю спанов с проставленным статусом — эти два числа показывают реальное качество инструментирования.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: OpenTelemetry Go — официальная документация, go.opentelemetry.io/otel — пакеты на pkg.go.dev
Частые вопросы
У меня OpenTracing, а не OpenTelemetry — примеры подойдут?
Спаны вообще не появляются в Jaeger, хотя код инструментирован. В чём дело?
Обязательно ли переприсваивать ctx, если внутри функции больше нет вложенных спанов?
defer span.End() в цикле — это точно проблема, а не преувеличение?
Как быть с фоновой горутиной, которая живёт дольше HTTP-запроса?
Нужно ли проставлять статус успеха у каждого спана?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.