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

Метрика говорит «p95 вырос до 3 секунд». Лог говорит «много ошибок в payment-service». Но ни метрика, ни лог не покажут, что именно произошло с конкретным запросом от клиента — через какие сервисы он прошёл и где потерял время.

Distributed tracing (распределённая трассировка) решает именно это: записывает путь каждого запроса через все сервисы системы. Вы видите полный маршрут — POST /orders → auth-service → payment-service → notification-service — с временем на каждом шаге.

Вот тот же запрос на три секунды, разложенный трассировкой по шагам:

метрика: p95 POST /orders = 3 с. trace: где именно эти 3 с01 с2 с3 сPOST /orders3000 мс auth-service40 мсpayment-service2850 мсnotification110 мсдети корня закрывают все 3000 мс: 40 + 2850 + 110 — почти всё время в payment-service внутри payment-service — те же 2850 мс по шагамSELECT orders30 мсPOST sber/charge2740 мсUPDATE orders80 мс30 + 2740 + 80 = 2850 мс: тормозит не своя база, а один внешний вызов notification через @Async без TaskDecorator — span вне traceв дереве остаются 2890 из 3000 мс: дыра 110 мс без объяснениято же со span, у которого span.end() не в finally — он не придёт метрика называет число, trace показывает: 2740 мс из 3000 — один вызов

Trace раскладывает те же 3 секунды по шагам: 2740 мс из 3000 съедает один внешний вызов — а потерянный контекст оставляет в дереве необъяснённую дыру.

Обязательно

Что такое 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».

Настройте адрес коллектора. Ключи otel.* читаются как есть, поэтому писать их проще плоско — так же, как в документации OpenTelemetry:

otel.exporter.otlp.endpoint=http://otel-collector:4317
otel.traces.sampler=parentbased_traceidratio
otel.traces.sampler.arg=0.1

Порт в адресе — это ещё и выбор протокола: 4317 — OTLP по gRPC, 4318 — тот же OTLP по HTTP, и там в адресе нужен путь (http://otel-collector:4318/v1/traces). Открыт у коллектора обычно один из двух; если трассы не доезжают, первым делом сверьте порт.

Как traceId путешествует между сервисами

Второй сервис должен как-то узнать, что он часть того же запроса, что и первый, — а между ними только HTTP. Поэтому идентификатор трассировки едет в заголовке, и его формат зафиксирован стандартом W3C Trace Context — заголовок traceparent:

traceparent: 00-5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4-1f2e3d4c5b6a7980-01

Здесь закодированы: версия протокола, traceId (16 байт), spanId (8 байт) и флаги (например, «этот запрос отслеживается»).

OpenTelemetry делает это автоматически: при входящем запросе извлекает traceparent из заголовков и присоединяет к текущему trace; при исходящем HTTP-вызове или отправке Kafka-сообщения — добавляет заголовок. Сервисы не пишут для этого никакого кода.

Добавить бизнес-контекст в span

Автоматические spans содержат технические детали — URL, статус, имя таблицы. Чтобы потом в Jaeger найти трассировки по orderId, нужно добавить бизнес-атрибуты вручную.

@Service
public class ConfirmOrderHandler {

    private final OrderRepository orderRepository;
    private final Tracer tracer;

    public ConfirmOrderHandler(OrderRepository orderRepository, OpenTelemetry openTelemetry) {
        this.orderRepository = orderRepository;
        this.tracer = openTelemetry.getTracer("order-service");
    }

    @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();
        }
    }
}

Посмотрите на конструктор. Стартер кладёт в контекст бин OpenTelemetry, а вот бина Tracer в контексте нет — трассировщик получают вызовом getTracer с именем своего сервиса. Если попробовать внедрить Tracer напрямую, приложение не поднимется.

Важно: span.end() в блоке finally — обязательно. Если span не закрыть, коллектор никогда не получит данные — трассировка будет висеть как незавершённая.

Если не нужны атрибуты и recordException, проще аннотация:

@WithSpan("confirmOrder")
public Order handle(ConfirmOrderCommand command) { ... }

Сама аннотация приезжает вместе со стартером, но обрабатывает её аспект — а значит, в сборке нужен ещё и spring-boot-starter-aop. Без него код соберётся и запустится, просто спана не будет: аннотацию некому прочитать.

Что класть в атрибуты, а что нельзя

Атрибуты spans хранятся отдельно — в Tempo, Jaeger, Honeycomb — и почти всегда с другими правами доступа и сроком хранения, чем база: к трассировкам пускают всю разработку, хранят их месяцами, а выгрузить дамп проще, чем таблицу клиентов. Поэтому персональные данные туда класть нельзя: утечка трассировок — утечка тех же данных без единой защиты базы.

Можно:

  • внутренние идентификаторы: order.id, customer.id, payment.id;
  • перечисления и статусы: order.status, payment.method;
  • технические метки: external.system="sber", circuit_breaker.state="open".

Нельзя:

  • email, телефон, имя клиента;
  • номер карты, IBAN, паспорт;
  • тело запроса целиком.

Имя спана — тоже низкая кардинальность

Про идентификаторы в тегах метрик предупреждают все, а про то же самое в именах спанов — почти никто, хотя ломается ровно так же.

Имя спана — это то, по чему трассы группируются: интерфейс хранилища показывает список операций с их числом и распределением времени. Если имя выглядит как GET /orders/42, каждый заказ образует свою операцию: список операций растёт до бесконечности, «покажи мне p95 по этому эндпоинту» не работает, потому что каждый вызов уникален, а поиск по имени бесполезен. Правильное имя — шаблон: GET /orders/{id}, SELECT orders, charge payment. То есть имя отвечает на вопрос «какая это операция», а не «с какими данными»; данные идут в атрибуты, где высокая кардинальность допустима и стоит дёшево.

Автоматические спаны это соблюдают сами (имя берётся из шаблона обработчика), а ломается это на ручных: tracer.spanBuilder("charge order " + orderId) — самая частая ошибка при первом знакомстве. Правильно — spanBuilder("charge order") и setAttribute("order.id", orderId).

Sampling: сколько трассировок сохранять

Записывать 100% трассировок в продакшене — дорого. Средненагруженный сервис (1000 запросов/с) даёт примерно десять тысяч спанов в секунду — по спану на каждый шаг запроса. При 100% sampling это под сотню гигабайт в сутки, и это с одного сервиса.

Стандартный подход — parentbased_traceidratio с 1-10%:

  • если входящий запрос уже помечен как «отслеживаемый» (флаг в traceparent) — сервис тоже участвует в трассировке;
  • иначе — отслеживается случайная доля запросов.

На стороне коллектора настраивают tail-based sampling: 100% трассировок с ошибками сохраняются независимо от основного коэффициента. Это даёт полный набор ошибочных трассировок без перегрузки хранилища.

head-based 100 запросов жребий на входе 1 % сохранён ошибки потеряны tail-based 100 запросов ждём конца 1 % успешных все ошибки есть

Сто запросов, часть из них с ошибкой: смотрите, что решение на входе теряет ошибочные трассы, а решение в конце оставляет их все.

Для малонагруженных сервисов (менее 10 запросов/с) 100% sampling — нормально.

Tracing в логах: связать запись лога с трассировкой

opentelemetry-logback-mdc-1.0 автоматически дописывает идентификаторы трассировки к каждой строке лога. Имена полей — trace_id и span_id, через нижнее подчёркивание: так их называет спецификация OpenTelemetry (переименовать можно, как это делается — в статье про контекст). Если вы пишете JSON-логи, поля попадают в каждую запись:

{
  "@timestamp": "2026-05-25T22:30:00Z",
  "level": "ERROR",
  "message": "Failed to charge payment: orderId=12345",
  "trace_id": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
  "span_id": "1f2e3d4c5b6a7980"
}

В Grafana кликаете на trace_id в Loki — открывается Tempo с полной трассировкой этого запроса. Вы видите, какой лог соответствует какому span в каком сервисе.

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

Разговор о цене трассировки обычно сводится к объёму хранилища, а для эксплуатации важнее другое: что происходит в самом сервисе, пока он трассируется, и что будет, когда коллектор отвалится.

Накладные расходы на запрос. Создание спана — это выделение объекта, отметки времени и строковые атрибуты. На один запрос с десятком спанов это единицы микросекунд и несколько килобайт мусора — не то, что видно на фоне похода в базу. Но два места умеют портить эту картину: спан в цикле (вызов на каждую строку из десяти тысяч — и вы получаете десять тысяч спанов на запрос, трассу, которую хранилище откажется показывать, и заметную нагрузку на сборщик мусора) и дорогие атрибуты — setAttribute("payload", mapper.writeValueAsString(obj)) считается всегда, даже если трасса не будет сохранена.

Правило: спан — на операцию, а не на элемент; атрибуты — готовые значения, а не результат сериализации.

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

Когда коллектор недоступен. Это главный вопрос, и ответ успокаивающий: очередь заполняется, и новые спаны молча отбрасываются. Сервис не тормозит, запросы не падают, память не растёт бесконечно — теряются только данные наблюдаемости. Это сознательное решение стандарта: трассировка не должна ронять то, что она наблюдает. Заметить потерю можно по собственным метрикам экспортёра (число отброшенных спанов) — и на них стоит иметь алерт, иначе вы обнаружите пропажу трассировки в тот момент, когда она понадобится для разбора.

Чего всё-таки стоит бояться. Не самой трассировки, а её синхронной настройки: экспортёр, отправляющий каждый спан отдельным запросом (так делают в примерах «для простоты») превращает поход в коллектор в часть обработки запроса. В продакшне всегда пакетный обработчик, а простой экспортёр — только в тестах.

Где трасса рвётся: брокер сообщений

С HTTP всё просто: заголовок едет сам. С очередью сообщений картина другая, и это самое частое место, где трасса разваливается на две несвязанные половины — «заказ создан» в одном сервисе и «письмо отправлено» в другом, и связи между ними нет.

Механика та же: контекст едет в заголовках сообщения. У Kafka заголовки есть, и автоматическое инструментирование производителя кладёт туда traceparent, а потребителя — достаёт и создаёт дочерний спан. То есть при подключённом инструментировании обеих сторон трасса продолжается сама. Ломается это в четырёх местах.

Ручная отправка мимо инструментирования. Сообщение публикуется не тем клиентом, который обёрнут, — например, через собственную обёртку, которая собирает запись сама. Тогда заголовок не проставляется, и потребитель начинает новую трассу. Лечится либо инструментированием, либо явной вставкой контекста в заголовки.

Потеря заголовков по дороге. Мост между брокерами, ретрансляция, перекладывание сообщения из одной очереди в другую «своими руками» — если при пересоздании записи заголовки не скопировали, контекст потерян. Это же относится к схеме, где событие сначала пишется в таблицу исходящих сообщений: контекст надо сохранить в строке таблицы вместе с событием, иначе отправщик, забирающий её через минуту, никакого контекста уже не имеет.

Пакетная обработка. Потребитель забирает сто сообщений из разных трасс и обрабатывает их вместе. Одного родителя у такого спана нет, и это не поломка, а свойство: правильный способ — свой спан на обработку пачки, а к нему связи (span links) на трассы отдельных сообщений. Так в интерфейсе видно и пачку, и переход к каждой исходной трассе.

Долгое ожидание в очереди. Сообщение пролежало в очереди двадцать минут — и трасса растягивается на двадцать минут, где настоящая работа заняла секунду. Трасса при этом верна, но читать её неудобно, а в хранилище она попадает с задержкой, потому что закрывается только с последним спаном. Это причина, по которой асинхронную часть смотрят ещё и метриками отставания, а не только трассами.

Как проверить у себя за пять минут: опубликовать событие, найти трассу производителя и посмотреть, есть ли в ней спан потребителя. Если нет — открыть само сообщение в брокере и посмотреть заголовки: traceparent там либо есть, либо нет, и это сразу говорит, на какой стороне искать.

Когда трассировка не нужна

Трассировка — не бесплатная добавка, а система со своей инфраструктурой (коллектор, хранилище, сроки, дежурство). Есть случаи, где она не оправдывает этой цены, и лучше честно сказать «не сейчас», чем подключить и не смотреть.

Один сервис без исходящих вызовов. Вся ценность трассировки — в связывании нескольких участников. Если сервис принимает запрос, читает свою базу и отвечает, то всё, что покажет трасса, уже есть в метриках (время ответа) и журнале (что случилось). Правильный первый шаг для такого сервиса — журнал с общим идентификатором запроса и метрики, а трассировка появляется вместе со вторым сервисом или внешним вызовом.

Пакетные задачи. Задача, которая раз в сутки обрабатывает миллион записей, даёт либо одну трассу длиной в час (бесполезно: хранилища не рассчитаны на такие трассы), либо миллион трасс (дорого и незачем). Здесь работают метрики прогресса и журнал с числами, а трассировка — только для отдельных шагов, которые ходят наружу.

Очень редкий трафик. Десять запросов в сутки — трассы будут, и полные, поскольку выборку в таком случае не включают. Вопрос в другом: нужна ли под это инфраструктура, если ту же картину даёт журнал. Обычно у такого сервиса трассировка появляется потому, что она уже есть в компании, — и это нормальная причина.

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

Обратная сторона: где трассировка окупается сразу — цепочка из трёх и более сервисов, асинхронная обработка через брокер, жалобы вида «иногда медленно» без воспроизведения, и подозрение на соседа, который отвечает медленно только на часть запросов.

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

Trace обрывается при @Async. Когда Spring выполняет метод на другом потоке через @Async или CompletableFuture.runAsync(...), контекст OTel не передаётся автоматически — trace обрывается. Решение: TaskDecorator, который копирует OTel-контекст в новый поток.

Это про стартер, который подключён в статье. Есть и второй способ подключить трассировку — javaagent OpenTelemetry, который надстраивается над байт-кодом при запуске: он оборачивает и пулы потоков тоже, и контекст туда переезжает сам. Граница проходит именно здесь: со стартером за передачу контекста в чужой поток отвечаете вы, с агентом — агент.

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();
}
Дополнительно: при первом чтении можно пропустить

Глубже: OpenTelemetry Collector: зачем отдельный процессрасширенное

«На стороне коллектора настраивают» встречается в статьях раздела дважды без объяснения, что это. Приложение может слать трассы, метрики и логи напрямую в хранилище, и на стенде так и делают. В проде между ними ставят коллектор, отдельный процесс, и причин четыре.

Развязка с хранилищем. Приложение экспортирует по одному протоколу (OTLP) на localhost или на адрес коллектора и не знает, что за ним: Jaeger, Tempo, коммерческий сервис или два хранилища сразу. Смена поставщика это правка конфигурации коллектора, а не выкат всех сервисов; отказ хранилища принимает на себя буфер коллектора, а не память приложения.

Пачки, повторы, лимит памяти. Коллектор собирает данные в пачки, повторяет отправку при сбое хранилища и ограничивает собственную память, сбрасывая лишнее вместо падения. Приложение освобождается от этой работы и от риска, что экспортёр трассировки съест его память при инциденте хранилища.

Выборка по хвосту. Приложение решает, сохранять ли трассу, в её начале, когда ещё не знает, кончится ли она ошибкой. Коллектор видит трассу целиком (для этого все спаны одной трассы должны приходить на один экземпляр коллектора, что решается балансировкой по идентификатору трассы) и может оставить все трассы с ошибками и медленные, а из успешных быстрых сохранить процент. Это единственный способ иметь и низкую цену, и все ошибки в трассах.

Обработка по дороге. Процессоры коллектора добавляют атрибуты (окружение, регион), вырезают персональные данные по шаблону, отбрасывают спаны проверок здоровья, переименовывают поля под соглашения, и маршрутизируют: трассы в одно хранилище, метрики в другое, логи аудита в третье с длинным сроком. Всё это одним правилом на все сервисы вместо правки каждого.

Метрики из спанов. Отдельная возможность, которая экономит много ручной работы: коллектор умеет считать из проходящих спанов метрики — число вызовов, долю ошибок и распределение времени по операциям. Получается то же самое, что вы писали таймерами руками, только без единой строки кода и одинаково для всех сервисов сразу, включая вызовы к базе и к соседям. Ограничения два: метрики считаются только по сохранённым спанам, поэтому при выборке один процент цифры придётся домножать (или считать метрики до выборки, ставя обработчик раньше), и имена операций становятся метками — то есть кардинальность имён спанов превращается в кардинальность рядов, о чём раздел про имена выше.

Практическая раскладка: стандартные метрики запросов оставляют за приложением (они точные и без выборки), а метрики по вызовам соседей и по базе удобнее получать из спанов.

Как ставят. Как sidecar рядом с каждым подом (проще, дороже по ресурсам), как агент на каждом узле (обычный вариант) и как отдельный слой шлюзов перед хранилищами, куда агенты отправляют пачки; выборка по хвосту живёт на шлюзах. Конфигурация это приёмники, процессоры и экспортёры, соединённые в конвейеры по типу сигнала, и она лежит в репозитории инфраструктуры рядом с остальным кодом. Приложению остаётся одна настройка: адрес коллектора.

Коротко

  • Distributed tracing показывает путь конкретного запроса через все сервисы с временем на каждом шаге. OpenTelemetry — отраслевой стандарт; opentelemetry-spring-boot-starter даёт автоматические spans для HTTP, JDBC, Kafka, кэша без кода.
  • traceparent (W3C) — заголовок, через который traceId путешествует между сервисами; OTel передаёт его автоматически. opentelemetry-logback-mdc-1.0 автоматически дописывает trace_id/span_id в каждую лог-запись — и она становится кликабельной ссылкой на trace.
  • Manual spans нужны для бизнес-операций с атрибутами; span.end() в finally — обязательно. В атрибуты можно класть внутренние идентификаторы и статусы; персональные данные — нельзя.
  • Sampling 1-10% в продакшене + tail-based 100% для ошибок — баланс полноты и стоимости хранения.
  • @Async и CompletableFuture.runAsync обрывают trace без TaskDecorator.
  • Коллектор OpenTelemetry развязывает приложение с хранилищем, собирает пачки и повторы, делает выборку по хвосту (все ошибки и медленные), вырезает персональные данные и маршрутизирует сигналы; ставят агентом на узле и шлюзами перед хранилищами.
  • Имя спана — шаблон операции, а не данные: идентификатор в имени ломает группировку так же, как тег высокой кардинальности ломает метрики.
  • Внутри сервиса трассировка стоит микросекунды и очередь пакетного экспортёра; при недоступном коллекторе спаны молча отбрасываются, а не тормозят запросы, поэтому на число отброшенных нужен алерт.
  • На брокере трасса рвётся чаще всего: контекст едет в заголовках сообщения, при записи в таблицу исходящих его сохраняют в строке, а пачку связывают со исходными трассами ссылками.
  • Трассировка не нужна одиночному сервису без исходящих вызовов и пакетным задачам, и подключается после структурированных журналов с общим идентификатором, а не вместо них.

Что почитать дальше