voidlearn
 

5.3OpenTelemetry в Go-сервисе

Сопоставишь OpenTelemetry API и SDK, настроишь экспорт и проследишь контекст через HTTP, gRPC и очередь. Поймёшь, почему надо завершать batch processor и чем head sampling отличается от tail sampling.

Что сможешь объяснить после этой главы
  • Почему библиотеки зависят только от API и что со спанами без SDK.
  • Чем OTLP по gRPC отличается от HTTP, зачем BatchSpanProcessor и что теряется без Shutdown.
  • Какие переменные OTEL_* Go SDK читает сам.
  • Как контекст идёт через HTTP, gRPC и Kafka, чем опасен baggage и зачем консьюмеру span link.
  • Почему при ParentBased решает корень, а tail sampling требует двух слоёв Collector.
  • Как привязать логи и метрики к трейсу и во что обходится инструментирование.

Прогоны: Go 1.27.1, go.opentelemetry.io/otel v1.46.0, contrib v0.71.0, otelpgx v0.12.0, franz-go v1.21.7; в контейнерах Collector contrib 0.161.0, Jaeger 2.21.0, Postgres 18 и Kafka 4.3.1.

API и SDK: что подключает библиотека, а что main

API (go.opentelemetry.io/otel, otel/trace) даёт интерфейсы: tracer, спан, атрибут. SDK (otel/sdk/…) их реализует: сэмплер, процессоры, экспортеры, ресурс. Библиотеки вроде otelhttp импортируют только API и берут глобальный провайдер, а SDK настраивает main, и экспортер с версией SDK выбирает сервис. Пока SDK нет, провайдер раздаёт пустышки:

tr := otel.Tracer("example.com/orders") // SDK никто не настраивал
ctx, span := tr.Start(context.Background(), "charge")
sc := span.SpanContext()
fmt.Printf("span: %T recording=%v valid=%v trace_id=%s\n", span, span.IsRecording(), sc.IsValid(), sc.TraceID())
span.End()

h := http.Header{}
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(h))
fmt.Printf("propagator fields=%v headers after Inject=%v\n", otel.GetTextMapPropagator().Fields(), h)

otel.SetTracerProvider(sdktrace.NewTracerProvider()) // SDK появился позже, tr тот же
_, span2 := tr.Start(context.Background(), "charge")
fmt.Printf("after SetTracerProvider: %T recording=%v valid=%v\n", span2, span2.IsRecording(), span2.SpanContext().IsValid())
$ go build -o noop ./cmd/noop && ./noop
span: global.nonRecordingSpan recording=false valid=false trace_id=00000000000000000000000000000000
propagator fields=[] headers after Inject=map[]
after SetTracerProvider: *trace.recordingSpan recording=true valid=true

Пустышка ничего не пишет и не падает. Tracer, взятый до настройки SDK, пересоздавать не нужно: глобальный провайдер передаст вызовы настоящему. А пропагатор по умолчанию пустой и заголовков не пишет даже при настоящем SDK.

Resource описывает источник (service.name, версию, окружение) и едет в каждой пачке OTLP. Без имени SDK подставит имя бинарника, и сервисы с бинарником app сольются в Jaeger в один:

$ go build -o orders ./cmd/resource && ./orders default | grep service.name
  service.name=unknown_service:orders

Ресурс по умолчанию читает OTEL_SERVICE_NAME и OTEL_RESOURCE_ATTRIBUTES. Если имя есть и в коде, решает порядок слияния: в resource.Merge(resource.Default(), свой) побеждает код, в resource.New с WithAttributes(…) и WithFromEnv() последний по списку детектор, то есть переменная.

Имена атрибутов задают семантические конвенции (semconv): в Go это пакеты по версиям (в otel v1.46.0 последний semconv/v1.43.0) со своим SchemaURL. Ресурсы с разными схемами не сливаются:

_, err := resource.Merge(resource.Default(), // внутри SDK схема 1.43.0
    resource.NewWithAttributes(oldsemconv.SchemaURL, // semconv/v1.26.0
        oldsemconv.ServiceName("orders")))
fmt.Println("err:", err)
// err: conflicting Schema URL: https://opentelemetry.io/schemas/1.43.0 and https://opentelemetry.io/schemas/1.26.0

Так бывает после обновления SDK со старым импортом semconv. Бери версию, которую импортирует sdk/resource, или resource.NewSchemaless.

TracerProvider: экспортер, процессор и Shutdown

func setupTracing(ctx context.Context, name string, ratio float64) (func(context.Context) error, error) {
    exp, err := otlptracegrpc.New(ctx) // адрес из OTEL_EXPORTER_OTLP_ENDPOINT, иначе localhost:4317
    if err != nil {
        return nil, fmt.Errorf("otlp exporter: %w", err)
    }
    res, err := resource.Merge(resource.Default(), resource.NewWithAttributes(semconv.SchemaURL,
        semconv.ServiceName(name), semconv.ServiceVersion(version)))
    if err != nil {
        return nil, fmt.Errorf("resource: %w", err)
    }
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp), // BatchSpanProcessor
        sdktrace.WithResource(res),
        sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),
    )
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{}, propagation.Baggage{}))
    return tp.Shutdown, nil
}

Экспортеров OTLP два: otlptracegrpc и otlptracehttp (POST на /v1/traces, по умолчанию protobuf). Collector слушает gRPC на 4317, HTTP на 4318. Порт перепутан (export создаёт спаны и зовёт Shutdown с таймаутом 5 секунд):

$ OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 ./export -proto grpc -n 5
grpc/batch: 5 spans, span.End() avg 1µs
shutdown error: context deadline exceeded
Shutdown took 5.002s

gRPC-экспортер считает недоступность временной и повторяет попытки до конца контекста, наружу выходит только context deadline exceeded. Так же молча ломается WithEndpoint без WithInsecure(): экспортер идёт по TLS (в переменной это решает схема, http:// или https://). HTTP-экспортер на 4317 сразу пишет в лог malformed HTTP response.

SimpleSpanProcessor экспортирует спан прямо в End(), BatchSpanProcessor кладёт в очередь на 2048 спанов и раз в 5 секунд шлёт пачки до 512 штук:

$ export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
$ ./export -processor batch -n 20
grpc/batch: 20 spans, span.End() avg 0s
…
$ ./export -processor simple -n 20
grpc/simple: 20 spans, span.End() avg 451µs
…
$ OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4999 ./export -processor simple -n 2 -shutdown=false
2026/09/17 04:50:07 traces export: exporter export timeout: rpc error: code = Unavailable desc = connection error: …
…
grpc/simple: 2 spans, span.End() avg 10.001884s

С недоступным Collector каждый End(), а с ним и ответ пользователю, ждёт таймаут экспорта в 10 секунд. У батчера спаны лежат в памяти до отправки, и процесс без Shutdown их теряет (jaeger-count.sh считает trace_id сервиса в Jaeger):

$ OTEL_SERVICE_NAME=exp-shutdown ./export -n 20
…
$ OTEL_SERVICE_NAME=exp-noshutdown ./export -n 20 -shutdown=false
…
$ for s in exp-shutdown exp-noshutdown; do printf '%s: ' $s; ./jaeger-count.sh $s; done
exp-shutdown: 20
exp-noshutdown: 0

В поде так пропадают до пяти секунд трейсов перед каждым рестартом. Спан, закрытый после Shutdown, тоже не уходит, поэтому по SIGTERM зовут srv.Shutdown(sctx), потом shutdownTracing(sctx) с общим таймаутом меньше terminationGracePeriodSeconds: иначе недоступный Collector съест срок и остановку оборвёт SIGKILL. Остановка HTTP-сервера разобрана в статье «Веб-сервисы» раздела Go, глава 10.2.

Переменные окруженияКто читает в Go
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTESresource.Default() и WithResource
OTEL_EXPORTER_OTLP_ENDPOINT, …_HEADERS, …_TIMEOUT, …_INSECUREэкспортеры OTLP; опции в коде главнее
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARGNewTracerProvider; WithSampler главнее
OTEL_BSP_SCHEDULE_DELAY, OTEL_BSP_MAX_QUEUE_SIZENewBatchSpanProcessor
OTEL_TRACES_EXPORTER, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_PROPAGATORScontrib/exporters/autoexport и contrib/propagators/autoprop; …_PROTOCOL ещё otlptracehttp (protobuf или JSON, с v1.46.0)

otelhttp, otelgrpc и pgx

Спаны на входящие и исходящие вызовы дают обёртки из contrib. Цепочка в опытах: gateway → orders → payments → Postgres. chain.sh поднимает её и шлёт запросы, jtraces.py печатает трейс из API Jaeger.

handler := otelhttp.NewHandler(mux, *name,
    otelhttp.WithFilter(func(r *http.Request) bool { return r.URL.Path != "/healthz" }))

var client = &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport), Timeout: 5 * time.Second}

cfg, err := pgxpool.ParseConfig(*dsn)
cfg.ConnConfig.Tracer = otelpgx.NewTracer()

srv := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
conn, err := grpc.NewClient("127.0.0.1:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
$ ./chain.sh "" "" /orders/42 /healthz && ./jtraces.py gateway d
GET /orders/42 -> 200
GET /healthz -> 200
traces with gateway: 1
trace 88ca0ce28ff70e3b14a174c9c5b97450
  gateway   GET /orders/{id}         span=dccbd86e947047de parent=-                SERVER   UNSET
  gateway   HTTP GET                 span=09eb75a5e6e47d6a parent=dccbd86e947047de CLIENT   UNSET
  orders    GET /orders/{id}         span=633372b4e28a40b4 parent=09eb75a5e6e47d6a SERVER   UNSET
  orders    load order               span=ce309bbca0a8431b parent=633372b4e28a40b4 INTERNAL UNSET
  orders    HTTP GET                 span=646d2b58198650eb parent=ce309bbca0a8431b CLIENT   UNSET
  payments  GET /payments/{id}       span=86c796c2286f0f6f parent=646d2b58198650eb SERVER   UNSET
  payments  pool.acquire             span=0700d5cad8aa1629 parent=86c796c2286f0f6f CLIENT   UNSET
  payments  connect                  span=9fca51937ce75e2f parent=0700d5cad8aa1629 CLIENT   UNSET
  payments  SELECT                   span=ccb3aa7661ac83cd parent=86c796c2286f0f6f CLIENT   UNSET
  payments  SELECT                   span=d9307bebbd99a666 parent=ccb3aa7661ac83cd CLIENT   UNSET
  • Имя серверного спана GET /orders/{id} собрано из метода и r.Pattern (ServeMux заполняет его с Go 1.23); второй аргумент NewHandler с otelhttp v0.69.0 на имя не влияет. У роутера без r.Pattern имя будет GET, и нужен WithSpanNameFormatter.
  • /healthz трейса не оставил, его отрезал WithFilter. Успешные спаны остались UNSET: OK инструментирование само не ставит.
  • otelpgx пишет ожидание пула, подключение и запрос, вложенный SELECT готовит выражение на первом вызове. В db.query.text плейсхолдеры без значений. Спан запроса будет, только если в контексте уже есть записываемый спан.
  • otelgrpc ставится как stats.Handler, traceparent едет в метаданных. Перехватчики вроде UnaryServerInterceptor устарели в v0.46.0, к v0.63.0 их удалили.

Свои спаны: атрибуты, события и ошибки

func loadOrder(ctx context.Context, id string) error {
    ctx, span := tracer.Start(ctx, "load order")
    defer span.End()
    span.SetAttributes(attribute.String("order.id", id))
    span.AddEvent("cache miss", trace.WithAttributes(attribute.String("cache", "redis")))
    time.Sleep(15 * time.Millisecond)

    code, err := call(ctx, client, *next+"/payments/"+id)
    if err == nil && code >= 400 {
        err = fmt.Errorf("payments returned %d", code)
    }
    if err != nil {
        span.RecordError(err)                                // событие exception
        span.SetStatus(codes.Error, "payment lookup failed") // статус спана
        return err
    }
    return nil
}

RecordError только добавляет событие exception, статус остаётся Unset; ошибкой спан делает SetStatus. Заказа 13 в базе нет:

$ ./chain.sh "" "" /orders/13 && ./jtraces.py gateway d | head -7
GET /orders/13 -> 502
traces with gateway: 1
trace 4278e79403dda87da52c7422154f6512
  gateway   GET /orders/{id}         span=ba405b7c43c459fa parent=-                SERVER   ERROR
  gateway   HTTP GET                 span=80b656f31a8d22b4 parent=ba405b7c43c459fa CLIENT   ERROR
  orders    GET /orders/{id}         span=8f2a11810b33c155 parent=80b656f31a8d22b4 SERVER   ERROR
  orders    load order               span=a3233ba7d84841a2 parent=8f2a11810b33c155 INTERNAL ERROR payment lookup failed
  orders    HTTP GET                 span=fc4a180dc471cb9d parent=a3233ba7d84841a2 CLIENT   ERROR
  payments  GET /payments/{id}       span=9bf3b45d6bd433f0 parent=fc4a180dc471cb9d SERVER   UNSET

Серверный спан payments с ответом 404 остался UNSET: по конвенциям HTTP 4xx для сервера не ошибка. Клиентский спан orders на тот же 404 получил ERROR и error.type=404, а 502 помечен ошибкой с обеих сторон. В Jaeger у load order значок ошибки, теги error=true, otel.status_code=ERROR и otel.status_description, в Events cache miss и exception.

Контекст на проводе: traceparent, tracestate, baggage

Заголовок traceparent (формат в главе 5.1) пишет и читает пропагатор. Без SetTextMapPropagator в setupTracing каждый сервис начинает свой трейс:

$ ./chain.sh "-no-propagator" "" /orders/42 >/dev/null
$ for s in gateway orders payments; do ./jtraces.py $s d | sed -n 3p; done
  gateway   GET /orders/{id}         span=4d643f7e33a4288a parent=-                SERVER   UNSET
  orders    GET /orders/{id}         span=c86fadf495cbbd5d parent=-                SERVER   UNSET
  payments  GET /payments/{id}       span=e4ed9adaca141a4c parent=-                SERVER   UNSET

Три корня вместо одного дерева. С пропагатором gateway кладёт в baggage tenant.id=acme и зовёт партнёра, сервер без OpenTelemetry, печатающий заголовки:

$ ./chain.sh "" "-external http://127.0.0.1:9099/partner -baggage" /orders/42
GET /orders/42 -> 200
GET /partner
…
  Baggage: tenant.id=acme
  Traceparent: 00-459034cfe4326ff669358d629fe2d406-f4fa710edf7cfc26-01
…

tracestate TraceContext передаёт как есть: на traceparent с флагом 00 и tracestate: congo=t61rcWkgMzE следующий сервис получил тот же trace_id, новый span_id, флаг 00 и тот же tracestate.

Baggage уходит к партнёру вместе с запросом

Пары из baggage пропагатор Baggage копирует в каждый исходящий запрос, во внешний API тоже: выше tenant.id ушёл партнёру, так и email клиента окажется в чужих логах. Входящему baggage не верят. Транспорту для внешних API пропагатор задают отдельно, otelhttp.WithPropagators(propagation.TraceContext{}), и партнёр получает только Traceparent.

Kafka: заголовки сообщения и span link

Пропагатору нужен TextMapCarrier над заголовками сообщения (Get, Set, Keys), для franz-go он есть в kotel.

// продюсер: контекст спана отправки уходит в заголовки
sendCtx, send := tr.Start(reqCtx, "send "+topic, trace.WithSpanKind(trace.SpanKindProducer))
r := &kgo.Record{Topic: topic, Key: []byte(id), Value: []byte(`{"order_id":"` + id + `"}`)}
prop.Inject(sendCtx, kotel.NewRecordCarrier(r))
if err := cl.ProduceSync(sendCtx, r).FirstErr(); err != nil {
    log.Fatal(err)
}
send.End()

// консьюмер, внутри PollFetches: сообщение станет ссылкой
msgCtx := prop.Extract(ctx, kotel.NewRecordCarrier(r))
_, span := tr.Start(ctx, "process "+topic, // родитель: контекст цикла, а не сообщение
    trace.WithSpanKind(trace.SpanKindConsumer),
    trace.WithLinks(trace.LinkFromContext(msgCtx)),
    trace.WithAttributes(semconv.MessagingSystemKafka, semconv.MessagingOperationTypeProcess,
        semconv.MessagingOperationName("process"), semconv.MessagingDestinationName(topic),
        semconv.MessagingConsumerGroupName("billing")))
span.End()
$ ./kafkademo
header traceparent: 00-b047f7f8ccac4bde5a3e06010951bc10-d4d4bb96d400514f-01
send orders   producer trace=b047f7f8ccac4bde5a3e06010951bc10 span=d4d4bb96d400514f parent=e4396c3d03e02370
POST /orders  server   trace=b047f7f8ccac4bde5a3e06010951bc10 span=e4396c3d03e02370 parent=-
process orders consumer trace=7714de8ff06efe34602b2486ca51de66 span=dd866df4a8053c7b parent=- link=b047f7f8ccac4bde5a3e06010951bc10/d4d4bb96d400514f

Спан обработки начал свой трейс и хранит ссылку (link) на спан отправки, в Jaeger это раздел Links. Так по умолчанию советуют конвенции messaging (semconv 1.44, статус Development), причина в главе 5.1. Плагин kotel v1.7.1 по умолчанию делает спан ребёнком, а ссылку только с kotel.LinkSpans().

Сэмплирование в SDK: решает корень

Description() у ParentBased(TraceIDRatioBased(0.1)) перечисляет пять сэмплеров: корневой TraceIDRatioBased{0.1} и AlwaysOnSampler или AlwaysOffSampler для удалённого и локального родителя, сэмплированного или нет. Доля работает только в корне. sampling гоняет 1000 запросов через три сервиса со своими TracerProvider и считает записанные серверные спаны:

$ ./sampling | grep -A1 '^[A-D]:'
A: ParentBased(TraceIDRatioBased(0.1)) у gateway, ParentBased у остальных
  recorded: gateway=98 orders=98 payments=98; complete traces=98, broken=0
--
B: gateway как в A, orders=TraceIDRatioBased(0.5), payments=TraceIDRatioBased(0.05) без ParentBased
  recorded: gateway=99 orders=512 payments=46; complete traces=46, broken=466
--
C: как A, но клиент сам присылает traceparent с флагом 01
  recorded: gateway=1000 orders=1000 payments=1000; complete traces=1000, broken=0
--
D: как C, но gateway сам решает и для чужого traceparent
  recorded: gateway=119 orders=119 payments=119; complete traces=119, broken=0

В A у orders своя доля 0.5, но родитель есть всегда, и сервисы записали одни и те же трейсы. В B каждый решает сам, и целых трейсов столько, сколько записал payments: с порогом сравнивается часть того же trace_id, и 5 % входят в 10 % и в 50 %. В C флаг 01 от клиента включил запись на всей цепочке; на входе это переопределяют sdktrace.WithRemoteParentSampled(ratio) и sdktrace.WithRemoteParentNotSampled(ratio) (опыт D).

Решение принимает корень трейса, остальные сервисы читают флаг из traceparent Запрос A: у gateway нет родителя, корневой сэмплер пропустил trace_id клиент без traceparent gateway нет родителя: решает сам TraceIDRatioBased(0.1): 01 orders ParentBased: родитель сэмплирован, спаны пишет payments ParentBased: родитель сэмплирован, спаны пишет 01 01 OTLP, пачки BatchSpanProcessor OpenTelemetry Collector принимает OTLP на :4317 и пачками отправляет дальше Jaeger одно дерево по trace_id в заголовке: trace-id один, span-id свой на хоп gateway → orders 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 orders → payments 00-4bf92f3577b34da6a3ce929d0e0e4736-b7ad6b7169203331-01 Запрос B: корневой сэмплер отказал, флаг 00 уходит дальше, в Collector не попадает ничего клиент без traceparent gateway TraceIDRatioBased(0.1): 00 orders флаг 00: спаны не пишет payments флаг 00: спаны не пишет 00 00
Сэмплирование в голове трейса. Корневой сервис решает один раз по trace_id и пишет решение во флаг traceparent. С ParentBased остальные ему следуют, и трейс либо сохраняется целиком, либо не экспортируется вовсе.

Collector: конвейер, агент и шлюз

OpenTelemetry Collector принимает телеметрию, обрабатывает и отправляет дальше по конвейерам из receivers, processors и exporters.

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80        # от memory.max контейнера
    spike_limit_percentage: 20

exporters:
  otlp_grpc/jaeger:
    endpoint: dv-o3-jaeger:4317
    tls:
      insecure: true
    sending_queue:
      batch: {}                 # пачки собирает экспортер
  debug:
    verbosity: basic

service:
  telemetry:
    metrics:
      readers:
        - pull:
            exporter:
              prometheus:
                host: 0.0.0.0
                port: 8888
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter]
      exporters: [otlp_grpc/jaeger, debug]
  • memory_limiter первым: выше мягкого порога он отказывает приёмнику, и отказ доходит до отправителя раньше, чем данные осели в конвейере. Процент считается от лимита контейнера: с --memory 256m в логе limit_mib: 204, spike_limit_mib: 51.
  • Пачки. Годами последним ставили процессор batch, теперь сборку пачек переносят в экспортер (sending_queue::batch, как в примерах документации). queuebatch на замену batch есть в ядре с 0.158, в contrib 0.161.0 его нет. batch ставят после сэмплирования, чтобы не упаковывать выброшенное.
  • Имена. С 0.144 экспортеры называются otlp_grpc и otlp_http, с 0.153 loadbalancing стал load_balancing; старые имена остались псевдонимами.
  • debug с verbosity: detailed печатает в лог каждый спан.

Свои метрики Collector по умолчанию отдаёт на localhost:8888 внутри контейнера, и снаружи порт молчит, пока в service.telemetry не задан хост:

$ curl -s 127.0.0.1:8888/metrics | grep -E '^otelcol_receiver_(accepted|refused)_spans'
otelcol_receiver_accepted_spans{receiver="otlp",transport="grpc"} 1231
otelcol_receiver_refused_spans{receiver="otlp",transport="grpc"} 0

Агент живёт рядом с сервисом (sidecar или DaemonSet): SDK шлёт на близкий адрес, агент буферизует, добавляет данные пода и пересылает. Шлюз, Deployment за Service, держит в одном месте сэмплирование, фильтры, адреса и секреты бэкендов. Часто работают оба слоя.

Tail sampling в Collector

tail_sampling копит спаны в памяти по trace_id, ждёт decision_wait с первого спана трейса и проверяет политики. Трейс сохраняется, если хоть одна политика сказала «да» и ни одна drop не сказала «выбросить»:

# фрагмент tail.yaml, остальное как выше
processors:
  tail_sampling:
    decision_wait: 10s          # по умолчанию 30s
    num_traces: 50000           # сколько трейсов держать в памяти до решения
    policies:
      - name: errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow
        type: latency
        latency: {threshold_ms: 500}
      - name: rest
        type: probabilistic
        probabilistic: {sampling_percentage: 10}
  batch: {}

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp_grpc/jaeger]

tailwatch.sh шлёт 240 трейсов (200 быстрых, 20 с ошибкой, 20 по 600 мс) и считает виды в Jaeger:

$ ./tailwatch.sh
sent 240 traces, shutdown: <nil>
t+2s: normal=0 error=0 slow=0
t+8s: normal=0 error=0 slow=0
t+18s: normal=15 error=20 slow=20
$ curl -s 127.0.0.1:8888/metrics | grep '^otelcol_processor_tail_sampling_global_count_traces_sampled'
otelcol_processor_tail_sampling_global_count_traces_sampled{decision="not_sampled",sampled="false"} 185
otelcol_processor_tail_sampling_global_count_traces_sampled{decision="sampled",sampled="true"} 55

Пока идёт decision_wait, в Jaeger пусто, потом приходят все ошибки, все медленные и 15 быстрых из 200 (rest, 10 % по хешу trace_id). latency меряет трейс от самого раннего начала до самого позднего конца среди пришедших спанов, и трейс длиннее decision_wait оценивается по неполным данным.

Tail sampling: все спаны трейса в одну реплику, решение после decision_wait сервисы агенты: load_balancing шлюз: tail_sampling, две реплики хранилище orders спан трейса 4bf9… payments спан трейса 4bf9… агент на узле 1 routing_key: traceID агент на узле 2 routing_key: traceID sampler-0 получает другие трейсы sampler-1 все спаны 4bf9… Jaeger сохранённые трейсы оба агента считают один хеш trace_id и шлют спаны 4bf9… в sampler-1 Внутри sampler-1 otlp приём на :4317 memory_limiter первым: отказывает выше мягкого порога tail_sampling буфер по trace_id, num_traces: 50000 4bf9…: 2 спана, ждёт 10 с decision_wait политики, достаточно одного «да»: errorsстатус ERRORнет slowдольше 500 мсда rest10 % по хешу trace_idне важно итог: трейс сохранить otlp_grpc экспорт в Jaeger не сохранён спаны удалены из памяти спан 4bf9…, пришедший после решения: пока решение в буфере, получает его; потом ждёт заново, если решение не запомнено в decision_cache
Tail sampling в двух слоях Collector. Агенты раскладывают спаны по репликам шлюза по хешу trace_id. Реплика держит спаны трейса в памяти decision_wait с первого спана и сохраняет трейс, если хоть одна политика сказала «да».

По документации процессора все спаны трейса должны прийти в один экземпляр. Обычный Service разложит соединения по репликам, поэтому реплику выбирает слой с load_balancing (конфиг в третьем вопросе).

Логи и метрики рядом с трейсом

В главе 5.1 trace_id клали в логгер один раз в middleware. Если брать контекст в момент записи, рядом ляжет и span_id текущего спана. Хендлер-обёртка для slog пишет обычный JSON в stdout:

type traceHandler struct{ slog.Handler }

func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
    if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
        r.AddAttrs(slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String()))
    }
    return h.Handler.Handle(ctx, r)
}

func (h traceHandler) WithAttrs(as []slog.Attr) slog.Handler {
    return traceHandler{h.Handler.WithAttrs(as)}
}

func (h traceHandler) WithGroup(name string) slog.Handler {
    return traceHandler{h.Handler.WithGroup(name)}
}
$ ./logs handler
{"time":"2026-09-17T04:52:15.460124+03:00","level":"INFO","msg":"payment processed","order_id":"42","trace_id":"c8f7e82600c2ca853f9c94caf8c40f0f","span_id":"ed4acf94dc453bb3"}
{"time":"2026-09-17T04:52:15.460597+03:00","level":"INFO","msg":"payment processed","order_id":"43"}

Вторая запись сделана через log.Info без контекста, и trace_id взять неоткуда. Другой путь даёт мост contrib/bridges/otelslog: slog.Handler, который отдаёт записи в Logs SDK (дальше по OTLP в Collector) и сам ставит trace_id и span_id:

$ ./logs bridge   # экспортер stdoutlog вместо OTLP, вывод сокращён
{"Timestamp":"2026-09-17T04:52:15.474825+03:00",…,"Body":{"Type":"STRING","Value":"payment processed"},…,"TraceID":"afaaa8735d55a3be3731393728bc95a6","SpanID":"19c924d56ee14427","TraceFlags":"01",…}

Если логи собирает агент со stdout подов, хватит своего хендлера; мост нужен для логов по OTLP. Сигнал логов в Go пока Beta (sdk/log v0.22.0, API менялся в v1.45), место вызова мост кладёт в code.* только с otelslog.WithSource(true). Писать в оба места позволяет slog.NewMultiHandler (Go 1.26).

Exemplars (глава 5.1) Metrics SDK прикрепляет сам к измерениям с контекстом сэмплированного спана (OTEL_METRICS_EXEMPLAR_FILTER=trace_based по умолчанию). Экспортер Prometheus отдаёт их в OpenMetrics и protobuf, но не в обычном текстовом формате:

$ curl -s -H 'Accept: application/openmetrics-text' 127.0.0.1:9464/otel | grep 'le="0.05"'
http_server_request_duration_seconds_bucket{http_route="/orders/{id}",otel_scope_name="example.com/orders",otel_scope_schema_url="",otel_scope_version="",le="0.05"} 1 # {trace_id="f0466d31eefcf0b97878c20f38ada88c",span_id="1f356d2bfbbaa463"} 0.042 1.7896099358424408e+09

Без заголовка Accept та же строка кончается на } 1, без exemplar.

Сколько стоит инструментирование

Бенчмарк зовёт обработчик напрямую, без сети; экспортер выбрасывает спаны. Медианы benchstat по шести прогонам на Apple M4 Pro:

Вариантна вызовпамятьаллокаций
без обёртки47,5 нс208 Б4
otelhttp, провайдеры no-op1,07 мкс2,6 КиБ34
otelhttp и SDK, спан не сэмплирован1,27 мкс3,2 КиБ36
otelhttp и SDK, спан сэмплирован2,76 мкс6,1 КиБ42
то же и metrics SDK3,68 мкс6,1 КиБ42
ручной спан с атрибутом, сэмплирован477,8 нс1 КиБ5
ручной спан с атрибутом, не сэмплирован114 нс208 Б3

У запроса с походом в базу микросекунды теряются в миллисекундах сети. Обёртка стоит около микросекунды и без SDK: сэмплирование срезает цену больше чем вдвое, но не до нуля. Полмикросекунды на ручной спан заметны только в цикле по десяти тысячам строк.

Поэтому не трассируют пробы /healthz и /ready (их отрезает WithFilter), скрейп /metrics, горячие функции, итерации цикла (спан на весь цикл, число элементов в атрибут) и кэш в памяти. Спан ставят на границе: входящий запрос, исходящий вызов, запрос в базу, заметный шаг бизнес-логики.

Вопросы

4
Суть: кусок начинается там, где сервис не получил или не прочитал контекст. Проверяют по порядку заголовки на границе, пропагатор у обеих сторон, путь ctx до исходящего вызова и сэмплирование.

Провод

Разрыв сидит между последним спаном одного куска и корнем следующего. Там и смотрят заголовки: печатающим сервером, как у партнёра выше, tcpdump или логом на стенде.

  • traceparent нет. Отправитель не вызвал otel.SetTextMapPropagator (OTEL_PROPAGATORS без autoprop не действует), клиент без otelhttp.NewTransport или otelgrpc.NewClientHandler, или заголовок срезал прокси.
  • Заголовок есть, а trace_id у получателя новый. Получатель без пропагатора или ждёт другой формат (b3, заголовки Jaeger), тогда нужен составной пропагатор.
  • trace_id один, а середины нет. Сервисы сэмплируют каждый сам (опыт B) или сервис не отправляет спаны: не тот адрес, TLS на открытый порт, выход без Shutdown.

Код

Внутри сервиса контекст теряют, как в главе 5.1: запрос без r.Context(), горутина с context.Background(). После Kafka два трейса со ссылкой в порядке вещей. Сломано, если в заголовках сообщения нет traceparent.

Суть: с BatchSpanProcessor сервис почти ничего не замечает: очередь на 2048 спанов полна, лишние выбрасываются, память не растёт, span.End() не ждёт, теряются трейсы. С SimpleSpanProcessor каждый спан ждёт таймаут экспорта, и падение Collector становится падением сервиса.

Прогон

Экспортер смотрит в закрытый порт, сервис создаёт 100 000 спанов, метрики SDK включены экспериментальным флагом (читает их ManualReader):

$ OTEL_GO_X_OBSERVABILITY=true OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4999 ./queuefull
100000 spans in 121ms, heap in use 3 MiB
otel.sdk.exporter.span.inflight{error.type=""} 512
otel.sdk.processor.span.queue.capacity{error.type=""} 2048
otel.sdk.processor.span.queue.size{error.type=""} 2048
otel.sdk.processor.span.processed{error.type="queue_full"} 97440
otel.sdk.processor.span.processed{error.type=""} 512
…

512 спанов в экспорте, 2048 в очереди, остальные выброшены с error.type=queue_full. Экспорт пачки с повторами ограничен 10 секундами. Без флага выброшенное видно только в отладочном логе SDK.

Где сервис всё-таки страдает

  • Остановка: Shutdown без таймаута ждёт каждую пачку из очереди до 10 секунд. С полной очередью это 50 секунд, больше срока до SIGKILL по умолчанию.
  • Огромная очередь: OTEL_BSP_MAX_QUEUE_SIZE в сотни тысяч превращает недоступный Collector в расход памяти, в очереди спаны целиком.
Суть: SDK сервисов отправляет все спаны, решает tail_sampling. За обычным Service его реплики видят обрывки трейсов, поэтому перед ними ставят слой с load_balancing, который шлёт спаны трейса в одну реплику.

Первый слой

exporters:
  load_balancing:
    routing_key: traceID          # все спаны трейса уходят в один экземпляр
    protocol:
      otlp:
        tls:
          insecure: true
    resolver:
      k8s:
        service: otelcol-sampler.observability   # Service перед репликами с tail_sampling
        ports: [4317]

Конфиг проходит otelcol-contrib validate на 0.161.0, остальное по README экспортера: резолвер k8s следит за EndpointSlice (сервисному аккаунту нужны get, list, watch), есть и dns по headless Service. При смене числа реплик переезжает около R/N маршрутов, и трейс на стыке решают по половинам.

Второй слой и SDK

Политики как в разделе про tail sampling, но sampling_percentage: 5. Память считают от потока: 2000 новых трейсов в секунду при decision_wait: 10s дают в буферах около 20 000 трейсов, переполнение видно по otelcol_processor_tail_sampling_sampling_trace_dropped_too_early. В сервисах остаётся умолчание ParentBased(AlwaysSample()): выброшенный в SDK трейс с ошибкой до Collector не дойдёт.

Долю ошибок по сохранённым трейсам не считают

Среди сохранённых ошибок 100 %, остальных 5 %. RED берут из метрик сервиса (otelhttp пишет http.server.request.duration сам) или считают до tail_sampling.

Суть: если метрики скрейпит Prometheus и дашборды построены на client_golang, проще на нём и остаться. OTel SDK выгоден, когда телеметрия идёт через Collector: один ресурс на трейсы и метрики, exemplars без ручного кода, готовые метрики otelhttp и otelgrpc. Имена при этом поменяются.

Одна метрика двумя способами

Гистограмма http.server.request.duration (единица s) и счётчик orders.created ({order}) через экспортер Prometheus для OTel SDK и ниже на client_golang:

$ curl -s 127.0.0.1:9464/otel | grep -E '^(http_server.*le="0.05"|orders_created|target_info)'
http_server_request_duration_seconds_bucket{http_route="/orders/{id}",otel_scope_name="example.com/orders",otel_scope_schema_url="",otel_scope_version="",le="0.05"} 1
orders_created_total{otel_scope_name="example.com/orders",otel_scope_schema_url="",otel_scope_version=""} 1
target_info{service_name="orders"} 1
$ curl -s 127.0.0.1:9464/prom | grep -E '^(http_server.*le="0.05"|orders_created)'
http_server_request_duration_seconds_bucket{http_route="/orders/{id}",le="0.05"} 1
orders_created_total 1
  • Имена. Точки стали подчёркиваниями, s стала суффиксом _seconds, у счётчика появился _total, единица в фигурных скобках пропала (стратегия UnderscoreEscapingWithSuffixes, умолчание экспортера с v0.61.0).
  • Метки. У каждой серии otel_scope_* (убирает WithoutScopeInfo()), ресурс лежит серией target_info, service_name из неё подтягивают через group_left.
  • Рантайм. contrib/instrumentation/runtime отдаёт go_goroutine_count, а готовые дашборды ждут go_goroutines и go_memstats_*.
  • Кардинальность. С v1.44 metrics SDK держит до 2000 наборов атрибутов на инструмент: 2500 разных user_id дали 2000 серий, последняя с otel.metric.overflow=true и значением 501. У client_golang такого ограничителя нет.

Что стоит запомнить

  • Инструментированная библиотека может вызывать API без SDK, но тогда реальные спаны не экспортируются.
  • Контекст должен пройти через границы процесса в headers или message metadata.
  • BatchSpanProcessor нужно штатно завершить, чтобы выгрузить накопленные данные.

Частые ошибки

  • Создать span недостаточно: без propagator trace context не перейдёт в следующий сервис.
  • Baggage может вынести чувствительные данные в соседние сервисы и внешние системы.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Найди потерю контекста в клиентском коде

Входящий server span создаётся. Почему в downstream сервисе HTTP запрос оказывается в новом trace?

func loadCatalog(ctx context.Context, client *http.Client, url string) error {
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil { return err }
    resp, err := client.Do(req)
    if err != nil { return err }
    defer resp.Body.Close()
    return nil
}
Ответ и разбор

http.NewRequest не связывает request с входящим ctx; outbound instrumentation извлекает trace context именно из request context. Создай запрос через http.NewRequestWithContext(ctx, ...) и оберни transport в OTel HTTP transport. Тогда client span и propagation используют контекст текущего входящего запроса.

Код-ревью

2. Не теряй batch при остановке

Проверь завершение SDK: что произойдёт с накопленными spans после возврата из main?

func main() {
    tp := newTracerProvider()
    otel.SetTracerProvider(tp)
    runServer()
}
Ответ и разбор

Провайдер не получает команду на flush/shutdown, поэтому batch spans могут не успеть экспортироваться при завершении процесса. Зарегистрируй tp.Shutdown(ctx) на graceful shutdown приложения с ограниченным timeout и вызови его после прекращения входящей работы. Также проверь и обработай ошибку shutdown.

Применение

3. Выбери sampling для редкой ошибки

Нужно сохранять почти все trace с ошибкой, но ограничить общий объём. Head sampler принимает решение в начале запроса, а ошибка может появиться только в конце. Какой вариант рассмотреть и какие ресурсы он требует?

Ответ и разбор

Рассмотри tail sampling в Collector: он удерживает trace до получения решения и может оставить ошибочные traces. Сервис должен отправлять spans в Collector, не отбрасывая их head sampler-ом; например, решение о sampling можно оставить Collector через tail-sampling policy. Нужны память на буфер, стратегия eviction и согласованная маршрутизация всех spans одного trace к одному sampler; при перегрузе часть данных всё равно может быть отброшена. Уточни это как явную политику деградации.

Применение

4. Передай trace через очередь

Producer создаёт span, публикует сообщение, consumer обрабатывает его через час. Как связать trace и обработку, и почему span link может подходить лучше прямого parent-child?

Ответ и разбор

Сериализуй trace context в headers/metadata сообщения и извлеки его на consumer. Если consumer обрабатывает сообщение позднее, параллельно с другими сообщениями или агрегирует несколько событий, span link выражает причинную связь без представления о синхронном вложенном вызове. Не клади чувствительные значения в baggage без необходимости.