Tracing в Go: OpenTelemetry и propagation

Trace показывает путь одной операции через сервис, БД, cache, broker и внешние API. Он особенно полезен, когда latency набирается не в одном месте, а по цепочке.

OpenTelemetry не стоит объяснять как "библиотеку для трейсов". Это стандартный контракт telemetry:

text
Go service -> OpenTelemetry SDK / instrumentation -> OTLP -> OpenTelemetry Collector -> Tempo, Jaeger, Prometheus, Loki, Elastic or vendor

На 6 мая 2026 в OpenTelemetry Go статус такой: traces stable, metrics stable, logs beta. Поэтому в учебном проекте основной путь: traces и metrics через OTel SDK, logs через slog JSON + collector/agent, а OTel logs показываем как advanced-тему.

Trace, span, attributes

text
trace: convert request span: HTTP POST /convert span: usecase.Convert span: postgres GetLatestRate span: redis Set cache span: kafka PublishConversionEvent

Trace - вся цепочка. Span - один участок работы. У span есть:

  • name: usecase.Convert;
  • kind: server, client, internal, producer, consumer;
  • start/end time;
  • attributes;
  • events;
  • links;
  • status;
  • recorded errors.

Хорошие span boundaries:

  • входящий HTTP/gRPC request;
  • use case;
  • repository call;
  • external API call;
  • Redis operation;
  • Kafka publish/consume;
  • background job.

Не надо создавать span на каждую мелкую функцию. Trace должен быть диагностической картой, а не дампом call stack.

Resource attributes

Resource отвечает на вопрос: "кто породил telemetry".

Обязательные поля:

AttributeПример
service.nameratedesk-api
service.namespacementor
service.version1.12.4 или git SHA
service.instance.idpod/container id
deployment.environment.nameproduction

Если не задать service.name, в Grafana/Tempo/Jaeger все быстро превращается в unknown_service.

Инициализация SDK

go
package telemetry import ( "context" "errors" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" "google.golang.org/grpc/credentials/insecure" ) func Init(ctx context.Context, service, env, version string) (func(context.Context) error, error) { res, err := resource.Merge(resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName(service), semconv.ServiceNamespace("mentor"), semconv.ServiceVersion(version), attribute.String("deployment.environment.name", env), )) if err != nil { return nil, err } traceExp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint("otel-collector:4317"), otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()), ) if err != nil { return nil, err } tp := trace.NewTracerProvider( trace.WithResource(res), trace.WithSampler(trace.ParentBased(trace.TraceIDRatioBased(0.10))), trace.WithBatcher(traceExp), ) otel.SetTracerProvider(tp) metricExp, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpoint("otel-collector:4317"), otlpmetricgrpc.WithTLSCredentials(insecure.NewCredentials()), ) if err != nil { return nil, err } mp := metric.NewMeterProvider( metric.WithResource(res), metric.WithReader(metric.NewPeriodicReader(metricExp, metric.WithInterval(15*time.Second))), ) otel.SetMeterProvider(mp) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) return func(ctx context.Context) error { return errors.Join(tp.Shutdown(ctx), mp.Shutdown(ctx)) }, nil }

Версию semconv пиньте под используемый go.opentelemetry.io/otel module. В примерах курса версия фиксирована ради повторяемости, но в реальном проекте проверяйте актуальную совместимую версию в pkg.go.dev.

В local/staging можно временно ставить 100% sampling. В production обычно начинают с head sampling, а дальше переходят к tail sampling в Collector для медленных/ошибочных traces.

Endpoint лучше брать из env, а не хардкодить в коде:

go
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") if endpoint == "" { endpoint = "otel-collector:4317" }

Порты:

EndpointПротоколКогда использовать
4317OTLP/gRPCЧастый production default для SDK -> Collector.
4318OTLP/HTTPУдобен для proxies, serverless, curl-like diagnostics.

В main shutdown telemetry должен быть частью graceful shutdown:

go
shutdownTelemetry, err := telemetry.Init(ctx, cfg.ServiceName, cfg.Env, cfg.Version) if err != nil { return fmt.Errorf("init telemetry: %w", err) } defer func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := shutdownTelemetry(ctx); err != nil { logger.Error("telemetry shutdown failed", "error", err) } }()

Если Collector временно недоступен, сервис не должен падать на каждом request. Ошибка exporter на старте - повод явно решить политику: в local можно fail fast, в production часто запускают сервис, но alertят на collector/exporter send failures.

Manual instrumentation

Manual instrumentation нужна там, где есть бизнес-смысл.

go
var tracer = otel.Tracer("ratedesk/internal/conversion") func (s *Service) Convert(ctx context.Context, req ConvertRequest) (ConvertResult, error) { ctx, span := tracer.Start(ctx, "usecase.Convert") defer span.End() span.SetAttributes( attribute.String("currency.from", req.From), attribute.String("currency.to", req.To), attribute.String("rate.source", req.Source), ) result, err := s.converter.Convert(ctx, req) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, safeStatus(err)) return ConvertResult{}, err } span.SetStatus(codes.Ok, "converted") return result, nil }

В attributes не кладите секреты, email, phone, request body и любые high-cardinality значения без явной причины.

Library instrumentation

Для сетевых и инфраструктурных границ используйте готовую instrumentation.

HTTP server/client:

go
handler := otelhttp.NewHandler(mux, "ratedesk-api") client := &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport), }

gRPC:

go
srv := grpc.NewServer( grpc.StatsHandler(otelgrpc.NewServerHandler()), ) conn, err := grpc.NewClient( addr, grpc.WithStatsHandler(otelgrpc.NewClientHandler()), )

SQL в Go экосистеме менее "однозначный", чем HTTP. На практике часто используют otelsql-подход и проверяют актуальность конкретного пакета под ваш driver.

Kafka и другие brokers: принцип важнее конкретной библиотеки. Producer inject trace context в message headers, consumer extract из headers и продолжает trace.

Для Echo можно использовать middleware вокруг стандартного http.Handler:

go
e := echo.New() e.Use(echo.WrapMiddleware(func(next http.Handler) http.Handler { return otelhttp.NewHandler(next, "ratedesk-api") }))

Проверьте, что span name и attribute http.route не превращаются в raw URL. Если instrumentation не знает route pattern, добавьте http.route в своем middleware после match route.

Propagation

Trace работает только пока вы не потеряли context.Context.

HTTP propagation:

  • traceparent;
  • tracestate;
  • optional baggage.

Kafka propagation:

  • headers;
  • producer inject;
  • consumer extract.

Пример для HTTP client обычно делает instrumentation, но вручную это выглядит так:

go
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) if err != nil { return err } otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

Consumer:

go
ctx := otel.GetTextMapPropagator().Extract( context.Background(), propagation.HeaderCarrier(headersFromMessage(msg)), ) ctx, span := tracer.Start(ctx, "kafka.consume rates.updated") defer span.End()

Пример carrier для Kafka-like headers без привязки к конкретной библиотеке:

go
type MessageHeader struct { Key string Value []byte } type HeaderCarrier struct { Headers []MessageHeader } func (c HeaderCarrier) Get(key string) string { for _, h := range c.Headers { if strings.EqualFold(h.Key, key) { return string(h.Value) } } return "" } func (c *HeaderCarrier) Set(key, value string) { for i := range c.Headers { if strings.EqualFold(c.Headers[i].Key, key) { c.Headers[i].Value = []byte(value) return } } c.Headers = append(c.Headers, MessageHeader{Key: key, Value: []byte(value)}) } func (c HeaderCarrier) Keys() []string { keys := make([]string, 0, len(c.Headers)) for _, h := range c.Headers { keys = append(keys, h.Key) } return keys }

В реальной Kafka-библиотеке carrier пишется поверх типа headers этой библиотеки. Главное - сохранить traceparent и tracestate как message headers, а не класть trace id в payload.

baggage не место для PII. Все, что вы положили в baggage, может автоматически уйти downstream.

Что можно класть в baggage только при явной политике:

  • tenant_tier=free|pro|enterprise;
  • request_class=interactive|batch;
  • region=ru|eu|us.

Что нельзя: email, phone, user id, token/session id, номер карты, договор, паспорт, произвольный payload.

Semantic conventions

OpenTelemetry semantic conventions - общий словарь. Не придумывайте db_name, method, status, если есть стандартные ключи для HTTP, DB, RPC, messaging.

Примеры:

go
span.SetAttributes( semconv.HTTPRequestMethodKey.String("POST"), semconv.HTTPRouteKey.String("/convert"), semconv.HTTPResponseStatusCode(500), )

Для доменных атрибутов используйте свой namespace:

text
ratedesk.currency.from ratedesk.currency.to ratedesk.rate.source

Errors и status

Не каждая ошибка должна делать span Error.

Примеры:

  • validation error: span может остаться Ok, HTTP status покажет 400;
  • rate not found: доменный NotFound, часто не production incident;
  • postgres timeout: RecordError, SetStatus(codes.Error, ...);
  • retry attempt: span event retry.attempt.
go
if err != nil { span.RecordError(err, trace.WithAttributes(attribute.String("error.kind", classify(err))), ) span.SetStatus(codes.Error, "dependency timeout") return err }

Logs correlation

У логов и traces должен быть общий ключ. Минимум: trace_id и span_id в structured logs.

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

Тогда в Grafana можно идти:

text
latency spike -> exemplar trace -> trace span -> related logs -> request_id

Sampling

Head sampling принимает решение в начале trace. Простой и дешевый вариант:

go
trace.ParentBased(trace.TraceIDRatioBased(0.10))

Минус: можно случайно не сохранить интересный ошибочный trace.

Tail sampling принимает решение после того, как trace почти собран. Его обычно делают в Collector gateway:

  • оставить все error traces;
  • оставить traces дольше 1s;
  • оставить часть нормальных traces;
  • разные правила для staging/prod.

В учебном проекте достаточно 100% local sampling и README-секции: почему в production будет иначе.

СредаSamplingПочему
local100%Нужно видеть каждый учебный запрос.
staging100% или высокий ratioТрафик небольшой, важно ловить regressions.
productionparent-based ratio + tail sampling в gatewayНельзя хранить все, но важно сохранять errors/slow traces.

Если нужен tail sampling в Collector, не выбрасывайте нужные traces слишком рано в SDK. Head sampling 10% в приложении означает, что Collector уже никогда не увидит остальные 90%, включая часть ошибочных.

OTel logs

OTel logs в Go сейчас стоит показывать аккуратно:

  • основной production-путь: slog JSON -> stdout -> Alloy/Fluent Bit/filelog -> Loki/Elastic;
  • advanced-путь: slog bridge / OTLP logs -> OTel Collector -> logs backend.

Причина: traces и metrics в Go стабильнее и привычнее для внедрения; logs через OTel полезны для unified pipeline, но не должны ломать понятный базовый путь.

Trace quality checklist

Хороший trace:

  • начинается на transport boundary: HTTP/gRPC/consumer;
  • имеет понятные span names: HTTP POST /convert, usecase.Convert, postgres.GetLatestRate;
  • не создает span на каждую мелкую функцию;
  • содержит standard semantic attributes для HTTP/DB/RPC/messaging;
  • содержит доменные attributes только с ограниченной кардинальностью;
  • записывает RecordError и status для системных ошибок;
  • добавляет events для retry, fallback, circuit breaker, cache miss;
  • не теряет context.Context между goroutines и adapters;
  • позволяет за 30 секунд понять, где набралась latency.

Что сделать в RateDesk

  1. Инициализировать OTel SDK при старте сервиса.
  2. Добавить resource attributes.
  3. Включить W3C Trace Context.
  4. Обернуть HTTP server/client.
  5. Добавить spans на use case, repository, Redis, Kafka/provider.
  6. Добавить trace_id/span_id в logs.
  7. Экспортировать traces в OTel Collector.
  8. Проверить trace в Tempo/Jaeger.

Acceptance:

  • один /convert виден как полный trace;
  • DB/Redis/provider spans дочерние для use case;
  • errors записываются в spans;
  • logs содержат trace_id;
  • context не теряется между слоями;
  • README объясняет sampling и propagation;
  • в MR есть ссылка/скрин trace или сохраненный trace id из локального stack.

Интерактивная практика

Quiz+10 XP

Где обычно должен начинаться trace backend-запроса?

  • На transport boundary: HTTP/gRPC/consumer
  • В каждой маленькой helper-функции
  • Только внутри database driver
  • Только в logger middleware
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func TraceDecision(hasParent bool) string { if hasParent { return "propagate" } return "new-root" } func main() { fmt.Println(TraceDecision(true)) fmt.Println(TraceDecision(false)) }
Задача+20 XP

Реализуй SpanAction: системная ошибка - record-error, retry/fallback - add-event, bounded route/status/dependency info - attribute.