Метрика говорит «p95 вырос до 3 секунд». Лог говорит «много ошибок в payment-service». Но ни метрика, ни лог не покажут, что именно произошло с конкретным запросом от клиента — через какие сервисы он прошёл и где потерял время.
Distributed tracing (распределённая трассировка) решает именно это: записывает путь каждого запроса через все сервисы системы. Вы видите полный маршрут — POST /orders → auth-service → payment-service → notification-service — с временем на каждом шаге.
Вот тот же запрос на три секунды, разложенный трассировкой по шагам:
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% трассировок с ошибками сохраняются независимо от основного коэффициента. Это даёт полный набор ошибочных трассировок без перегрузки хранилища.
Сто запросов, часть из них с ошибкой: смотрите, что решение на входе теряет ошибочные трассы, а решение в конце оставляет их все.
Для малонагруженных сервисов (менее 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 развязывает приложение с хранилищем, собирает пачки и повторы, делает выборку по хвосту (все ошибки и медленные), вырезает персональные данные и маршрутизирует сигналы; ставят агентом на узле и шлюзами перед хранилищами.
- Имя спана — шаблон операции, а не данные: идентификатор в имени ломает группировку так же, как тег высокой кардинальности ломает метрики.
- Внутри сервиса трассировка стоит микросекунды и очередь пакетного экспортёра; при недоступном коллекторе спаны молча отбрасываются, а не тормозят запросы, поэтому на число отброшенных нужен алерт.
- На брокере трасса рвётся чаще всего: контекст едет в заголовках сообщения, при записи в таблицу исходящих его сохраняют в строке, а пачку связывают со исходными трассами ссылками.
- Трассировка не нужна одиночному сервису без исходящих вызовов и пакетным задачам, и подключается после структурированных журналов с общим идентификатором, а не вместо них.
Что почитать дальше
- Логирование в Java — структурированные логи и связка с traceId.
- Метрики в Java — Micrometer, Prometheus и почему traceId не подходит для меток.
- Health checks в Java — liveness, readiness и кастомные проверки.