lenec ru

← все посты

Distributed tracing с OpenTelemetry: что включить и как читать спаны

12K

Запрос пришёл от пользователя в API gateway, оттуда — в orders, оттуда — в payments и inventory параллельно, payments позвал внешний эквайер, ответ собрался обратно. Где-то в этой цепочке произошёл таймаут. Без трассировки ответ на «где?» — это часовое расследование с grep'ами по логам пяти сервисов. С трассировкой — открыли trace в Jaeger, увидели спан длиной 9 секунд с пометкой «timeout», это сделано.

Я внедрял distributed tracing трижды, в трёх разных стеках: Zipkin/Brave для Spring, Jaeger с OpenTracing на Go, и последний раз — OpenTelemetry, который сейчас фактический стандарт. OpenTelemetry — не просто очередная библиотека, это попытка унифицировать всё: traces, metrics, logs, на одной модели.

Разберу базовые понятия, как правильно настраивать инструментацию, что включать в спаны и какие подводные камни лежат вне счастливого пути.

Базовые понятия

Чтобы дальнейший разговор имел смысл, нужно зафиксировать термины.

  • Trace — последовательность операций, обслуживающих один запрос. Имеет уникальный trace_id.
  • Span — отдельная операция внутри trace. У спана есть span_id, родитель (parent_span_id), время начала и конца, имя, атрибуты.
  • Context propagation — передача trace_id и span_id между сервисами через заголовки.
  • Sampling — решение, какой процент трасс сохранять.

Стандарт OpenTelemetry даёт единую модель этих штук независимо от языка и backend'а (Jaeger, Zipkin, Tempo, Datadog).

Минимальная настройка

В Spring Boot с OTel-агентом всё работает почти само:

java -javaagent:opentelemetry-javaagent.jar \
     -Dotel.service.name=orders \
     -Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
     -jar orders.jar

Агент инструментирует HTTP-клиенты, JDBC, Kafka producer/consumer, gRPC автоматически. На каждый входящий запрос создаётся root span, на каждый исходящий вызов — child span. Trace_id прокидывается через заголовок traceparent (W3C Trace Context).

Для Go и других языков auto-instrumentation менее зрелый — обычно надо вручную обернуть HTTP-клиент и server:

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

// Server
handler := otelhttp.NewHandler(http.HandlerFunc(myHandler), "my-handler")
http.ListenAndServe(":8080", handler)

// Client
client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}

Что класть в спаны

Атрибуты спана — это тэги, по которым потом ищутся проблемы. Каноничные атрибуты определены в OpenTelemetry semantic conventions, и им стоит следовать вместо изобретения своих.

Для HTTP-server-спана:

  • http.method — GET, POST.
  • http.route — паттерн пути: /users/{id}, не /users/42.
  • http.status_code — 200, 500.
  • http.url — только если это нужно (без секретов).

Для DB-спана:

  • db.system — postgresql, redis.
  • db.statement — SQL без параметров (или с redacted-значениями).
  • db.operation — SELECT, INSERT.

Для бизнес-логики, которую инструментируете руками, добавляйте бизнес-контекст:

val tracer = openTelemetry.getTracer("orders-service")

fun chargePayment(orderId: UUID, amount: Money) {
    val span = tracer.spanBuilder("charge_payment")
        .setAttribute("order.id", orderId.toString())
        .setAttribute("payment.amount", amount.value.toString())
        .setAttribute("payment.currency", amount.currency)
        .startSpan()
    
    try {
        span.makeCurrent().use {
            paymentClient.charge(orderId, amount)
        }
    } catch (e: Exception) {
        span.recordException(e)
        span.setStatus(StatusCode.ERROR, e.message ?: "")
        throw e
    } finally {
        span.end()
    }
}

Атрибуты позволяют фильтровать трассы: «покажи все trace'ы, где order.id=123» или «все вызовы charge_payment, где payment.amount > 10000».

Что НЕ класть

Несколько вещей, которые часто пытаются положить в спан и потом жалеют.

Полный body запроса. Десятки килобайт на каждом спане превращают traces в мусор. Кладите только маленькие критичные поля.

Секреты, токены, пароли. Trace-данные часто хранятся в общем backend'е с менее строгим доступом, чем prod. Никаких auth_token и password_hash в атрибутах.

PII без редактирования. Email, телефоны, паспорта в спанах = проблемы с GDPR/КИИ. Либо хешируйте, либо не кладите.

Stack traces в каждом спане. Только в спанах с ошибками. recordException делает это правильно.

Sampling

Хранить 100% трассы дорого: на 1000 RPS — 86 миллионов трасс в день. Большинство — успешные запросы, которые никому не интересны.

Стандартные стратегии sampling.

Head-based sampling

Решение принимается на старте трассы, до того как известно, как она завершится. Например, «1% всех запросов».

Плюс — простота, нет необходимости буферизовать. Минус — может пропустить редкие проблемы (взяли 1%, среди них только успешные, ошибки выбросили).

-Dotel.traces.sampler=traceidratio \
-Dotel.traces.sampler.arg=0.01

Tail-based sampling

Решение принимается после завершения трассы — ошибки/медленные сохраняются всегда, успешные семплируются.

Делается в OTel Collector с tailsamplingprocessor:

processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: slow
        type: latency
        latency:
          threshold_ms: 1000
      - name: random
        type: probabilistic
        probabilistic:
          sampling_percentage: 1

Минус — сложнее (нужен буферизующий collector), вычислительно дороже. Плюс — не теряете важные трассы.

На умеренных объёмах (до 1000 RPS) я обычно начинаю с head-based на 10-20%. На больших — переходим на tail-based, нет другого варианта.

Context propagation

Trace начинается на одном сервисе, продолжается на другом. Чтобы спаны связались в одну трассу, нужно передать trace_id и span_id через сетевой вызов.

W3C Trace Context — стандарт. Заголовок traceparent:

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01

Формат: version-trace_id-parent_span_id-flags.

OTel auto-instrumentation добавляет этот заголовок автоматически на исходящих HTTP-запросах и распарсивает на входящих. Если используете кастомный транспорт — пробросьте сами:

val carrier = HashMap<String, String>()
openTelemetry.propagators.textMapPropagator.inject(
    Context.current(), carrier, MapTextMapSetter()
)
request.headers.putAll(carrier)

Особый случай — асинхронные системы. Когда события идут через Kafka, trace_id передаётся в headers сообщения. На consumer-стороне создаётся новый спан, привязанный к trace через links или продолжающий ту же трассу.

Trace + log correlation

Главное преимущество tracing — возможность связать с логами. В каждый лог-запись добавляется trace_id и span_id. В Jaeger/Tempo видим спан, по trace_id ищем логи в Loki/ELK.

// Spring Boot logback, MDC заполняется автоматически OTel-агентом
logger.info("order created", 
    "order_id", order.id,
    "user_id", order.userId
)
// В выводе: trace_id=abc123 span_id=def456 order_id=...

Это превращает «найти логи проблемы» из глобального grep'а в выборку по trace_id. Экономит часы расследования инцидентов.

Как читать трейсы

Открыли Jaeger, увидели Gantt-диаграмму со спанами. Что искать.

Длинные спаны. Самый длинный спан — там, где задержка. Если это HTTP-вызов — соседний сервис тормозит. Если DB-запрос — план выполнения.

Параллельность vs последовательность. Несколько спанов идут последовательно, хотя могли бы параллельно — повод для оптимизации. Часто видно, что fetch'и можно сделать одновременно.

Гэпы между спанами. Между концом одного и началом следующего пустое пространство — где-то синхронная блокировка, GC pause, ожидание в очереди.

Ошибки в leaf-спанах. Если глубоко в дереве спан со статусом ERROR — это первоисточник. Распространяется наверх как ошибка во всех родителях.

Аномальное количество спанов. Один запрос вдруг породил 200 child-спанов, обычно их 5 — значит, n+1 проблема, цикл вызовов, который должен быть batch-запросом.

Подводные камни

Несколько вещей, которые я ловил.

Sampling на старте, ошибки потеряны. Head-based sampling 1% — взяли запрос, который оказался с ошибкой. На лог-уровне сохранили (логи всегда полные), но trace-context не сохранён. В итоге ошибка есть в логе, а контекста, как она возникла, нет.

Лекарство — обязательно сохранять трассы со статусом ERROR (либо tail-based, либо headRule «if has error → keep»).

Гигантские атрибуты. Вставили в атрибут полный JSON-тело запроса, размер спана — 10 КБ. На 1000 RPS — 10 МБ/сек на трейсы. Бэкенд не справляется. Атрибуты должны быть короткими.

Async-context loss. В Java переход на executor service без правильной обвязки теряет current span. На executor-задачах создаются orphan-спаны, которые не привязаны к трассе. OTel дает обертки (Context.taskWrapping(executor)), используйте их.

Слишком детальные спаны. Каждая функция — span. Один запрос порождает 5000 спанов. Backend задыхается, читать невозможно. Span — это про существенные операции (HTTP-вызов, DB-запрос, бизнес-операция), не про каждую строчку кода.

Несовпадение часов между сервисами. Два сервиса с расходящимся NTP — спаны выглядят так, что child начался до parent'а. NTP обязательна.

Trace в production без security review. Атрибуты содержат чувствительную информацию, бэкенд не покрыт ограничениями доступа. Хуже, чем не иметь tracing — иметь утечку через него.

Что запомнить

OpenTelemetry — стандарт, и им стоит пользоваться вместо проприетарных SDK от Datadog/NewRelic/etc. Дальше выбирайте бэкенд (Tempo, Jaeger, любой managed) — это уже второстепенно.

Auto-instrumentation покрывает 80% кейсов: HTTP, DB, очереди. Ручная — для бизнес-логики, где важно видеть конкретные операции с контекстом.

Sampling — обязателен на любых заметных объёмах. Tail-based лучше head-based, потому что сохраняет важные трассы.

Trace + logs correlation — главная польза tracing. Без MDC trace_id в логах половина пользы потеряна.

Distributed tracing не «решает» проблем — оно делает их видимыми. Видеть проблему за 5 минут вместо 5 часов — это экономия инцидентов и нервов команды.

Комментарии 0

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

Войдите, чтобы оставить комментарий.