← назад к разделу

Метрика говорит «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 и кастомные проверки.