Трассировка в Go через OpenTelemetry: как делать правильно
Разбираем, как корректно инструментировать Go-сервис через OpenTelemetry: где заканчивается span, почему context теряет трассу и чем платишь за сэмплирование.

Сервис на Go отдаёт 200 по всем ручкам, но p99 периодически прыгает до 4 секунд, а логи ничего не объясняют: запрос вошёл, запрос вышел, между ними тишина. Логи показывают точки, а не путь между ними. Трассировка нужна как раз для середины — увидеть, что запрос 3,8 секунды ждал ответа от базы, потому что соединение уходило в пул, где не осталось свободных коннектов. Ниже — как инструментировать Go-сервис через OpenTelemetry так, чтобы трасса не рвалась, span'ы закрывались, а объём телеметрии не съел половину CPU. Речь про стек go.opentelemetry.io/otel (модуль стабилизировался, tracing API имеет версию v1.x) и Go 1.21+.
Что именно инструментируем и почему context — центр всего
В OpenTelemetry трасса — это дерево span'ов. Каждый span знает своего родителя, и связь между ними живёт не в глобальной переменной, а в context.Context. Это ключевой момент, на котором ломается большинство реализаций: если вы создали span, но не передали обновлённый context дальше по вызовам, следующий span станет корнем новой трассы, и дерево развалится на несвязанные куски.
Правило одно: функция, которую вы трассируете, принимает ctx context.Context первым аргументом, и вы передаёте вниз именно тот ctx, который вернул tracer.Start, а не исходный.
Трасса рвётся не там, где вы забыли создать span, а там, где создали, но потеряли context. Потерянный ctx — это невидимая ветка дерева, которую вы уже не найдёте в бэкенде.
Минимальная настройка провайдера
Сначала — инициализация экспортёра и провайдера трасс. Экспортёр по OTLP через gRPC отправляет данные в коллектор или напрямую в бэкенд (Jaeger, Tempo). Пакет otlptracegrpc живёт в go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc.
package main
import (
"context"
"time"
"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.24.0"
)
// initTracer настраивает глобальный провайдер и возвращает функцию
// корректного завершения — её нужно вызвать при остановке сервиса.
func initTracer(ctx context.Context) (func(context.Context) error, error) {
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("localhost:4317"),
otlptracegrpc.WithInsecure(), // без TLS — только для локальной разработки
)
if err != nil {
return nil, err
}
// resource описывает, чей это сервис — попадёт в атрибуты трассы
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("orders-api"),
semconv.ServiceVersion("1.4.0"),
),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter, sdktrace.WithBatchTimeout(5*time.Second)),
sdktrace.WithResource(res),
// сэмплируем 10% трасс; про выбор доли — ниже
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}
Обратите внимание на WithBatcher, а не WithSyncer. Синхронный экспорт отправляет каждый span отдельным вызовом и блокирует горутину — на проде это лишняя латентность на каждом запросе. Batcher копит span'ы и отправляет пачками в фоне.
Ручное создание span'ов: что делать и чего не делать
Точку входа (HTTP-хендлер, gRPC-метод) чаще всего инструментируют готовым middleware — например otelhttp из go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp. Он сам достаёт входящий контекст трассировки из заголовков и создаёт корневой span. А вот вложенные вызовы — обращение к базе, к внешнему API, тяжёлую бизнес-логику — вы размечаете руками.
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("orders-api/service")
func (s *Service) reserveStock(ctx context.Context, orderID string) error {
// ctx, возвращённый Start, содержит текущий span — передаём его дальше
ctx, span := tracer.Start(ctx, "reserveStock")
defer span.End() // span закрывается всегда, даже при panic-разворачивании
span.SetAttributes(attribute.String("order.id", orderID))
if err := s.db.decrement(ctx, orderID); err != nil {
// помечаем span как ошибочный — в бэкенде он подсветится красным
span.RecordError(err)
span.SetStatus(codes.Error, "stock decrement failed")
return err
}
return nil
}
Три вещи, которые нарушают чаще всего:
- Забывают
defer span.End(). Незакрытый span не отправится в бэкенд или отправится без длительности. СтавьтеEndсразу послеStart, до любой логики. - Передают вниз старый ctx вместо нового. После
ctx, span := tracer.Start(ctx, ...)все вложенные вызовы должны получить этот новыйctx. Иначе они привяжутся к родителю, а не к текущему span. - Кладут PII в атрибуты. Номер карты, телефон, email в
SetAttributesуедут в бэкенд трассировки и осядут там на срок хранения. Атрибут — это идентификатор и метрика, а не дамп payload'а.
Ошибки и статус span'а — не одно и то же
RecordError добавляет событие с описанием ошибки, но не меняет статус span'а. Чтобы span считался неуспешным, вызывайте отдельно SetStatus(codes.Error, ...). Если вы вызвали только RecordError, span останется зелёным, и фильтр «показать трассы с ошибками» его не найдёт.
Сэмплирование: чем платите за каждую сохранённую трассу
Собирать все трассы на нагруженном сервисе — способ утопить в данных и коллектор, и бюджет на хранилище. Но и слишком агрессивное сэмплирование убивает смысл: редкая ошибка на 0,5% запросов не попадёт в выборку 1%.
ParentBased(TraceIDRatioBased(x)) из примера выше принимает решение по родителю: если входящий запрос уже трассируется (флаг пришёл в заголовке), решение наследуется, иначе — сэмплируется доля x. Это head sampling — решение принимается в начале, до того как известно, будет ли ошибка.
| Стратегия | Где решает | Плюс | Минус |
|---|---|---|---|
| AlwaysSample | всегда пишет | ничего не теряется | объём и стоимость на проде |
| TraceIDRatioBased(0.1) | в начале трассы | простая, дёшево | ошибки могут не попасть в выборку |
| ParentBased(...) | наследует у родителя | трасса не рвётся между сервисами | всё ещё head sampling |
| Tail sampling (в коллекторе) | после сбора всей трассы | можно оставлять все трассы с ошибкой | требует OTel Collector и буферизации |
Практический вывод: head sampling настраивайте в приложении, а «оставлять все ошибочные и медленные трассы» решайте на уровне OpenTelemetry Collector через tail-sampling-процессор. В коде сервиса вы это не сделаете — на момент Start вы ещё не знаете, упадёт запрос или нет.
Где трасса ломается на практике
- Горутины. Запустили
go doWork(ctx)и забыли, что после возврата хендлера родительский span уже закрыт черезdefer. Span в горутине привяжется к завершённому родителю или окажется вне трассы. Для фоновой работы создавайте span явно и не рассчитывайте, что родитель ещё жив. - Пропаганда между сервисами. Если вызываете другой сервис обычным
http.Client, заголовки трассировки не уйдут. Оборачивайте транспорт вotelhttp.NewTransport, иначе на границе сервисов дерево обрывается. - Shutdown. Если процесс завершился, а
tp.Shutdown(ctx)не вызвали, батчер не успеет отправить накопленные span'ы. Последние трассы перед рестартом потеряются — как раз те, что нужны при разборе инцидента. - Слишком мелкая гранулярность. Span на каждую функцию превращает трассу в нечитаемую простыню и раздувает объём. Размечайте границы ввода-вывода и логические этапы, а не каждый вызов.
Как убедиться, что работает, и что мерить дальше
Поднимите локально Jaeger или Grafana Tempo, направьте на них OTLP-эндпоинт и прогоните тестовый запрос. В UI должна появиться трасса с одним корневым span'ом и вложенными дочерними — если вместо дерева видите несколько отдельных трасс, вы где-то потеряли context.
Дальше в проде стоит смотреть на две вещи: долю сэмплированных трасс (не съедает ли экспорт CPU и сеть) и наличие «сирот» — span'ов без родителя, которых быть не должно. Инструментацию популярных клиентов (database/sql, Redis, gRPC) не пишите руками — берите готовые модули из репозитория opentelemetry-go-contrib, они закрывают пропаганду context'а за вас. Логи и метрики к трассам подключаются отдельно через связь по trace_id — это следующая тема, здесь мы разобрали только трассы.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: OpenTelemetry Go — официальная документация, opentelemetry-go — репозиторий на GitHub
Частые вопросы
У меня другая версия otel — примеры сработают?
А если трасса разваливается на отдельные куски?
Почему span'ы не доходят до Jaeger, хотя код отрабатывает?
Сколько трасс сэмплировать на проде?
Можно ли класть тело запроса в атрибуты span'а для отладки?
Нужно ли инструментировать каждую функцию span'ом?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.