В новом переводе от команды Spring АйО смотрим, как подружить современный Spring Boot и OpenTelemetry так, чтобы данные уходили по OTLP в любой совместимый бэкенд. 

В экосистеме Spring большая часть телеметрии была завязана на Micrometer Project (Был ещё spring-cloud-sleuth если кто помнит). Но полноценного all-in-one решения для того, чтобы Spring Boot приложение просто начало экспортировать телеметрию по OTLP не было. До Spring Boot 4.

На данный момент для интеграции OTel в Spring Boot приложения есть 3 пути: Java Agent (минимум кода, но чувствителен к версиям и может конфликтовать с другими агентами), сторонний OTel starter (стартер от самих OpenTelemetry, но тянет alpha-зависимости) и новый spring-boot-starter-opentelemetry, доступный в Spring Boot 4.0. Про него и будет речь.

Введение

В современных cloud native архитектурах наблюдаемость (observability) — уже не опция, а базовое требование. Вам нужно понимать, что делает приложение: через метрики — как оно работает, через трассировки — как через него проходят запросы, и через логи — что оно вообще делает.

Проект OpenTelemetry, который иногда сокращают до OTel, предоставляет вендоронезависимый open-source-фреймворк для сбора, обработки и экспорта телеметрических данных. Под эгидой Cloud Native Computing Foundation он предлагает API, SDK, стандартный сетевой протокол OTLP для экспорта данных и модульную архитектуру (включая OpenTelemetry Collector) для приема, обработки и отправки данных в бэкенды.

Инструментированные проекты и библиотеки используют API, чтобы выдавать данные телеметрии. SDK, реализующий этот API, применяется для настройки того, как данные собираются и экспортируются. Collector — опциональный компонент, который может помочь агрегировать и фильтровать данные, но можно отправлять их напрямую в любой совместимый бэкенд.

Комментарий от Михаила Поливаха

Вообще для Production деплойментов Otel Collector это вещь нужная, лучше всё же с ней работать, чем без неё. 

Опять же, в теории задача простая - отправить данные по телеметрии в условный стек Grafana-ы. Просто когда речь о продакшене, то там всегда возникают вопросы типа ретраев, типа батчевой отправки и т.д, то есть вся вот эта обвязка.

Как мы все знаем, микросервис у нас уже давно не микросервис, так что нагрмождать его лишней логикой, которая для прода нужна, это лишнее. В общем, используйте коллекторы. Какие конкретно - зависит от вашего Otel стека, но мысль я думаю понятна.

Экосистема Spring обладает сильной поддержкой наблюдаемости через Micrometer, а сочетание Spring Boot и OpenTelemetry — мощный способ покрыть все сигналы наблюдаемости (метрики, трассировки и логи). Ключевым фактором здесь выступает протокол OTLP, а не конкретная библиотека.

В этом посте мы разберем, что означает OpenTelemetry, и посмотрим, как интегрировать его со Spring Boot, чтобы идеально закрыть задачу наблюдаемости.

Использование OpenTelemetry со Spring Boot

Когда вы решаете интегрировать OpenTelemetry со Spring Boot, есть несколько альтернативных путей — от «просто подключить runtime-агент» до «использовать встроенную поддержку Spring». У вас есть три варианта:

Использование OpenTelemetry Java Agent

Начать очень просто: вы подключаете opentelemetry-javaagent.jar через флаг -javaagent при запуске. Этот агент выполняет байткод-инструментацию библиотек (HTTP, JDBC, Spring и т. д.). Это самый простой путь «без изменений кода». Агент сам разбирается с трассами, спанами (спан — атомарная часть трассы), метриками и т. д.

Комментарий от Михаила Поливаха

Вообще если прямо хотите детально разобраться, откуда ноги растут у именно трассировки, то я рекомендую ознакомиться c Scientific Paper от Google Research, которая посвещена Dapper:

https://research.google/pubs/dapper-a-large-scale-distributed-systems-tracing-infrastructure/

Тут как раз Google описывает вообще стратегии трейсинга, семплирование, роль Span-а как абстрации в DAG-е трейса и т.д. От архитектуры Dapper-а соотвественно во многом в своё время отталкивались как Jaeger, так и Zipking.

Главная проблема такого подхода в том, что Java-агент должен достаточно точно соответствовать версиям ваших библиотек, потому что он модифицирует их байткод. Если есть несовпадение между версиями, с которыми агент тестировался, и версиями, которые используете вы, проблемы бывает сложно диагностировать. Кроме того, если вы используете native-image в GraalVM или хотите задействовать AOT-кэш Java, придется пройти через дополнительные сложности. Также, если у вас уже используется какой-то агент, они могут конфликтовать.

Использование стороннего OpenTelemetry Spring Boot Starter

У OpenTelemetry есть собственный Spring Boot starter, который инструментирует некоторые технологии. Однако они отмечают, что их выбор по умнолчанию для инструментирования — это OpenTelemetry Java Agent, а starter стоит использовать только если агент применить нельзя. Кроме того, хотя сам starter помечен как стабильный, он подтягивает зависимости с суффиксом alpha.

Использовать OpenTelemetry Spring Boot Starter от команды Spring

В Spring Boot 4.0 мы представляем новый Spring Boot Starter для OpenTelemetry. Он называется spring-boot-starter-opentelemetry (мы, конечно, очень изобретательны в названиях) и доступен через зависимость «OpenTelemetry» на https://start.spring.io.

Мы можем быть пристрастны, но это наш любимый вариант, чтобы добавить наблюдаемость в приложение на Spring Boot.

Starter включает OpenTelemetry API и компоненты для экспорта сигналов Micrometer по OTLP. Micrometer Tracing с мостом OpenTelemetry использует OpenTelemetry API, чтобы экспортировать трассировки в формате OTLP.

Комментарий от Михаила Поливаха

Сигналы в Otel это такой umbrella term для всех видов телеметрии, которые экспортируются через Otel, типа трейсы, метрики, логи и т.д. Это всё одним словом называют "сигналы".

Micrometer OtlpMeterRegistry используется для отправки метрик, собранных через Micrometer API, в ваш OpenTelemetry-совместимый метрик-бэкенд по протоколу OTLP.

Повторим: важно именно то, какой протокол используется, а не какая библиотека. Даже если портфельные проекты Spring используют Micrometer как API наблюдаемости, сигналы вполне можно экспортировать по OTLP в любой OpenTelemetry-совместимый бэкенд — как вы скоро увидите.

Spring Boot также поддерживает отправку логов по OTLP в OpenTelemetry-совместимый бэкенд, но «из коробки» не устанавливает лог-аппендеры для Logback и Log4j2. В будущем это может измениться, но даже сейчас это несложно сделать, и мы тоже разбираем настройку в этом посте.

Остальная часть поста будет посвящена тому, как получить бесшовный опыт OpenTelemetry в приложении на Spring Boot с использованием нового Spring Boot Starter for OpenTelemetry от команды Spring.

Экспорт метрик

Как уже упоминалось, Spring Boot использует OTLP Regsitry в Micrometer для экспорта метрик Micrometer по OTLP в любой OpenTelemetry-совместимый бэкенд. Нужная зависимость io.micrometer:micrometer-registry-otlp автоматически включена в spring-boot-starter-opentelemetry. При наличии этой зависимости Micrometer экспортирует метрики в формате OTLP в бэкенд по адресу http://localhost:4318/v1/metrics. Чтобы настроить адрес экспорта метрик, установите свойство management.otlp.metrics.export.url, например:

management.otlp.metrics.export.url=http://otlp.example.com:4318/v1/metrics

Команда Micrometer также добавила так называемые “соглашения наблюдений” (observation conventions) для OpenTelemetry.

Комментарий от Михаила Поливаха

Обычно когда говорят "Observation Conventions" имеют в виду как раз Semantic Convention OpenTelemetry. Это такие условно соглашения о наименовании имен метрик, тегов и т.д.

Это просто общие конвенции наименования различных атрибутов сигналов, например все договорились, что когда метрика репортит размер тела http ответа она будет называться http.server.response.body.size и т.д.

Сигналы в OpenTelemetry следуют Semantic Convention OpenTelemetry, а соглашения наблюдений в Micrometer реализуют стабильные части Semantic Convention OpenTelemetry. Чтобы использовать их в Spring Boot, нужно определить некоторую конфигурацию (вероятно, в будущем это будет улучшено):

@Configuration(proxyBeanMethods = false)
public class OpenTelemetryConfiguration {

    @Bean
    OpenTelemetryServerRequestObservationConvention openTelemetryServerRequestObservationConvention() {
        return new OpenTelemetryServerRequestObservationConvention();
    }

    @Bean
    OpenTelemetryJvmCpuMeterConventions openTelemetryJvmCpuMeterConventions() {
        return new OpenTelemetryJvmCpuMeterConventions(Tags.empty());
    }

    @Bean
    ProcessorMetrics processorMetrics() {
        return new ProcessorMetrics(List.of(), new OpenTelemetryJvmCpuMeterConventions(Tags.empty()));
    }

    @Bean
    JvmMemoryMetrics jvmMemoryMetrics() {
        return new JvmMemoryMetrics(List.of(), new OpenTelemetryJvmMemoryMeterConventions(Tags.empty()));
    }

    @Bean
    JvmThreadMetrics jvmThreadMetrics() {
        return new JvmThreadMetrics(List.of(), new OpenTelemetryJvmThreadMeterConventions(Tags.empty()));
    }

    @Bean
    ClassLoaderMetrics classLoaderMetrics() {
        return new ClassLoaderMetrics(new OpenTelemetryJvmClassLoadingMeterConventions());
    }

}

Spring Boot не настраивает OpenTelemetry API для метрик автоматически. Если вы действительно хотите использовать OpenTelemetry Metrics API (что мы не рекомендуем) вместо Micrometer API, или если у вас есть библиотека, использующая OpenTelemetry Metrics API, пожалуйста, обратитесь к документации Spring Boot о том, как это настроить.

Экспорт трассировок

Проекты Spring используют Micrometer Observation API для создания наблюдений (observations). Наблюдение (так называемый Observation) — интересная концепция в Micrometer, потому что его можно преобразовать и в метрику, и в трассировку.

Трассировки, сгенерированные наблюдениями, затем адаптируются через зависимость io.micrometer:micrometer-tracing-bridge-otel (которая также входит в spring-boot-starter-opentelemetry) к API OpenTelemetry.

Spring Boot содержит автонастройку для OpenTelemetry SDK. Для трассировок он устанавливает OtlpHttpSpanExporter (или OtlpGrpcSpanExporter, если вы предпочитаете gRPC вместо HTTP). С этим экспортером OpenTelemetry SDK начинает отправлять трассы (которые исходно появляются из Micrometer Observation) в формате OTLP в ваш бэкенд.

Чтобы включить это в приложении, нужно задать свойство management.opentelemetry.tracing.export.otlp.endpoint, например:

management.opentelemetry.tracing.export.otlp.endpoint=http://localhost:4318/v1/traces

Экспорт логов

Как уже говорилось, Spring Boot содержит автонастройку, которая конфигурирует OpenTelemetry SDK с возможностью экспортировать логи в формате OTLP. Однако он не устанавливает аппендер в Logback или Log4j2, поэтому, хотя базовая инфраструктура есть, реальные логи не экспортируются. Чтобы экспортировать логи в формате OTLP, нужно сделать две вещи:

Во-первых, задать свойство management.opentelemetry.logging.export.otlp.endpoint, например:

management.opentelemetry.logging.export.otlp.endpoint=http://localhost:4318/v1/logs

И во-вторых, установить аппендер в Logback или Log4j2, который отправляет записи логов в OpenTelemetry API.

Для Logback сначала нужно добавить зависимость io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:2.21.0-alpha (суффикс -alpha в версии означает, что компонент помечен как нестабильный; к сожалению, для аппендера нет не-alpha версий. Подробнее об этом можно прочитать здесь).

Затем нужно создать кастомную конфигурацию logback, которая размещается в файле src/main/resources/logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <appender name="OTEL" class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender">
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="OTEL"/>
    </root>
</configuration>

Эта конфигурация импортирует базовую конфигурацию Logback из Spring Boot, а затем определяет дополнительный аппендер с именем OTEL, который отправляет все события логирования в OpenTelemetry API. Этот аппендер затем добавляется к корневому логгеру, фактически отправляя все записи логов в OpenTelemetry API в дополнение к выводу в консоль.

Последнее, что нужно сделать, — сообщить OpenTelemetryAppender, какой экземпляр OpenTelemetry API использовать. Для этого можно создать небольшой бин, в который будет внедрен экземпляр OpenTelemetry:

@Component
class InstallOpenTelemetryAppender implements InitializingBean {

    private final OpenTelemetry openTelemetry;

    InstallOpenTelemetryAppender(OpenTelemetry openTelemetry) {
        this.openTelemetry = openTelemetry;
    }

    @Override
    public void afterPropertiesSet() {
        OpenTelemetryAppender.install(this.openTelemetry);
    }
    
}

Следите за контекстом


Логи, метрики и трассировки используют контекстную информацию, например текущий trace ID. По умолчанию, когда Micrometer Tracing находится в classpath, Spring Boot корректирует шаблон логов так, чтобы в сообщение также включались trace ID и span ID. Это может быть очень полезно, если вы пытаетесь найти логи, относящиеся к конкретной трассировке.

Полезный прием — включать trace ID запроса в ответ сервера, например через HTTP-заголовок. Тогда, если пользователи получают ответ с ошибкой от вашего HTTP-эндпойнта, они могут указать trace ID в тикете, а вы сможете по этому trace ID найти все логи, относящиеся ровно к этому ошибочному запросу.

Поместить trace ID в заголовок можно с помощью такого Servlet-фильтра:

@Component
class TraceIdFilter extends OncePerRequestFilter {
    
    private final Tracer tracer;

    TraceIdFilter(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String traceId = getTraceId();
        if (traceId != null) {
            response.setHeader("X-Trace-Id", traceId);
        }
        filterChain.doFilter(request, response);
    }

    private @Nullable String getTraceId() {
        TraceContext context = this.tracer.currentTraceContext().context();
        return context != null ? context.traceId() : null;
    }
    
}

Если вы работаете с методами, которые переключают потоки, например с методами, помеченными @Async, или используете Spring AsyncTaskExecutor, вы заметите, что в новом потоке контекст теряется. Потеря контекста влияет на логи (в них больше нет trace ID) и на трассировки (теряются спаны).

Контекст теряется потому, что он хранится в ThreadLocal, который не переносится в новый поток. Однако решение довольно простое: используйте ContextPropagatingTaskDecorator в AsyncTaskExecutor (который также применяется для методов с @Async). ContextPropagatingTaskDecorator использует Context Propagation API из Micrometer, чтобы обеспечить перенос trace-контекста в новые потоки. Установить ContextPropagatingTaskDecorator просто: достаточно определить метод @Bean, как показано в следующем фрагменте:

@Configuration(proxyBeanMethods = false)
public class ContextPropagationConfiguration {

    @Bean
    ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
        return new ContextPropagatingTaskDecorator();
    }

}

Автонастройка Spring Boot ищет бины TaskDecorator и устанавливает их в AsyncTaskExecutor. С ContextPropagatingTaskDecorator контекст начинает переноситься в новые потоки, устраняя потерю trace ID в логах и потерю спанов. В будущем вся настройка с ContextPropagatingTaskDecorator, вероятно, будет улучшена для более бесшовного опыта.

Распространение контекста — снова

Если у вас несколько сервисов, разве не было бы здорово, чтобы trace ID совпадал во всех сервисах, и вы могли посмотреть один trace и увидеть все задействованные сервисы? Или найти логи всех сервисов, участвующих в этой трассировке? Именно отсюда и «distributed» в distributed tracing.

Теперь вопрос: как вызываемый сервис узнает, что он является частью уже идущей трассировки? Это снова распространение контекста, но на этот раз контекст переносится не между потоками, а через границы процессов.

Для распространения контекста по HTTP существует рекомендация W3C, и Spring Boot настраивает все задействованные компоненты на ее использование «из коробки». Отправляющая сторона должна добавить текущий trace ID в заголовок, а принимающая — извлечь trace ID из заголовка и восстановить контекст.

Все работает бесшовно, если соблюдать одно простое правило: вы не говорите о Бойцовском клубе. Ой, извините, не тот сценарий. Единственное правило, которого нужно придерживаться: не создавайте RestTemplate / RestClient / WebClient вручную через new. Вместо этого внедряйте RestTemplateBuilder / RestClient.Builder / WebClient.Builder и используйте их для создания клиента.

Spring Boot автонастраивает билдера всей необходимой инфраструктурой, чтобы автоматически отправлять trace-контекст в заголовке. Если вы создадите клиент сами через new, этой инфраструктуры не будет, и контекст не будет распространяться — что приведет к грустным дежурным инженерам и красным плиткам на ретроспективах спринта.

Посмотрим это в действии

Мы подготовили пример проекта, с которым можно поэкспериментировать с наблюдаемостью OpenTelemetry. Он состоит из трех сервисов:

Сервис пользователей (user-service) использует in-memory базу H2 и Spring Data JDBC, чтобы искать пользователей по user ID. Он предоставляет HTTP API для поиска пользователя по заданному user ID.

Сервис приветствий (greeting-service) имеет HTTP API, который возвращает приветствие на языке, указанном в заголовке Accept-Language. Он знает приветствия на английском, немецком и испанском.

Сервис hello (hello-service) — входная точка. У него есть HTTP API, который принимает user ID и возвращает приветствие для этого пользователя. Для этого он вызывает сервис пользователей с user ID, чтобы получить имя пользователя. Он также вызывает сервис приветствий, чтобы получить приветствие. Затем он объединяет имя пользователя с приветствием и возвращает результат.

Сначала запустим все три сервиса. В настройку также входит модуль spring-boot-docker-compose, который автоматически обнаруживает файл конфигурации Docker Compose с именем compose.yaml и выполняет docker compose up. Файл compose.yaml содержит один сервис с образом grafana/otel-lgtm. Стек Grafana LGTM включает OTLP-совместимые бэкенды для логов, метрик и трассировок, упакованные вместе в одном UI, который мы можем использовать для просмотра сигналов наблюдаемости.

После того как Docker-контейнер запущен и работает, Spring Boot автоматически настраивает экспортеры логов, метрик и трассировок так, чтобы они указывали на контейнер Docker. Поэтому мы не находим свойства экспорта OTLP, упомянутые ранее, в application.properties: все происходит «за кулисами». При деплое в продакшен эти свойства нужно задавать самостоятельно. Если хотите подробнее прочитать про эту фичу developer experience, пожалуйста, ознакомьтесь с этим постом в блоге.

Теперь выполним первый запрос:

> curl -i http://localhost:8080/api/1
HTTP/1.1 200 
X-Trace-Id: 0dbe0809731e35081d6db16c2ca0ef91
Content-Type: application/json
Content-Length: 12

Hello Moritz

Отлично, сработало. Теперь попробуем по-немецки:

> curl -i http://localhost:8080/api/1 --header "Accept-Language: de"
HTTP/1.1 200 
X-Trace-Id: 6c0753c7ec390fff15fcf05f536e21cd
Content-Type: application/json
Content-Length: 12

Hallo Moritz

Отлично, теперь у нас есть два trace ID, с которыми можно поиграться. Обратите внимание: trace ID для запросов включены в заголовок X-Trace-Id с использованием Servlet-фильтра выше.

Давайте посмотрим в интерфейсе Grafana, есть ли у нас какие-то логи:

Здесь мы видим, что логи были отправлены в Grafana по OTLP, и теперь мы можем просматривать все логи из трех сервисов в одном интерфейсе. Также можно найти логи из всех сервисов для заданного trace ID.

Теперь посмотрим, сможем ли мы найти трассировку по этому trace ID.

Нашли! Здесь мы видим, что hello-service вызывает greeting-service и user-service — в виде наглядной «водопадной» диаграммы. Вы также можете раскрыть спан, чтобы посмотреть его атрибуты:

В этом случае мы использовали аннотацию Micrometer @SpanTag, чтобы прикрепить к спану локаль приветствия (en_US) и идентификатор пользователя (1). Давайте посмотрим на второй trace — там должна быть немецкая локаль:

Отлично, все работает как ожидается.

Последняя остановка — давайте посмотрим на метрики, которые генерируют сервисы:

Здесь вы видите пользовательскую метрику с названием say-hello (она создана аннотацией метода @Observed(name = "say-hello")), которая считает, сколько раз было сгенерировано приветственное сообщение.

Кроме того, «из коробки» вы получаете множество метрик о своем приложении — например, количество потоков в executors, метрики HTTP-сервера и т. д.

Или множество JVM-метрик:

Заключение

Надеемся, вам понравилось это стремительное путешествие по OpenTelemetry со Spring Boot. Как вы увидели, Spring Boot предлагает отличные интеграции с OpenTelemetry. Используете ли вы Observation API из Micrometer или нет — с точки зрения интеграции с OTel это не так уж важно. Важно то, что используется протокол OTLP: именно он имеет значение и абстрагирует тот API, которым было выполнено инструментирование приложения.

Spring Boot 4.0 с новым стартером OpenTelemetry вышел 20 ноября. Micrometer 1.16 с улучшениями для OpenTelemetry и множеством других новых возможностей тоже уже выпущен.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.