Представьте: запрос приходит в ваш сервис, проходит через базу данных, вызывает внешний API — и где-то посередине что-то тормозит. Метрики говорят «p95 латентности вырос», логи — «где-то была ошибка». Но ни то, ни другое не показывает, что именно произошло с этим конкретным запросом.
Distributed tracing решает эту проблему. Он прослеживает путь одного запроса через все сервисы и показывает: вот HTTP-сервер принял вызов, вот он ушёл в базу и провёл там 180 мс, вот сходил во внешний API и получил таймаут. Всё — в одном представлении, с временными метками.
В Java есть javaagent, который инструментирует байткод автоматически. В Go такого нет, но библиотеки из экосистемы OpenTelemetry (otelhttp, otelpgx, otelchi) дают почти то же самое — одной строкой подключения.
Настройка TracerProvider
Всё начинается с TracerProvider — это центральный объект, который знает, куда отправлять данные и какой процент запросов записывать.
// internal/platform/tracing/setup.go
package tracing
import (
"context"
"fmt"
"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"
)
type Config struct {
OTLPEndpoint string
ServiceName string
Version string
Env string
SampleRate float64
}
func Setup(ctx context.Context, cfg Config) (func(context.Context) error, error) {
exp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("tracing exporter: %w", err)
}
res, _ := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(cfg.ServiceName),
semconv.ServiceVersion(cfg.Version),
semconv.DeploymentEnvironmentName(cfg.Env),
),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithSampler(
sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SampleRate)),
),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}
Функция возвращает shutdown — его нужно вызвать при завершении приложения, чтобы незаполненный буфер успел уйти в коллектор.
ParentBased — важная деталь: если входящий запрос уже несёт заголовок traceparent с пометкой «записывать», ваш сервис тоже запишет span. Если нет — решает SampleRate. Это значит, что внешний шлюз может управлять тем, какие запросы трассируются по всей цепочке.
В main.go это выглядит так:
shutdown, err := tracing.Setup(ctx, tracing.Config{
OTLPEndpoint: cfg.OTLPEndpoint,
ServiceName: "order-service",
Version: version,
Env: cfg.AppEnv,
SampleRate: 0.01, // 1% в продакшене
})
if err != nil {
slog.Error("tracing setup failed", "error", err)
os.Exit(1)
}
defer func() { _ = shutdown(ctx) }()
Автоинструментация chi-роутера
Без дополнительного кода chi-роутер не создаёт spans. Одна строка с otelchi.Middleware исправляет это:
import (
"github.com/go-chi/chi/v5"
"go.opentelemetry.io/contrib/instrumentation/github.com/go-chi/chi/v5/otelchi"
)
router := chi.NewRouter()
router.Use(otelchi.Middleware("order-service", otelchi.WithChiRoutes(router)))
// ... остальные middleware и маршруты
После этого каждый HTTP-запрос автоматически получает span с именем маршрута, HTTP-методом, кодом ответа и временем выполнения.
Для исходящих запросов к другим сервисам — оборачиваем транспорт:
func New() *http.Client {
return &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
Timeout: 10 * time.Second,
}
}
otelhttp.NewTransport автоматически добавляет заголовок traceparent в каждый исходящий запрос. Сервис-получатель видит этот заголовок и создаёт дочерний span — trace выстраивается в единую цепочку без дополнительного кода.
Инструментация базы данных через otelpgx
Каждый SQL-запрос — это отдельная операция с задержкой, которую полезно видеть. otelpgx подключается к pgx-пулу одной строкой:
// internal/platform/postgres/pool.go
import "go.opentelemetry.io/contrib/instrumentation/github.com/jackc/pgx/v5/otelpgx"
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.ConnConfig.Tracer = otelpgx.NewTracer()
return pgxpool.NewWithConfig(ctx, cfg)
}
После этого в Tempo (или Jaeger) вы увидите дочерние spans для каждого SQL-запроса с атрибутами db.statement, db.operation, db.name — и сразу поймёте, сколько времени занимает каждый запрос в контексте всего вызова.
Ручные spans для ключевых операций
Автоматические spans покрывают HTTP и базу данных. Но бизнес-логика между ними остаётся невидимой. Для этого добавляют ручные spans:
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
ctx, span := otel.Tracer("order").Start(ctx, "ConfirmOrder")
defer span.End()
span.SetAttributes(
attribute.String("order.id", cmd.OrderID),
attribute.String("payment.method", string(cmd.PaymentMethod)),
)
order, err := h.orders.Load(ctx, cmd.OrderID)
if err != nil {
span.RecordError(err, trace.WithStackTrace(true))
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("load order: %w", err)
}
if err = order.Confirm(); err != nil {
span.RecordError(err, trace.WithStackTrace(true))
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("confirm order: %w", err)
}
span.SetAttributes(attribute.String("order.status", string(order.Status)))
if err = h.orders.Save(ctx, order); err != nil {
span.RecordError(err, trace.WithStackTrace(true))
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("save order: %w", err)
}
return nil
}
Два важных момента:
defer span.End() сразу после Start — это Go-идиома, аналог try-finally в Java. defer гарантирует закрытие span при любом пути выполнения, включая панику. Забыть span.End() — значит потерять span целиком.
ctx с активным span передаётся дальше в h.orders.Load(ctx, ...) — именно так дочерние операции (SQL-запросы через otelpgx) становятся дочерними spans. Контекст — единственный механизм связи.
Что класть в span attributes, а что нет
Span attributes — это теги, по которым потом ищут и фильтруют traces. Здесь важно понимать одно ограничение: tracing-хранилища (Tempo, Jaeger) имеют более широкий доступ и другие сроки хранения, чем основная база данных. Персональные данные в атрибутах — нарушение требований.
Хорошо: идентификаторы (order.id, customer.id), статусы и enum-значения (order.status, payment.method), технические параметры (search.limit, circuit_breaker.state).
Плохо: email, телефон, номер карты, полный JSON тела запроса. Данные из которых можно восстановить личную информацию.
Внутренние UUID-идентификаторы (order.id, customer.id) — это нормально: они нужны для навигации к записи в основной базе данных, сами по себе ничего не раскрывают.
Sampling: сколько traces записывать
Записывать 100% запросов в продакшене — слишком дорого при любой нагрузке. Стандартный подход: 1% через TraceIDRatioBased, но 100% для ошибок.
Процент выборки задаётся в конфигурации TracerProvider (параметр SampleRate: 0.01 выше). При 100 запросах в секунду это 1 trace/с, около 86 000 traces в день — Tempo справляется без затрат.
Для 100% сохранения ошибочных traces настраивают tail-based sampling в OTel Collector — вне кода приложения:
# otel-collector/config.yaml
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: errors-policy
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow-traces-policy
type: latency
latency: {threshold_ms: 1000}
- name: probabilistic-policy
type: probabilistic
probabilistic: {sampling_percentage: 1}
errors-policy гарантирует: trace с ошибкой всегда сохранится, даже если он попал под 1% выборки. Медленные запросы (больше 1 секунды) тоже сохраняются полностью.
Trace ID в логах
Когда в трассировке видна ошибка, следующий шаг — посмотреть логи этого запроса. Для этого в каждую строку лога нужно добавить trace_id.
Если пробрасывать ctx в slog-методы (InfoContext, WarnContext, ErrorContext) и настроить OTel-slog bridge, trace_id и span_id добавляются в лог-записи автоматически:
h.log.InfoContext(ctx, "order_confirmed",
slog.String("order_id", cmd.OrderID),
slog.String("customer_id", order.CustomerID),
)
В Loki запись будет содержать "trace_id": "5e92c8a3b1f4d2e6…" — кликаете, переходите в Tempo, видите весь путь запроса. Добавлять trace_id вручную через ctx.Value(...) — лишнее: bridge делает это надёжнее и единообразнее.
Горутины и разрыв trace
Частая проблема: горутина стартует с context.Background() и создаёт отдельный корневой span — связь с родительским trace теряется.
// Плохо — trace разорван
go func() {
h.notify(context.Background(), order.CustomerID)
}()
// Хорошо — передаём родительский контекст аргументом
notifyCtx, notifyCancel := context.WithTimeout(ctx, 5*time.Second)
defer notifyCancel()
go func(ctx context.Context) {
if err := h.notify(ctx, order.CustomerID); err != nil {
h.log.WarnContext(ctx, "customer_notify_failed",
slog.String("customer_id", order.CustomerID),
slog.String("error", err.Error()),
)
}
}(notifyCtx)
Контекст передаётся аргументом (не захватывается через замыкание) — это защита от случая, когда родительская горутина отменит ctx раньше, чем дочерняя завершит работу.
Коротко
- TracerProvider — настраивается один раз при старте, регистрируется глобально через
otel.SetTracerProvider. Возвращаетshutdownдля корректного завершения. - otelchi + otelhttp.NewTransport — автоматические spans для входящих и исходящих HTTP без кода в обработчиках.
- otelpgx — один
cfg.ConnConfig.Tracer = otelpgx.NewTracer()делает все SQL-запросы видимыми в трассировке. - Ручной span:
ctx, span := otel.Tracer("...").Start(ctx, "...")+defer span.End()сразу же. Передавайте обновлённыйctxдальше — это связывает дочерние операции. - Span attributes — внутренние ID и enum-значения. Персональные данные (email, карта, телефон) — не класть.
- Sampling: 1% (
TraceIDRatioBased(0.01)) в продакшене + tail-based в OTel Collector для 100% ошибок. - Горутина с
context.Background()разрывает trace. Передавайте родительскийctxаргументом.
Что почитать дальше
- Логирование в Go —
slog, OTel-slog bridge, связкаtrace_idс log-записью. - Context propagation в Go —
context.Contextкак единственный механизм распространения; горутины и fan-out. - Метрики в Go —
prometheus/client_golang, почему высокая кардинальность не для метрик. - SLO и алерты — multi-window burn-rate alerts, error budget.