Tracing в Go: OpenTelemetry и propagation
Trace показывает путь одной операции через сервис, БД, cache, broker и внешние API. Он особенно полезен, когда latency набирается не в одном месте, а по цепочке.
OpenTelemetry не стоит объяснять как "библиотеку для трейсов". Это стандартный контракт telemetry:
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
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.name | ratedesk-api |
service.namespace | mentor |
service.version | 1.12.4 или git SHA |
service.instance.id | pod/container id |
deployment.environment.name | production |
Если не задать service.name, в Grafana/Tempo/Jaeger все быстро превращается в unknown_service.
Инициализация SDK
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, а не хардкодить в коде:
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if endpoint == "" {
endpoint = "otel-collector:4317"
}
Порты:
| Endpoint | Протокол | Когда использовать |
|---|---|---|
4317 | OTLP/gRPC | Частый production default для SDK -> Collector. |
4318 | OTLP/HTTP | Удобен для proxies, serverless, curl-like diagnostics. |
В main shutdown telemetry должен быть частью graceful shutdown:
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 нужна там, где есть бизнес-смысл.
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:
handler := otelhttp.NewHandler(mux, "ratedesk-api")
client := &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
gRPC:
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:
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, но вручную это выглядит так:
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return err
}
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
Consumer:
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 без привязки к конкретной библиотеке:
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.
Примеры:
span.SetAttributes(
semconv.HTTPRequestMethodKey.String("POST"),
semconv.HTTPRouteKey.String("/convert"),
semconv.HTTPResponseStatusCode(500),
)
Для доменных атрибутов используйте свой namespace:
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.
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.
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 можно идти:
latency spike -> exemplar trace -> trace span -> related logs -> request_id
Sampling
Head sampling принимает решение в начале trace. Простой и дешевый вариант:
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 | Почему |
|---|---|---|
| local | 100% | Нужно видеть каждый учебный запрос. |
| staging | 100% или высокий ratio | Трафик небольшой, важно ловить regressions. |
| production | parent-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
- Инициализировать OTel SDK при старте сервиса.
- Добавить resource attributes.
- Включить W3C Trace Context.
- Обернуть HTTP server/client.
- Добавить spans на use case, repository, Redis, Kafka/provider.
- Добавить
trace_id/span_idв logs. - Экспортировать traces в OTel Collector.
- Проверить 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.
Интерактивная практика
Где обычно должен начинаться trace backend-запроса?
Что выведет этот код?
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))
}
Реализуй SpanAction: системная ошибка - record-error, retry/fallback - add-event, bounded route/status/dependency info - attribute.