Трассировка запросов в Go: как сделать правильно с первого раза
Из логов не видно, почему запрос выполнялся 4 секунды: 200 записей от разных горутин перемешаны. Трассировка через OpenTelemetry показывает, где именно потерялось время. Разбираем рабочие приёмы.

Запрос к вашему API отдаёт ответ за 4 секунды вместо привычных 200 миллисекунд. В логах — двести строк от десятка горутин, перемешанных так, что не понять, какая обращалась к базе, какая к внешнему сервису, а какая просто ждала мьютекс. Стандартный log.Println здесь бесполезен: он показывает события, но не связывает их в одну цепочку и не измеряет длительность каждого шага.
Распределённая трассировка решает именно это: каждый запрос получает единый идентификатор, а каждая операция внутри — свой отрезок (span) с временем начала и конца. В экосистеме Go стандарт де-факто — OpenTelemetry (пакет go.opentelemetry.io/otel, версия SDK 1.x на момент написания). Ниже — как подключить его так, чтобы потом не переписывать половину кодовой базы.
Что выбрать: ручные спаны, автоинструментация или комбинация
Первое решение, которое определит всё остальное, — как именно вы создаёте спаны. Здесь три подхода, и цена у каждого разная.
- Только ручные спаны. Вы сами вызываете
tracer.Start()в каждой функции, которую хотите видеть в трассе. Максимальный контроль над именами и атрибутами, но много рутинного кода и легко забыть закрыть спан. - Автоинструментация библиотек. Готовые обёртки для HTTP (
otelhttp), gRPC (otelgrpc),database/sql(otelsqlот стороннего мейнтейнера). Ставите обёртку один раз — получаете спаны на каждый входящий и исходящий вызов без правки бизнес-логики. - Комбинация. Автоинструментация даёт скелет трассы (HTTP → SQL → внешний вызов), а ручные спаны вы добавляете точечно там, где логика сложная и границ библиотек недостаточно.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Только ручные | Полный контроль, точные имена спанов | Много boilerplate, риск незакрытых спанов |
| Только автоинструментация | Почти нулевой код, покрытие границ | Не видно, что происходит внутри функции |
| Комбинация | Скелет бесплатно + детали где нужно | Надо понимать оба механизма |
Для 90% сервисов правильный ответ — комбинация. Автоинструментация HTTP и SQL закрывает границы, а ручные спаны вешаются на две-три тяжёлые функции.
Правильная инициализация: TracerProvider один раз на процесс
Самая частая ошибка новичка — создавать TracerProvider внутри обработчика запроса. Он инициализируется один раз на весь процесс и живёт до graceful shutdown. Вот рабочая инициализация с экспортом в OTLP-коллектор:
// OpenTelemetry Go SDK 1.x
package tracing
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
// Init настраивает глобальный TracerProvider.
// Возвращает функцию завершения — её надо вызвать при остановке сервиса.
func Init(ctx context.Context, serviceName, collectorAddr string) (func(context.Context) error, error) {
// Экспортёр отправляет спаны в коллектор по gRPC
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(collectorAddr),
otlptracegrpc.WithInsecure(), // в проде — TLS, не WithInsecure
)
if err != nil {
return nil, err
}
// Ресурс описывает, какой сервис породил спаны
res, err := resource.New(ctx,
resource.WithAttributes(semconv.ServiceName(serviceName)),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
// BatchSpanProcessor копит спаны и шлёт пачками — это важно для нагрузки
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
// Сэмплирование: 100% на старте, потом снизить (см. ниже)
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}Обратите внимание на две детали, о которые спотыкаются чаще всего. Первая — WithBatcher, а не WithSyncer: синхронный процессор блокирует горутину на каждом спане и под нагрузкой убивает latency. Вторая — версия пакета semconv должна совпадать с той, что документирована для вашей версии SDK; имена атрибутов между версиями меняются, и semconv.ServiceName из старой версии может не собраться.
Контекст — это всё. Никаких глобальных переменных
Трассировка в Go держится на context.Context. Спан хранится в контексте, и именно контекст протаскивается через все вызовы. Порвёте цепочку контекста — родительский и дочерний спаны окажутся в разных трассах, и вы увидите два оборванных куска вместо одной картины.
// Правильно: контекст приходит из обработчика и течёт вниз
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
tracer := otel.Tracer("order-service")
// Start возвращает новый контекст со вложенным спаном
ctx, span := tracer.Start(ctx, "ProcessOrder")
defer span.End() // закрыть обязательно, иначе спан не завершится
span.SetAttributes(attribute.String("order.id", orderID))
// Передаём ИМЕННО этот ctx дальше, а не context.Background()
if err := s.checkInventory(ctx, orderID); err != nil {
span.RecordError(err) // ошибка попадёт в трассу
span.SetStatus(codes.Error, err.Error())
return err
}
return nil
}Правило одно и жёсткое: если внутри функции где-то появилсяcontext.Background()вместо переданногоctx— трасса в этом месте рвётся. Ищите такие места первым делом, когда спаны «теряются».
Никогда не храните спан в поле структуры или глобальной переменной, чтобы «не таскать контекст». Один и тот же экземпляр сервиса обслуживает параллельные запросы разных пользователей — глобальный спан смешает их данные и запишет ошибку одного запроса в трассу другого.
Автоинструментация HTTP и SQL за пять строк
Границы сервиса покрываются обёртками. Для входящего HTTP-трафика — otelhttp:
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
// Оборачиваем весь роутер — спан создаётся на каждый входящий запрос
handler := otelhttp.NewHandler(mux, "http-server")
http.ListenAndServe(":8080", handler)Для исходящих HTTP-вызовов оберните Transport клиента в otelhttp.NewTransport — тогда идентификатор трассы автоматически уйдёт в заголовках следующему сервису. Это и есть распределённая часть трассировки: запрос, прошедший через три сервиса, соберётся в одну трассу, если каждый корректно пробрасывает контекст.
Сэмплирование: почему AlwaysSample убьёт вас в проде
На тестовом стенде AlwaysSample() удобен — видно каждый запрос. В проде сервис на 5000 RPS с полным сэмплированием сгенерирует поток спанов, который забьёт сеть до коллектора и раздует счёт за хранилище трасс. Считать точный overhead без замеров нельзя — он зависит от числа спанов на запрос и от их размера, поэтому измерьте на своём трафике.
Практичный вариант — ParentBased поверх TraceIDRatioBased: сэмплируем фиксированную долю новых трасс, но всегда следуем решению родителя, чтобы распределённая трасса не разваливалась на полпути.
// Сэмплируем 10% новых трасс, но уважаем решение родительского сервиса
sampler := sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(0.1),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithSampler(sampler),
)Что ломается на практике
- Спаны не появляются в бэкенде. Чаще всего забыт вызов
tp.Shutdown():BatchSpanProcessorне успевает отправить накопленное перед выходом процесса. Вызывайте Shutdown в graceful shutdown с таймаутом. - Трасса обрывается между сервисами. Не настроен пропагатор. Проверьте, что вызван
otel.SetTextMapPropagatorсpropagation.TraceContext— без него заголовкиtraceparentне пишутся и не читаются. - Незакрытые спаны. Пропущенный
defer span.End()оставляет спан открытым; в бэкенде он либо не отобразится, либо повиснет с некорректной длительностью. - Утечка контекста в горутины. Запустили фоновую горутину с
ctxзапроса, а запрос завершился — контекст отменён, спан оборвётся. Для фоновых задач создавайте отдельный контекст, но линкуйте его к родителю черезtrace.LinkFromContext.
Что делать дальше
Поднимите локально коллектор (OpenTelemetry Collector) и бэкенд для просмотра трасс — Jaeger или Tempo подойдут для старта. Прогоните нагрузочный тест с включённым сэмплированием на 100% и посмотрите на реальный overhead — прирост p99 latency и объём трафика к коллектору. По результатам подберите долю сэмплирования. Дальше смотрите на самые долгие спаны в трассах медленных запросов — это и есть ваши узкие места. Инструментация базы через otelsql и метрики (тот же OpenTelemetry умеет и в метрики) — отдельные темы, здесь мы разобрали только трассы запросов.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: OpenTelemetry Go — официальная документация, OpenTelemetry Go Contrib (instrumentation) на GitHub
Частые вопросы
У меня старая версия SDK, до 1.0. Подойдёт этот код?
Спаны не доезжают до Jaeger, хотя ошибок в логах нет. Что проверить?
Насколько трассировка замедляет сервис?
Можно ли обойтись без context.Context и хранить спан в структуре?
Как связать трассы между несколькими сервисами?
Нужно ли трассировать вообще всё?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.