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_ATTRIBUTES | resource.Default() и WithResource |
OTEL_EXPORTER_OTLP_ENDPOINT, …_HEADERS, …_TIMEOUT, …_INSECURE | экспортеры OTLP; опции в коде главнее |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG | NewTracerProvider; WithSampler главнее |
OTEL_BSP_SCHEDULE_DELAY, OTEL_BSP_MAX_QUEUE_SIZE | NewBatchSpanProcessor |
OTEL_TRACES_EXPORTER, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_PROPAGATORS | contrib/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 копирует в каждый исходящий запрос, во внешний 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).
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.153loadbalancingстал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 оценивается по неполным данным.
По документации процессора все спаны трейса должны прийти в один экземпляр. Обычный 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-op | 1,07 мкс | 2,6 КиБ | 34 |
otelhttp и SDK, спан не сэмплирован | 1,27 мкс | 3,2 КиБ | 36 |
otelhttp и SDK, спан сэмплирован | 2,76 мкс | 6,1 КиБ | 42 |
| то же и metrics SDK | 3,68 мкс | 6,1 КиБ | 42 |
| ручной спан с атрибутом, сэмплирован | 477,8 нс | 1 КиБ | 5 |
| ручной спан с атрибутом, не сэмплирован | 114 нс | 208 Б | 3 |
У запроса с походом в базу микросекунды теряются в миллисекундах сети. Обёртка стоит около микросекунды и без SDK: сэмплирование срезает цену больше чем вдвое, но не до нуля. Полмикросекунды на ручной спан заметны только в цикле по десяти тысячам строк.
Поэтому не трассируют пробы /healthz и /ready (их отрезает WithFilter),
скрейп /metrics, горячие функции, итерации цикла (спан на весь цикл, число элементов в
атрибут) и кэш в памяти. Спан ставят на границе: входящий запрос, исходящий вызов, запрос в базу, заметный
шаг бизнес-логики.
Вопросы
4ctx до
исходящего вызова и сэмплирование.Провод
Разрыв сидит между последним спаном одного куска и корнем следующего. Там и смотрят заголовки:
печатающим сервером, как у партнёра выше, 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 в расход памяти, в очереди спаны целиком.
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.
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 без необходимости.