Перейти к содержимому

Distributed Tracing: OpenTelemetry в Go

Зачем знать: в микросервисной архитектуре один пользовательский запрос проходит через 5-50 сервисов. Логи показывают «что произошло», метрики — «сколько раз», но только трассы отвечают «где и почему медленно». В 2026 OpenTelemetry — единый стандарт; middle 1 Go-разработчик обязан уметь инструментировать сервис, прокидывать context и читать flame graph в Tempo/Jaeger.

  1. Базовая концепция
  2. Как в Go (с примерами)
  3. Gotchas
  4. Best practices в production
  5. Вопросы на собесе
  6. Practice
  7. Источники

Distributed tracing — это техника наблюдения за запросом, который пересекает границы процесса/сервиса. Каждое действие записывается как span (отрезок времени с метаданными), spans связаны причинно-следственной связью и образуют trace (дерево).

Пример: пользователь нажимает «Купить»:

trace_id=abc
├── span: api-gateway: POST /buy [120ms]
│ ├── span: auth-svc: VerifyToken [10ms]
│ ├── span: cart-svc: GetCart [25ms]
│ │ └── span: redis: GET cart:user123 [3ms]
│ ├── span: payment-svc: Charge [60ms]
│ │ └── span: external: stripe.com [55ms] ← вот и узкое место
│ └── span: order-svc: CreateOrder [20ms]
│ └── span: postgres: INSERT orders [12ms]
  • Видеть где медленно (latency breakdown по сервисам).
  • Понимать зависимости (карта сервисов).
  • Дебажить ошибки, которые ловятся не там, где случились.
  • Коррелировать с логами и метриками через trace_id.

В 2026 OTel — индустриальный стандарт. История:

  • OpenTracing (2016) — API стандарт.
  • OpenCensus (Google, 2018) — API + SDK.
  • OpenTelemetry (2019) — слияние двух выше.

OTel = API + SDK + semantic conventions + protocol (OTLP).

Дерево spans с одним TraceID (16 байт, обычно hex 32 символа).

Один отрезок времени:

  • Name (короткое: db.query, http.request).
  • SpanID (8 байт hex).
  • ParentSpanID (для иерархии).
  • StartTime / EndTime (длительность).
  • Attributes (key-value).
  • Events (точки во времени внутри span).
  • Status (Ok, Error).

Trace должен пересекать процесс. Когда сервис A зовёт B по HTTP, в заголовке передаётся traceparent:

traceparent: 00-{trace_id}-{parent_span_id}-{trace_flags}

Сервис B читает этот header, создаёт child span. Стандарт — W3C TraceContext.

Произвольные key-value, которые пробрасываются вместе с trace context (например, user_id). Не для большой нагрузки!

Решает, записывать ли trace полностью. Без sampling — слишком много данных.

  • AlwaysOn — 100% (dev).
  • AlwaysOff — 0%.
  • TraceIDRatioBased(0.1) — 10%.
  • ParentBased — наследовать от parent (если parent sampled, дети тоже).
  • Tail sampling — решение принимается в Collector после получения всего trace (полезно: «оставить только ошибочные и медленные»).

Куда отправлять spans. В 2026 фактически все используют OTLP (gRPC или HTTP/protobuf) → OTel Collector → бэкенд.

  • Jaeger — open-source классика (CNCF).
  • Tempo (Grafana) — дешёвое хранилище, ищет по trace_id, использует логи/метрики для запросов.
  • Zipkin — старый формат, но всё ещё используется.
  • Datadog APM, Honeycomb, New Relic, Lightstep — коммерческие.

Пайплайн: receivers → processors → exporters. Без него ваш сервис шлёт прямо в backend, но Collector добавляет:

  • Batching (отправка пачками).
  • Retry, queue (надёжность).
  • Tail sampling.
  • Преобразования (filter, attributes).
  • Multiple exporters (один трасса → Jaeger + Datadog).

Окно терминала
go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/sdk
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp

В 2026 актуальная версия SDK — v1.30+, API стабильное.

package main
import (
"context"
"log"
"os"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func initTracer(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
exp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(endpoint), // "otel-collector:4317"
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion("1.0.0"),
semconv.DeploymentEnvironment(os.Getenv("ENV")),
),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp,
sdktrace.WithBatchTimeout(5*time.Second),
sdktrace.WithMaxExportBatchSize(512),
),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return tp.Shutdown, nil
}
func main() {
ctx := context.Background()
shutdown, err := initTracer(ctx, "payments", "otel-collector:4317")
if err != nil {
log.Fatal(err)
}
defer shutdown(ctx)
// ... ваш сервис
}
import "go.opentelemetry.io/otel"
var tracer = otel.Tracer("payments-service")
func ProcessPayment(ctx context.Context, userID string, amount int64) error {
ctx, span := tracer.Start(ctx, "ProcessPayment")
defer span.End()
span.SetAttributes(
attribute.String("user.id", userID),
attribute.Int64("payment.amount", amount),
)
if err := validate(ctx, userID, amount); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "validation failed")
return err
}
span.AddEvent("validation_passed")
if err := charge(ctx, userID, amount); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "charge failed")
return err
}
return nil
}
func validate(ctx context.Context, userID string, amount int64) error {
_, span := tracer.Start(ctx, "validate")
defer span.End()
// ...
return nil
}

tracer.Start возвращает новый ctx с прокинутым span. Передавайте дальше — child-spans автоматически найдут parent через ctx.

import (
"net/http"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", usersHandler)
// Обёртка создаёт span для каждого запроса автоматически
handler := otelhttp.NewHandler(mux, "users-api",
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
return r.Method + " " + r.URL.Path
}),
)
http.ListenAndServe(":8080", handler)
}
client := http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.com/users", nil)
resp, _ := client.Do(req)

traceparent header добавляется автоматически.

import (
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
)
// Server
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
// Client
conn, _ := grpc.NewClient("payments:9090",
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)

(Раньше использовались interceptors — otelgrpc.UnaryServerInterceptor() — но в 2026 рекомендуется StatsHandler, он покрывает streams корректно.)

import (
"github.com/XSAM/otelsql"
"database/sql"
_ "github.com/lib/pq"
)
db, err := otelsql.Open("postgres", connStr,
otelsql.WithAttributes(semconv.DBSystemPostgreSQL),
)
// Регистрация метрик пула
otelsql.RegisterDBStatsMetrics(db, otelsql.WithAttributes(...))

Каждый db.QueryContext создаёт span db.query с атрибутами db.statement, db.operation.

import (
"github.com/redis/go-redis/extra/redisotel/v9"
"github.com/redis/go-redis/v9"
)
rdb := redis.NewClient(&redis.Options{Addr: "redis:6379"})
if err := redisotel.InstrumentTracing(rdb); err != nil {
log.Fatal(err)
}

Команды GET, SET создают spans.

import (
"go.opentelemetry.io/contrib/instrumentation/github.com/segmentio/kafka-go/otelkafkago"
)
// или для confluent-kafka-go / sarama — отдельные пакеты

Producer/consumer оборачиваются, headers заполняются traceparent-ом.

Если нет auto-instrumentation (например, кастомный transport):

// Sender side
carrier := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(ctx, carrier)
// положить carrier в headers сообщения
// Receiver side
carrier := propagation.MapCarrier(headers) // headers — map[string]string
ctx = otel.GetTextMapPropagator().Extract(ctx, carrier)
// теперь tracer.Start(ctx, ...) подцепит parent
span.AddEvent("cache_miss",
trace.WithAttributes(attribute.String("key", k)),
)
span.AddEvent("cache_filled",
trace.WithTimestamp(time.Now()),
)

Events отображаются как точки во времени внутри span.

Связь span с другим trace (например, batch обрабатывает много исходных запросов):

spanCtx := trace.SpanContextFromContext(parentCtx)
ctx, span := tracer.Start(ctx, "batch.process",
trace.WithLinks(trace.Link{SpanContext: spanCtx}),
)
import "go.opentelemetry.io/otel/baggage"
mem, _ := baggage.NewMember("user_id", "u-123")
b, _ := baggage.New(mem)
ctx = baggage.ContextWithBaggage(ctx, b)

В следующем сервисе:

b := baggage.FromContext(ctx)
userID := b.Member("user_id").Value()

baggage уходит в baggage header. Не кладите много — это дорого по сети.

type CustomSampler struct{}
func (s CustomSampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult {
for _, attr := range p.Attributes {
if attr.Key == "http.url" && strings.Contains(attr.Value.AsString(), "/health") {
return sdktrace.SamplingResult{Decision: sdktrace.Drop}
}
}
return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample}
}
func (s CustomSampler) Description() string { return "custom" }

Часто /health, /metrics дропаются, чтобы не засорять.

func traceCtxAttrs(ctx context.Context) []slog.Attr {
span := trace.SpanFromContext(ctx)
sc := span.SpanContext()
if !sc.IsValid() {
return nil
}
return []slog.Attr{
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
}
}

Логи получают trace_id — в Grafana клик переносит на trace в Tempo.

OTel поддерживает не только traces, но и metrics, logs (через одну SDK). В 2026 в Go: metrics — стабильно, logs — beta. Можно использовать OTel metrics вместо Prometheus client, экспортируя в Prometheus через collector. Но в большинстве проектов: metrics — Prometheus, traces — OTel, logs — slog → Loki.


func handler(w http.ResponseWriter, r *http.Request) {
go process() // BAD: новый goroutine без ctx
}
func handler(w http.ResponseWriter, r *http.Request) {
go process(r.Context()) // GOOD
}

Без ctx tracer.Start создаст root span — связь с запросом потеряна.

ctx, span := tracer.Start(ctx, "op")
defer span.End() // ОБЯЗАТЕЛЬНО, иначе span не закроется и не уйдёт

Если забыли — span зависнет в SDK буфере. При перезапуске потеряется.

sdktrace.WithSampler(sdktrace.AlwaysSample()) // BAD в prod — много данных

100% sampling в prod = терабайты в неделю. Используйте TraceIDRatioBased(0.05-0.1) или tail-based в collector.

Не клейте user_id, request_id в attributes как сотни тысяч уникальных значений — backend (Jaeger/Tempo) индексирует, и cardinality растёт. Лучше user_id один раз в root span, не в каждом child.

span.SetAttributes(attribute.String("response.body", body)) // BAD: тело 1MB

Bбекенды режут до ~10KB. Большие данные → s3 link.

Аналогично логам: не клейте пароли, токены, email в attributes. У трасс retention обычно 14-30 дней — но всё равно лучше mask или вообще не писать.

tracer.Start(ctx, fmt.Sprintf("GET /users/%s", id)) // BAD: имя span содержит user_id

Span names как метрики — должны быть низкой cardinality. Используйте route pattern: GET /users/:id.

if err != nil {
span.RecordError(err) // запишет error.* attributes
// span.SetStatus(codes.Error, err.Error()) // нужно отдельно!
return err
}

RecordError НЕ меняет статус span на Error. Делайте оба.

defer tp.Shutdown(ctx) // flush buffered spans

Без shutdown буферизированные spans потеряются при выходе.

tracer.Start(ctx, ...) не уважает ctx.Deadline() — span не отменится. Но операции внутри должны уважать ctx.

Span на каждую функцию = огромные деревья и шум. Один span на «работу» (HTTP-handler, DB-query, RPC-call). Внутри handler — события, attributes.

Не все клиенты поддерживают OTel headers по умолчанию. Если RabbitMQ — пишите сами в headers сообщения; читая — extract вручную.

go.opentelemetry.io/otel/semconv/v1.26.0 — версия семантических соглашений. Атрибуты меняются (например, было http.status_code, стало http.response.status_code). При обновлении SDK проверьте — backend дашборды могут сломаться.

sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))

Если parent sampled, ваш сервис тоже sample (даже если 0.1). Это критично для consistency — нельзя дропать trace в середине.

import "go.opentelemetry.io/otel" // API
import "go.opentelemetry.io/otel/sdk/trace" // SDK

Библиотеки должны зависеть только от API (otel). SDK — только в main сервиса. Это позволяет менять SDK без рекомпиляции зависимостей.


В collector настройте tail sampling:

processors:
tail_sampling:
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: probabilistic
type: probabilistic
probabilistic: { sampling_percentage: 5 }

Получаете 100% ошибочных, 100% медленных, 5% остальных.

В каждом сервисе:

  • service.name — нижний регистр, дефис: payments-service.
  • service.version — git SHA или semver.
  • deployment.environmentprod/staging/dev.

Это шум. Дропайте в SDK sampler или collector filter.

  • Auto: HTTP, gRPC, БД, Redis, Kafka — через готовые пакеты go.opentelemetry.io/contrib/....
  • Manual: бизнес-логика, важные шаги (валидация, расчёт).

Не отправляйте напрямую из приложения в backend. Collector:

  • Batching → меньше сетевых вызовов.
  • Retry/queue → надёжность.
  • Tail sampling → меньше данных.
  • Multiple exporters → migration легко.
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 75

Без этого collector OOM при пике трафика.

{"level":"ERROR","msg":"payment failed","trace_id":"abc","span_id":"def","err":"..."}

В Grafana: панель Tempo рядом с Loki, клик на trace_id в логе открывает trace.

OTel + Prometheus exemplars: метрика http_request_duration_seconds хранит ссылки на trace_id. Из dashboard клик на «медленную» точку → trace.

Используйте semconv пакет, не свои имена:

span.SetAttributes(
semconv.HTTPMethod(r.Method),
semconv.HTTPRoute("/users/:id"),
semconv.HTTPResponseStatusCode(200),
semconv.UserID("u-123"),
)

Дашборды и алерты в backend опираются на стандартные имена.

OAuth-callback, password reset — лучше игнорировать или strip query string.

Целевой overhead — < 5%. Если больше:

  • Снизьте sampling.
  • Меньше spans (агрегируйте мелкие операции).
  • Не вызывайте span.SetAttributes в hot loop.

Trace storage дорог. Tempo + S3 backend = $/TB. 7-14 дней retention обычно достаточно. Долгое хранение — только для аудита/compliance.

Метрика «процент запросов с trace_id» = SLI наблюдаемости. Если < 95% — auto-instrumentation сломана.

  • Sidecar (один collector на pod) — для очень больших инстансов.
  • Daemonset (один на node) — стандарт.
  • Deployment (центральный) — для tail sampling, нужен load balancer.

Обычно: agent (daemonset) → gateway (deployment) → backend.

Создавайте root span на запуск, передавайте ctx в горутины. Часто помогают span links: один batch span с links на исходные events.


  1. Что такое distributed tracing? Запись пути запроса через сервисы как дерева spans с общим trace_id. Используется для debugging latency и зависимостей.

  2. Чем OpenTelemetry отличается от OpenTracing? OTel = слияние OpenTracing (API) + OpenCensus (SDK) + новые vendor-neutral conventions. OpenTracing deprecated.

  3. Что такое span? Отрезок времени с именем, временем начала/конца, attributes, events, status. Имеет родителя — образует tree (trace).

  4. Как пробрасывается trace между сервисами? Через W3C TraceContext header traceparent (HTTP) или message headers (Kafka, RabbitMQ).

  5. Что такое sampling? Решение, записывать ли trace. AlwaysOn (dev), TraceIDRatioBased(0.1) для prod, ParentBased для consistency.

  6. Чем tail-based sampling лучше head-based? Head-based решает в начале (теряет редкие ошибки если sample rate низкий). Tail-based — в collector, имея весь trace (оставляет ошибки и медленные).

  7. Что такое OTLP? OpenTelemetry Protocol — gRPC/HTTP бинарный (protobuf) протокол отправки telemetry. Поддерживается всеми backends в 2026.

  8. Зачем OTel Collector? Batching, retry, tail sampling, преобразования, multiple exporters. Sделать в коде каждого сервиса нельзя — collector централизует.

  9. Чем RecordError отличается от SetStatus? RecordError добавляет event exception с attributes (msg, stacktrace). SetStatus(codes.Error) меняет статус span. Делайте оба.

  10. Что такое baggage? Key-value, пробрасываемые с trace context. Для бизнес-данных (user_id), но не для большого объёма.

  11. Чем tracer.Start отличается от просто создания span? Start возвращает новый ctx с прокинутым span. Без передачи ctx дальше — child spans не свяжутся.

  12. Что такое semantic conventions? Стандартные имена attributes: http.method, db.system, service.name. Позволяют backend универсально визуализировать.

  13. Как корелировать traces с логами? Класть trace_id и span_id из контекста в каждый лог-record (через custom handler в slog).

  14. Что такое exemplars? Ссылки из Prometheus метрики на trace_id. Клик в Grafana → flame graph конкретного запроса.

  15. Сколько накладных расходов добавляет OTel? Обычно < 5% CPU, ~100-200 ns на span. При 100% sampling и тысячах spans/sec может быть больше — снижайте sampling.

  16. Что такое span link? Связь между spans разных traces (например, batch обрабатывает 100 событий из 100 разных traces).

  17. Зачем ParentBased sampler? Чтобы child spans всегда наследовали решение от parent. Иначе trace будет «дырявым» (одни сервисы sample, другие нет).

  18. Чем defer span.End() важен? Без End span не отправится. Defer гарантирует вызов даже при панике.

  19. Что такое Jaeger / Tempo / Zipkin? Backends для traces. Jaeger — CNCF классика. Tempo — Grafana, ищет по trace_id, дешёвое хранилище. Zipkin — старый формат.

  20. Зачем Shutdown у TracerProvider? Flush буферизированных spans перед выходом. Без него — потеряются.

  21. Что такое propagator? Компонент, который сериализует/десериализует trace context в headers. По умолчанию TraceContext (W3C) + Baggage.

  22. Чем StatsHandler лучше Interceptor для gRPC? Покрывает streaming, более низкоуровневый, рекомендован OTel community с 2023.

  23. Что писать в span name? Низкая cardinality, короткое: db.query, http.request, kafka.produce. НЕ GET /users/123 (cardinality bomb), но GET /users/:id норм.

  24. Как тестировать tracing? sdktrace.NewTracerProvider с in-memory exporter (tracetest.SpanRecorder). Проверяете spans в Recorder.

  25. Как OTel помогает находить N+1 query? В trace видно: внутри HTTP-handler сотни spans db.query с одинаковым SQL — характерная картина N+1.


Создайте сервис «hello-world», подключите OTel SDK, экспорт в локальный Jaeger (docker run jaegertracing/all-in-one).

Сделайте 2 сервиса: A (gateway) → B (worker). Запрос в A создаёт span, A зовёт B по HTTP, B создаёт child span. Проверьте в Jaeger, что trace связан.

Сервис с gRPC + Postgres (otelgrpc + otelsql). Один gRPC-метод делает 3 SQL-запроса — в Jaeger увидьте 4 spans (gRPC + 3 SQL).

В функции CalculatePrice оберните цикл в span, добавьте attributes (items.count), events (discount_applied).

Подключите TraceIDRatioBased(0.1). Запустите 1000 запросов, посчитайте в Jaeger — ~100 traces. Затем ParentBased(TraceIDRatioBased(0.1)) — поведение должно быть таким же, но trace целостный.

Настройте collector на tail sampling: все traces с ошибкой и > 500ms, плюс 5% остальных.

Реализуйте slog.Handler, который читает trace_id/span_id из ctx и добавляет в каждый лог.

В сервисе A пробросьте user_id через baggage. В B прочитайте и используйте в логах.


  1. Официальная документация OpenTelemetryhttps://opentelemetry.io/docs/languages/go/.
  2. W3C TraceContext spechttps://www.w3.org/TR/trace-context/.
  3. Semantic Conventionshttps://opentelemetry.io/docs/specs/semconv/.
  4. OpenTelemetry Collectorhttps://opentelemetry.io/docs/collector/.
  5. Grafana Tempo docshttps://grafana.com/docs/tempo/.
  6. Jaeger Architecturehttps://www.jaegertracing.io/docs/latest/architecture/.
  7. “Distributed Systems Observability” by Cindy Sridharan (O’Reilly).
  8. CNCF OpenTelemetry Projecthttps://www.cncf.io/projects/opentelemetry/.
  9. Honeycomb blog about tracinghttps://www.honeycomb.io/blog (Charity Majors).
  10. otel-go contrib repohttps://github.com/open-telemetry/opentelemetry-go-contrib (готовые instrumentations).