Метрика говорит «p95 вырос до 3 секунд». Лог говорит «много ошибок в payment-service». Но ни метрика, ни лог не покажут, что именно произошло с конкретным запросом от клиента — через какие сервисы он прошёл и где потерял время.
Distributed tracing (распределённая трассировка) решает именно это: записывает путь каждого запроса через все сервисы системы. Вы видите полный маршрут — POST /orders → auth-service → payment-service → notification-service — с временем на каждом шаге.
Что такое span и trace
Раньше трассировку реализовывали каждая компания по-своему: Zipkin, Jaeger, AWS X-Ray — у каждого свой клиент и формат. Переключить бэкенд было мучительно.
Сейчас есть OpenTelemetry — открытый стандарт и набор SDK, который работает с любым бэкендом (Jaeger, Tempo, Datadog, Honeycomb). Один код — любой storage.
Два ключевых понятия:
- Span — одна операция: входящий HTTP-запрос, запрос к базе, вызов другого сервиса. У span есть имя, время начала и конца, теги (attributes) и ссылка на родительский span.
- Trace — дерево связанных spans. Все spans одного запроса через все сервисы формируют единый trace с общим
traceId.
Когда order-service вызывает payment-service, он передаёт traceId в HTTP-заголовке. payment-service видит его и создаёт дочерний span — так spans связываются в дерево.
Подключение: автоматические spans без кода
Добавьте стартер в build.gradle:
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")
implementation("io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0")
После этого Spring Boot автоматически создаёт spans для:
- входящих HTTP-запросов (Spring MVC) — с атрибутами
http.method,http.url,http.status_code; - исходящих HTTP-запросов (
RestClient,WebClient) — с передачейtraceIdдальше; - каждого SQL-запроса через JDBC;
- отправки и получения сообщений Kafka;
- кэш-операций через Spring Cache.
Без единой строки своего кода уже видно полную картину: «HTTP → SQL → Kafka → исходящий HTTP».
Настройте endpoint коллектора в application.yml:
otel:
exporter:
otlp:
endpoint: http://otel-collector:4317
traces:
sampler: parentbased_traceidratio
sampler.arg: 0.1
Как traceId путешествует между сервисами
Стандарт W3C Trace Context описывает формат заголовка traceparent:
traceparent: 00-5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4-1f2e3d4c5b6a7980-01
Здесь закодированы: версия протокола, traceId (16 байт), spanId (8 байт) и флаги (например, «этот запрос отслеживается»).
OpenTelemetry делает это автоматически: при входящем запросе извлекает traceparent из заголовков и присоединяет к текущему trace; при исходящем HTTP-вызове или отправке Kafka-сообщения — добавляет заголовок. Сервисы не пишут для этого никакого кода.
Добавить бизнес-контекст в span
Автоматические spans содержат технические детали — URL, статус, имя таблицы. Чтобы потом в Jaeger найти трассировки по orderId, нужно добавить бизнес-атрибуты вручную.
@Service
@RequiredArgsConstructor
public class ConfirmOrderHandler {
private final OrderRepository orderRepository;
private final Tracer tracer;
@Transactional
public Order handle(ConfirmOrderCommand command) {
var span = tracer.spanBuilder("confirmOrder")
.setAttribute("order.id", command.orderId())
.startSpan();
try (var scope = span.makeCurrent()) {
var order = orderRepository.findById(command.orderId())
.orElseThrow();
order.confirm();
span.setAttribute("order.status", order.status().name());
return orderRepository.save(order);
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
}
}
Важно: span.end() в блоке finally — обязательно. Если span не закрыть, коллектор никогда не получит данные — трассировка будет висеть как незавершённая.
Если не нужны атрибуты и recordException, проще аннотация:
@WithSpan("confirmOrder")
public Order handle(ConfirmOrderCommand command) { ... }
Что класть в атрибуты, а что нельзя
Атрибуты spans хранятся отдельно — в Tempo, Jaeger, Honeycomb — часто с другими правами доступа и сроком хранения. Персональные данные туда класть нельзя.
Можно:
- внутренние идентификаторы:
order.id,customer.id,payment.id; - перечисления и статусы:
order.status,payment.method; - технические метки:
external.system="sber",circuit_breaker.state="open".
Нельзя:
- email, телефон, имя клиента;
- номер карты, IBAN, паспорт;
- тело запроса целиком.
Sampling: сколько трассировок сохранять
Записывать 100% трассировок в продакшене — дорого. Средненагруженный сервис (1000 запросов/с) при 100% sampling даёт терабайты данных в день.
Стандартный подход — parentbased_traceidratio с 1-10%:
- если входящий запрос уже помечен как «отслеживаемый» (флаг в
traceparent) — сервис тоже участвует в трассировке; - иначе — отслеживается случайная доля запросов.
На стороне коллектора настраивают tail-based sampling: 100% трассировок с ошибками сохраняются независимо от основного коэффициента. Это даёт полный набор ошибочных трассировок без перегрузки хранилища.
Для малонагруженных сервисов (менее 10 запросов/с) 100% sampling — нормально.
Tracing в логах: связать запись лога с трассировкой
opentelemetry-logback-mdc-1.0 автоматически добавляет traceId и spanId в MDC (Mapped Diagnostic Context). Если вы пишете JSON-логи, эти поля попадают в каждую запись:
{
"@timestamp": "2026-05-25T22:30:00Z",
"level": "ERROR",
"message": "Failed to charge payment: orderId=12345",
"traceId": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
"spanId": "1f2e3d4c5b6a7980"
}
В Grafana кликаете на traceId в Loki — открывается Tempo с полной трассировкой этого запроса. Вы видите, какой лог соответствует какому span в каком сервисе.
Частые ошибки
Trace обрывается при @Async. Когда Spring выполняет метод на другом потоке через @Async или CompletableFuture.runAsync(...), контекст OTel не передаётся автоматически — trace обрывается. Решение: TaskDecorator, который копирует OTel-контекст в новый поток.
Span без try-finally. Если создали span вручную, но span.end() не гарантирован (например, бросается исключение до него) — span никогда не придёт в коллектор.
// Так не делать — при исключении span не закроется
var span = tracer.spanBuilder("foo").startSpan();
doWork();
span.end();
// Правильно
var span = tracer.spanBuilder("foo").startSpan();
try (var scope = span.makeCurrent()) {
doWork();
} finally {
span.end();
}
Коротко
- Distributed tracing показывает путь конкретного запроса через все сервисы с временем на каждом шаге.
- OpenTelemetry — отраслевой стандарт;
opentelemetry-spring-boot-starterдаёт автоматические spans для HTTP, JDBC, Kafka, кэша без кода. - traceparent (W3C) — заголовок, через который traceId путешествует между сервисами; OTel передаёт его автоматически.
- Manual spans нужны для бизнес-операций с атрибутами;
span.end()вfinally— обязательно. - В атрибуты можно класть внутренние идентификаторы и статусы; персональные данные — нельзя.
- Sampling 1-10% в продакшене + tail-based 100% для ошибок — баланс полноты и стоимости хранения.
opentelemetry-logback-mdc-1.0автоматически кладётtraceId/spanIdв MDC — каждая лог-запись становится кликабельной ссылкой на trace.@AsyncиCompletableFuture.runAsyncобрывают trace безTaskDecorator.
Что почитать дальше
- Логирование в Java — структурированные логи и связка с traceId.
- Метрики в Java — Micrometer, Prometheus и почему traceId не подходит для меток.
- Health checks в Java — liveness, readiness и кастомные проверки.