← назад к разделу

Представьте: запрос приходит в ваш сервис, проходит через базу данных, вызывает внешний 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.