Distributed tracing с OpenTelemetry: что включить и как читать спаны
Запрос пришёл от пользователя в API gateway, оттуда — в orders, оттуда — в payments и inventory параллельно, payments позвал внешний эквайер, ответ собрался обратно. Где-то в этой цепочке произошёл таймаут. Без трассировки ответ на «где?» — это часовое расследование с grep'ами по логам пяти сервисов. С трассировкой — открыли trace в Jaeger, увидели спан длиной 9 секунд с пометкой «timeout», это сделано.
Я внедрял distributed tracing трижды, в трёх разных стеках: Zipkin/Brave для Spring, Jaeger с OpenTracing на Go, и последний раз — OpenTelemetry, который сейчас фактический стандарт. OpenTelemetry — не просто очередная библиотека, это попытка унифицировать всё: traces, metrics, logs, на одной модели.
Разберу базовые понятия, как правильно настраивать инструментацию, что включать в спаны и какие подводные камни лежат вне счастливого пути.
Базовые понятия
Чтобы дальнейший разговор имел смысл, нужно зафиксировать термины.
- Trace — последовательность операций, обслуживающих один запрос. Имеет уникальный
trace_id. - Span — отдельная операция внутри trace. У спана есть
span_id, родитель (parent_span_id), время начала и конца, имя, атрибуты. - Context propagation — передача trace_id и span_id между сервисами через заголовки.
- Sampling — решение, какой процент трасс сохранять.
Стандарт OpenTelemetry даёт единую модель этих штук независимо от языка и backend'а (Jaeger, Zipkin, Tempo, Datadog).
Минимальная настройка
В Spring Boot с OTel-агентом всё работает почти само:
java -javaagent:opentelemetry-javaagent.jar \
-Dotel.service.name=orders \
-Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
-jar orders.jarАгент инструментирует HTTP-клиенты, JDBC, Kafka producer/consumer, gRPC автоматически. На каждый входящий запрос создаётся root span, на каждый исходящий вызов — child span. Trace_id прокидывается через заголовок traceparent (W3C Trace Context).
Для Go и других языков auto-instrumentation менее зрелый — обычно надо вручную обернуть HTTP-клиент и server:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// Server
handler := otelhttp.NewHandler(http.HandlerFunc(myHandler), "my-handler")
http.ListenAndServe(":8080", handler)
// Client
client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}Что класть в спаны
Атрибуты спана — это тэги, по которым потом ищутся проблемы. Каноничные атрибуты определены в OpenTelemetry semantic conventions, и им стоит следовать вместо изобретения своих.
Для HTTP-server-спана:
http.method— GET, POST.http.route— паттерн пути: /users/{id}, не /users/42.http.status_code— 200, 500.http.url— только если это нужно (без секретов).
Для DB-спана:
db.system— postgresql, redis.db.statement— SQL без параметров (или с redacted-значениями).db.operation— SELECT, INSERT.
Для бизнес-логики, которую инструментируете руками, добавляйте бизнес-контекст:
val tracer = openTelemetry.getTracer("orders-service")
fun chargePayment(orderId: UUID, amount: Money) {
val span = tracer.spanBuilder("charge_payment")
.setAttribute("order.id", orderId.toString())
.setAttribute("payment.amount", amount.value.toString())
.setAttribute("payment.currency", amount.currency)
.startSpan()
try {
span.makeCurrent().use {
paymentClient.charge(orderId, amount)
}
} catch (e: Exception) {
span.recordException(e)
span.setStatus(StatusCode.ERROR, e.message ?: "")
throw e
} finally {
span.end()
}
}Атрибуты позволяют фильтровать трассы: «покажи все trace'ы, где order.id=123» или «все вызовы charge_payment, где payment.amount > 10000».
Что НЕ класть
Несколько вещей, которые часто пытаются положить в спан и потом жалеют.
Полный body запроса. Десятки килобайт на каждом спане превращают traces в мусор. Кладите только маленькие критичные поля.
Секреты, токены, пароли. Trace-данные часто хранятся в общем backend'е с менее строгим доступом, чем prod. Никаких auth_token и password_hash в атрибутах.
PII без редактирования. Email, телефоны, паспорта в спанах = проблемы с GDPR/КИИ. Либо хешируйте, либо не кладите.
Stack traces в каждом спане. Только в спанах с ошибками. recordException делает это правильно.
Sampling
Хранить 100% трассы дорого: на 1000 RPS — 86 миллионов трасс в день. Большинство — успешные запросы, которые никому не интересны.
Стандартные стратегии sampling.
Head-based sampling
Решение принимается на старте трассы, до того как известно, как она завершится. Например, «1% всех запросов».
Плюс — простота, нет необходимости буферизовать. Минус — может пропустить редкие проблемы (взяли 1%, среди них только успешные, ошибки выбросили).
-Dotel.traces.sampler=traceidratio \
-Dotel.traces.sampler.arg=0.01Tail-based sampling
Решение принимается после завершения трассы — ошибки/медленные сохраняются всегда, успешные семплируются.
Делается в OTel Collector с tailsamplingprocessor:
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow
type: latency
latency:
threshold_ms: 1000
- name: random
type: probabilistic
probabilistic:
sampling_percentage: 1Минус — сложнее (нужен буферизующий collector), вычислительно дороже. Плюс — не теряете важные трассы.
На умеренных объёмах (до 1000 RPS) я обычно начинаю с head-based на 10-20%. На больших — переходим на tail-based, нет другого варианта.
Context propagation
Trace начинается на одном сервисе, продолжается на другом. Чтобы спаны связались в одну трассу, нужно передать trace_id и span_id через сетевой вызов.
W3C Trace Context — стандарт. Заголовок traceparent:
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01Формат: version-trace_id-parent_span_id-flags.
OTel auto-instrumentation добавляет этот заголовок автоматически на исходящих HTTP-запросах и распарсивает на входящих. Если используете кастомный транспорт — пробросьте сами:
val carrier = HashMap<String, String>()
openTelemetry.propagators.textMapPropagator.inject(
Context.current(), carrier, MapTextMapSetter()
)
request.headers.putAll(carrier)Особый случай — асинхронные системы. Когда события идут через Kafka, trace_id передаётся в headers сообщения. На consumer-стороне создаётся новый спан, привязанный к trace через links или продолжающий ту же трассу.
Trace + log correlation
Главное преимущество tracing — возможность связать с логами. В каждый лог-запись добавляется trace_id и span_id. В Jaeger/Tempo видим спан, по trace_id ищем логи в Loki/ELK.
// Spring Boot logback, MDC заполняется автоматически OTel-агентом
logger.info("order created",
"order_id", order.id,
"user_id", order.userId
)
// В выводе: trace_id=abc123 span_id=def456 order_id=...Это превращает «найти логи проблемы» из глобального grep'а в выборку по trace_id. Экономит часы расследования инцидентов.
Как читать трейсы
Открыли Jaeger, увидели Gantt-диаграмму со спанами. Что искать.
Длинные спаны. Самый длинный спан — там, где задержка. Если это HTTP-вызов — соседний сервис тормозит. Если DB-запрос — план выполнения.
Параллельность vs последовательность. Несколько спанов идут последовательно, хотя могли бы параллельно — повод для оптимизации. Часто видно, что fetch'и можно сделать одновременно.
Гэпы между спанами. Между концом одного и началом следующего пустое пространство — где-то синхронная блокировка, GC pause, ожидание в очереди.
Ошибки в leaf-спанах. Если глубоко в дереве спан со статусом ERROR — это первоисточник. Распространяется наверх как ошибка во всех родителях.
Аномальное количество спанов. Один запрос вдруг породил 200 child-спанов, обычно их 5 — значит, n+1 проблема, цикл вызовов, который должен быть batch-запросом.
Подводные камни
Несколько вещей, которые я ловил.
Sampling на старте, ошибки потеряны. Head-based sampling 1% — взяли запрос, который оказался с ошибкой. На лог-уровне сохранили (логи всегда полные), но trace-context не сохранён. В итоге ошибка есть в логе, а контекста, как она возникла, нет.
Лекарство — обязательно сохранять трассы со статусом ERROR (либо tail-based, либо headRule «if has error → keep»).
Гигантские атрибуты. Вставили в атрибут полный JSON-тело запроса, размер спана — 10 КБ. На 1000 RPS — 10 МБ/сек на трейсы. Бэкенд не справляется. Атрибуты должны быть короткими.
Async-context loss. В Java переход на executor service без правильной обвязки теряет current span. На executor-задачах создаются orphan-спаны, которые не привязаны к трассе. OTel дает обертки (Context.taskWrapping(executor)), используйте их.
Слишком детальные спаны. Каждая функция — span. Один запрос порождает 5000 спанов. Backend задыхается, читать невозможно. Span — это про существенные операции (HTTP-вызов, DB-запрос, бизнес-операция), не про каждую строчку кода.
Несовпадение часов между сервисами. Два сервиса с расходящимся NTP — спаны выглядят так, что child начался до parent'а. NTP обязательна.
Trace в production без security review. Атрибуты содержат чувствительную информацию, бэкенд не покрыт ограничениями доступа. Хуже, чем не иметь tracing — иметь утечку через него.
Что запомнить
OpenTelemetry — стандарт, и им стоит пользоваться вместо проприетарных SDK от Datadog/NewRelic/etc. Дальше выбирайте бэкенд (Tempo, Jaeger, любой managed) — это уже второстепенно.
Auto-instrumentation покрывает 80% кейсов: HTTP, DB, очереди. Ручная — для бизнес-логики, где важно видеть конкретные операции с контекстом.
Sampling — обязателен на любых заметных объёмах. Tail-based лучше head-based, потому что сохраняет важные трассы.
Trace + logs correlation — главная польза tracing. Без MDC trace_id в логах половина пользы потеряна.
Distributed tracing не «решает» проблем — оно делает их видимыми. Видеть проблему за 5 минут вместо 5 часов — это экономия инцидентов и нервов команды.