Загрузка
Все статьи

Kotlin + OpenTelemetry: распределённый трейсинг без слёз

В микросервисной архитектуре с 12 сервисами запрос проходит через 4-5 сервисов. Когда что-то тормозит — непонятно где. Добавили OpenTelemetry: теперь каждый запрос имеет trace ID, и мы видим полную цепочку вызовов с таймингами. Рассказываю, как настроить OpenTelemetry в Kotlin-микросервисе на Ktor и Spring Boot без боли.

У нас 12 микросервисов. Типичный запрос от пользователя проходит через 4–5 из них: шлюз, сервис авторизации, основной бизнес-сервис, сервис уведомлений, сервис аналитики. Когда фронтенд жалуется, что «страница грузится 8 секунд», — первый вопрос: где тормозит? И вот тут начинается боль.

Мы сидели в Slack, передавали друг другу логи: «у меня запрос отработал за 200мс», «а у меня за 50мс», «а вот этот — 6 секунд». Без единого контекста. Без понимания, что это вообще один и тот же запрос. Потом кто-то находил correlation ID в заголовках, дальше ручной grep по логам... В общем, вы понимаете.

Что такое OpenTelemetry на практике

OpenTelemetry (OTel) — это стандарт для сбора телеметрии: трейсов, метрик и логов. Для распределённого трейсинга нас интересуют три понятия:

Trace — весь путь запроса через систему. Уникальный trace-id пробрасывается через все сервисы.

Span — единичная операция внутри трейса. HTTP-запрос, вызов БД, внешнее API — всё это спаны. У каждого спана есть родитель, поэтому строится дерево.

Context Propagation — механизм проброса trace-id и span-id между сервисами. Обычно через заголовки traceparent и tracestate.

Когда всё работает, в Jaeger или Grafana Tempo вы видите дерево вызовов и сразу понимаете, где узкое место. Это меняет правила игры.

Настройка в Spring Boot (Kotlin)

Со Spring Boot всё относительно просто благодаря авто-конфигурации. Добавляем зависимости:

// build.gradle.kts
dependencies {
    implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:2.5.0")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.36.0")
}

И конфигурируем в application.yml:

otel:
  exporter:
    otlp:
      endpoint: http://jaeger-collector:4317
  service:
    name: order-service
  traces:
    exporter: otlp
  resource:
    attributes:
      deployment.environment: production

Стартер автоматически создаёт спаны для всех HTTP-запросов, вызовов JDBC, gRPC и даже для Redis. Можно буквально ничего не писать в коде — и уже получить базовое трейсирование.

Но «базовое» — это не всегда достаточно.

Кастомные спаны: когда автоматика не хватает

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

import io.opentelemetry.api.trace.Tracer
import io.opentelemetry.api.trace.StatusCode

class PricingService(
    private val tracer: Tracer,
    private val ruleRepository: RuleRepository,
    private val userRepository: UserRepository,
    private val promoService: PromoService
) {
    fun calculatePrice(orderId: String, userId: String): PriceResult {
        val span = tracer.spanBuilder("PricingService.calculatePrice")
            .setAttribute("order.id", orderId)
            .setAttribute("user.id", userId)
            .startSpan()

        return try {
            val rules = span.withChild("loadRules") {
                ruleRepository.findActive()
            }
            val user = span.withChild("fetchUser") {
                userRepository.findById(userId)
            }
            val promoDiscount = span.withChild("applyPromo") {
                promoService.getDiscount(orderId, user.tier)
            }

            val result = PriceResult(
                basePrice = rules.basePrice,
                discount = promoDiscount,
                finalPrice = rules.basePrice - promoDiscount
            )
            span.setAttribute("price.final", result.finalPrice)
            result
        } catch (e: Exception) {
            span.recordException(e)
            span.status = StatusCode.ERROR
            throw e
        } finally {
            span.end()
        }
    }
}

// Утилита для дочерних спанов
fun Span.withChild(name: String, block: () -> Unit) {
    val child = tracer.spanBuilder(name)
        .setParent(Context.current().with(this))
        .startSpan()
    try { block() } finally { child.end() }
}

Обратите внимание: я добавил атрибуты order.id и user.id. Это критически важно — без атрибутов вы увидите в Jaeger безликие спаны без возможности фильтрации. Атрибуты — это поисковые фильтры вашей системы наблюдаемости.

Настройка в Ktor

С Ktor чуть больше ручной работы, но это не страшно. Используем opentelemetry-kotlin от JetBrains:

fun Application.configureTracing() {
    val openTelemetry = OpenTelemetrySdk.builder()
        .setTracerProvider(
            TracerProvider.builder()
                .addSpanProcessor(
                    BatchSpanProcessor.builder(
                        OtlpGrpcSpanExporter.builder()
                            .setEndpoint("http://jaeger-collector:4317")
                            .build()
                    ).build()
                )
                .setResource(
                    Resource.getDefault().merge(
                        Resource.builder()
                            .put("service.name", "notification-service")
                            .put("deployment.environment", "production")
                            .build()
                    )
                )
                .build()
        )
        .buildAndRegisterGlobal()

    val tracer = openTelemetry.getTracer("notification-service", "1.0.0")

    intercept(ApplicationCallPipeline.Plugins) {
        val span = tracer.spanBuilder(
            "${call.request.httpMethod.value} ${call.request.uri}"
        ).setSpanKind(SpanKind.SERVER).startSpan()

        try {
            proceed()
            span.status = if (call.response.status() < HttpStatusCode.InternalServerError)
                SpanStatus.OK else SpanStatus.ERROR
        } catch (e: Throwable) {
            span.recordException(e)
            span.status = SpanStatus.ERROR
            throw e
        } finally {
            span.end()
        }
    }
}

Экспорт в Jaeger

Jaeger поднимается одним docker-compose:

version: "3.8"
services:
  jaeger:
    image: jaegertracing/all-in-one:1.54
    ports:
      - "16686:16686"  # Jaeger UI
      - "4317:4317"    # OTLP gRPC
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Через пару минут после деплоя вы открываете Jaeger UI, выбираете сервис, и видите полную картину. Первый раз, когда я увидел трейс нашего заказа — 7 сервисов, 23 спана, и чётко видно, что inventory-service делает 4 последовательных запроса в БД вместо одного — это был момент «ааа, вот оно что».

Практические советы

Первое: не пытайтесь трейсировать всё. Спаны для каждого лога — это шум. Трассируйте границы сервисов, внешние вызовы, тяжёлые вычисления. Если спанов слишком много — Jaeger становится нечитаемым.

Второе: атрибуты должны быть согласованы между сервисами. Договоритесь с командой, какие атрибуты использовать: order.id, а не orderId в одном сервисе и order_id в другом.

Третье: sampling. В продакшене не нужно трейсировать 100% запросов. Мы используем head-based sampling: 10% обычных запросов и 100% запросов с ошибками.

Четвёртое: логируйте trace-id в каждом логе. Это связывает логи с трейсами. Без этого вы будете переключаться между двумя инструментами.

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

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин