Опирается на правила: R-OBS-TRC-1R-OBS-TRC-6 и R-OBS-TRC-X1R-OBS-TRC-X4 из Observability Style Guide → раздел 3. Tracing.

Важно знать

  • OTel автоинструментация через otelhttp.NewHandler на chi-роутере даёт auto-spans для каждого HTTP-запроса.
  • traceparent W3C Trace Context пробрасывается входящим middleware и исходящим otelhttp.NewTransport автоматически.
  • pgx и HTTP-клиент инструментируются через otelpgx и otelhttp.NewTransport — без ручного кода в queries.
  • Manual spans для UseCase-хендлеров: otel.Tracer("…").Start(ctx, "…") + обязательный defer span.End().
  • Span attributes — business context (order.id, payment.method), не PII: не customer.email, не card.pan.
  • SamplingTraceIDRatioBased(0.01) в ParentBased в проде + tail-based для ошибок в OTel Collector.
  • trace_id/span_id в логах — автоматически через OTel-slog bridge; руками не добавлять.
  • Горутина с context.Background() разрывает trace — пробрасывай родительский ctx аргументом.

Tracing — третья нога observability. Когда метрики говорят «p95 вырос», а логи — «много ошибок в product-service», trace показывает конкретный запрос сквозь сервисы: где 180ms провели в pgx, где 900ms в HTTP-вызове в Sber API, где упало. В Go нет авто-инструментации уровня javaagent, но contrib-пакеты покрывают net/http, pgx/v5, kafka-go одной строкой подключения.

Настройка TracerProvider

R-OBS-TRC-1: инициализация в 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.DeploymentEnvironment(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
}

ParentBased уважает решение вышестоящего сервиса: если входящий traceparent помечен sampled=1, дочерний сервис тоже включается в trace независимо от своего ratio. Для low-traffic сервисов (<10 req/s) SampleRate: 1.0 — storage не перегружается.

Инициализация и graceful shutdown в main:

// cmd/order-service/main.go
shutdown, err := tracing.Setup(ctx, tracing.Config{
    OTLPEndpoint: cfg.OTLPEndpoint,
    ServiceName:  "order-service",
    Version:      version,
    Env:          cfg.AppEnv,
    SampleRate:   0.01,
})
if err != nil {
    log.Fatal("tracing setup", slog.String("error", err.Error()))
}
defer func() { _ = shutdown(ctx) }()

Автоинструментация chi-роутера и HTTP-клиента

R-OBS-TRC-1, R-OBS-TRC-2: оборачиваем роутер и Transport.

// cmd/order-service/main.go
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(otelchi.Middleware("order-service", otelchi.WithChiRoutes(router)))
router.Use(chiMiddleware.Logger)
// ... регистрация маршрутов

businessSrv := &http.Server{
    Addr:    cfg.Addr,
    Handler: router,
}

Для исходящего HTTP-клиента (вызов Sber API или product-service):

// internal/platform/httpclient/client.go
func New() *http.Client {
    return &http.Client{
        Transport: otelhttp.NewTransport(http.DefaultTransport),
        Timeout:   10 * time.Second,
    }
}

otelhttp.NewTransport автоматически добавляет traceparent в исходящие заголовки — сервис-получатель видит trace и создаёт child span без дополнительного кода.

Инструментация pgx

R-OBS-TRC-1: otelpgx подключается к пулу соединений.

// 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)
}

После этого каждый SQL-запрос через pgx создаёт дочерний span с атрибутами db.statement, db.operation, db.name — видно в Tempo без единой строки в прикладном коде.

Manual spans для UseCase-хендлеров

R-OBS-TRC-3: хендлеры добавляют named span с бизнес-атрибутами.

// internal/order/usecase/confirm_order.go
package usecase

import (
    "context"
    "fmt"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
)

type ConfirmOrderHandler struct {
    orders  OrderRepository
    sberPay SberPayGateway
}

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)
        span.SetStatus(codes.Error, err.Error())
        return fmt.Errorf("load order: %w", err)
    }

    if err = order.Confirm(); err != nil {
        span.RecordError(err)
        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)
        span.SetStatus(codes.Error, err.Error())
        return fmt.Errorf("save order: %w", err)
    }

    return nil
}

defer span.End() — идиоматичный Go-эквивалент Java try-finally. В Go нет try-with-resources, defer — единственный надёжный гарант закрытия span при любом пути выполнения, включая panic.

Аналогичный паттерн для поиска продуктов:

// internal/product/usecase/search_products.go
func (h *SearchProductsHandler) Handle(ctx context.Context, q SearchProductsQuery) ([]Product, error) {
    ctx, span := otel.Tracer("product").Start(ctx, "SearchProducts")
    defer span.End()

    span.SetAttributes(
        attribute.String("search.query", q.Query),
        attribute.Int("search.limit", q.Limit),
    )

    products, err := h.catalog.Search(ctx, q)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return nil, fmt.Errorf("catalog search: %w", err)
    }

    span.SetAttributes(attribute.Int("search.results", len(products)))
    return products, nil
}

Span attributes — business context, не PII

R-OBS-TRC-4: tracing-хранилище (Tempo, Jaeger) имеет другой режим доступа и другой retention. PII там — нарушение compliance.

РазрешеноЗапрещено
order.id, customer.id (внутренние ID)customer.email, customer.phone
order.status, payment.method (enum)card.pan, card.cvv
product.id, product.skuproduct.description (может содержать ПД)
sber.request_idsber.api_key, sber.session_token
circuit_breaker.stateполный JSON тела запроса

Внутренние UUID-идентификаторы (order.id, customer.id) — норма: они нужны для навигации к записи в основной БД.

Sampling

R-OBS-TRC-5: 1-10% в проде через TraceIDRatioBased.

В Go SDK sampling-стратегия задаётся при создании TracerProvider — одна строка конфигурации (SampleRate: 0.01). В prod-конфиге нагруженного сервиса достаточно 1%: 100 req/s → 1 trace/s → ~86K traces/день, Tempo справляется без затрат.

Tail-based sampling настраивается в OTel Collector вне Go-кода:

# 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 гарантирует 100% сохранение traces с ошибками — error-traces не попадают под ratio-обрезку.

TraceId в логах через slog bridge

R-OBS-TRC-6: go.opentelemetry.io/contrib/bridges/otelslog автоматически добавляет trace_id/span_id в каждую slog-запись, если в контексте есть активный span.

// internal/platform/log/setup.go
import "go.opentelemetry.io/contrib/bridges/otelslog"

func New(env string) *slog.Logger {
    var handler slog.Handler
    if env == "production" {
        handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
    } else {
        handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
    }
    return slog.New(otelslog.NewHandler("order-service", otelslog.WithLoggerProvider(otel.GetLoggerProvider()),
        otelslog.WithSource(true),
    ))
}

Если OTel logger provider не нужен отдельно, достаточно пробрасывать ctx в slog-методы — bridge извлекает trace_id из активного span в контексте:

// span активен в ctx после otel.Tracer(...).Start(ctx, ...)
h.log.InfoContext(ctx, "order_confirmed",
    slog.String("order_id", cmd.OrderID),
    slog.String("customer_id", order.CustomerID),
)

JSON в Loki будет содержать "trace_id": "5e92c8a3b1f4d2e6…" — клик → Tempo → весь distributed trace.

Горутины и разрыв trace

R-OBS-TRC-X4 / R-OBS-CTX-X3: горутина, стартующая с context.Background(), создаёт отдельный root span — trace разрывается, parent связь теряется.

// ПЛОХО — trace разорван
go func() {
    h.notify(context.Background(), order.CustomerID)
}()

// ХОРОШО — пробрасываем родительский ctx аргументом
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 аргументом (не захват из замыкания) — защита от случая, когда родительская горутина отменит ctx раньше, чем дочерняя дочитает данные.

Что запрещено

АнтипаттернПравилоЧто взамен
sdktrace.AlwaysSample() в продеR-OBS-TRC-X1TraceIDRatioBased(0.01) + tail-based в коллекторе
PII в span attributes (customer.email, card.pan)R-OBS-TRC-X2только внутренние ID и enum-значения
Manual span без defer span.End()R-OBS-TRC-X3всегда defer span.End() сразу после Start
Горутина с context.Background()R-OBS-TRC-X4пробрасывать родительский ctx аргументом
Прямой Zipkin / Jaeger client вместо OTelR-OBS-TRC-1go.opentelemetry.io/otel — отраслевой стандарт
HTTP-клиент без otelhttp.NewTransportR-OBS-TRC-2otelhttp.NewTransport добавляет traceparent
trace_id руками через ctx.Value(...) в slogR-OBS-TRC-6OTel-slog bridge добавляет автоматически

Куда дальше

  • Logging — slog, OTel-slog bridge, связка trace_id с log-записью.
  • Context propagation — context.Context как единственный механизм propagation; горутины и fan-out.
  • Metrics — prometheus/client_golang, почему high-cardinality не для метрик.
  • Конфигурация — два HTTP-сервера, management-порт, управление уровнем логов.
  • Health checks — /health/live, /health/ready, TTL-кеш для внешних проверок.
  • SLO и алерты — multi-window burn-rate alerts, error budget.