Модуль: Observability · Уровень: Middle+/Senior

TL;DR#

Distributed tracing восстанавливает путь одного запроса через множество сервисов как дерево/DAG спанов. Trace = набор спанов с общим trace_id; span = одна операция (start/end/attributes/events/status/kind), ссылающаяся на родителя. Связь между сервисами держится на context propagation: W3C traceparent (= version-trace_id-span_id-flags) инжектится в исходящие заголовки и извлекается на входе. Sampling решает, какие трейсы хранить: head-based (решение на старте, дёшево, но может пропустить редкие ошибки) или tail-based (решение после завершения трейса в коллекторе — можно отобрать по latency/error, но дорого по памяти/буферизации). Чтение трейса = поиск critical path, gaps (сеть/блокировки/GC), fan-out и N+1. Корреляция: trace_id в логах, exemplars из метрик в трейс. Senior-грабли: разрыв контекста на context.Background(), потеря спана в горутине без ctx, clock skew, и RecordErrorSetStatus.

Теория#

Модель данных#

trace_id = 4bf92f3577b34da6a3ce929d0e0e4736   (один на весь запрос)

[SERVER] GET /checkout            span A (root, 230ms)
  ├─[CLIENT] POST cart-service    span B (parent=A, 40ms)
  │   └─[SERVER] cart handler     span C (parent=B, в другом сервисе)
  ├─[CLIENT] POST payment         span D (parent=A, 150ms)  ← critical path
  └─[PRODUCER] publish order      span E (parent=A, async)
        ⋮ link
      [CONSUMER] order-worker     span F (link→E, позже)

Span содержит:

  • trace_id, span_id, parent_span_id
  • start/end time → duration
  • attributes (key=value: http.method, db.statement, user.id)
  • events (точечные отметки во времени: exception, cache miss)
  • status (Ok/Error/Unset)
  • kind: SERVER/CLIENT/PRODUCER/CONSUMER/INTERNAL — критично для бэкенда (как строить causality, кто инициатор сетевого вызова)
  • links — ссылки на другие спаны вне parent-child
tracer := otel.Tracer("checkout")
ctx, span := tracer.Start(ctx, "charge",
    trace.WithSpanKind(trace.SpanKindClient),
    trace.WithAttributes(attribute.String("payment.provider", "stripe")),
)
defer span.End()

span.AddEvent("retry", trace.WithAttributes(attribute.Int("attempt", 2)))

if err != nil {
    span.RecordError(err)                       // событие exception
    span.SetStatus(codes.Error, "charge failed") // помечает спан как failed
}

Trace context propagation (W3C)#

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
             │  trace_id (16 байт / 32 hex)      span_id (8б)     trace-flags
             version (00)                        (parent для next) (01 = sampled)

tracestate: vendor1=value,vendor2=value   (vendor-специфичный контекст, упорядочен)
  • version 00 — текущая версия формата.
  • trace_id — 16 байт, общий на весь трейс.
  • span_id (parent-id в заголовке) — id текущего спана, станет parent’ом следующего hop’а.
  • flags — битовая маска; младший бит = sampled. Именно он переносит решение сэмплера между сервисами → consistent sampling.

Inject / Extract:

// исходящий запрос — inject (обычно делает otelhttp.Transport)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

// входящий запрос — extract (обычно делает otelhttp.Handler)
ctx = otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier(r.Header))

B3 (Zipkin legacy) — старый формат (X-B3-TraceId, X-B3-SpanId, X-B3-Sampled), часто поддерживают параллельно с W3C для миграции через composite propagator.

Parent-child выражает «вызвал и ждёт». Но бывает fan-in / batch, где у спана несколько «причин»:

  • Batch processing: воркер обрабатывает 100 сообщений из очереди в одном спане — у него 100 producer-спанов как links, а не один parent.
  • Fan-out aggregation: спан, агрегирующий результаты N параллельных запросов.
  • Async: producer (publish) и consumer (обработка позже) связаны link’ом, а не parent-child, т.к. consumer не «внутри» producer.
ctx, span := tracer.Start(ctx, "process-batch",
    trace.WithLinks(
        trace.Link{SpanContext: msg1SpanCtx},
        trace.Link{SpanContext: msg2SpanCtx},
    ),
)

Sampling#

Head-basedTail-based
Когда решениеНа старте корневого спанаПосле завершения всего трейса
ГдеВ приложении (SDK)В коллекторе (gateway)
КритерииВероятность / rate / parentlatency, error, attributes — по факту
СтоимостьДёшевоДорого (буфер всех спанов трейса в памяти)
МинусПропускает редкие медленные/ошибочные трейсы (решает до того, как узнал результат)Память, late spans, сложность

Head-based виды:

  • Probabilistic (TraceIDRatioBased(0.1)) — детерминированный хеш trace_id, 10%.
  • Parent-based — уважать решение родителя (флаг sampled в traceparent) → consistent trace.
  • Rate-limiting — N трейсов/сек.

Каноничный head-config: ParentBased(TraceIDRatioBased(p)) — корень бросает кубик, потомки уважают флаг родителя → трейс семплируется целиком или не семплируется вовсе.

Tail-based — коллектор буферизует все спаны трейса до его завершения (по таймауту/закрытию root), затем применяет политику: «оставить все трейсы с ошибкой», «оставить если latency > 1s», «1% успешных». Так редкие проблемные трейсы не теряются. Цена — gateway-collector держит спаны в памяти и должен собрать весь трейс (проблема с очень долгими/незакрытыми трейсами и late spans, приходящими после решения).

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

  • Critical path — самая длинная цепочка зависимых спанов, определяющая total latency. Оптимизировать имеет смысл только её. Параллельные ветки короче critical path не влияют на итог.
  • Gaps между спанами — белое пространство в timeline = время вне инструментированного кода: сеть, ожидание блокировки/lock, GC-пауза, очередь пула, time-to-first-byte.
  • N+1 в трейсе — лесенка из сотен одинаковых коротких CLIENT-спанов к БД = классический N+1 запрос.
  • Fan-out — много параллельных CLIENT-спанов из одного родителя; смотри, не ограничивает ли самый медленный (tail amplification).
  • Async gaps — большой разрыв между producer и consumer (link) = задержка в очереди, а не в обработке.

Корреляция трёх сигналов#

  • Логи: класть trace_id/span_id в каждую запись (через slog Handler из ctx) → из лога прыжок в трейс.
  • Метрики → трейс: exemplars — точки гистограммы латенси несут trace_id примера; кликаешь медленную точку → конкретный медленный трейс.
  • Span events — заменяют часть «локальных» логов внутри спана, давая контекст без отдельной log-строки.

Подводные камни / gotchas#

  • Разрыв контекста: где-то создан новый context.Background() вместо проброса входящего ctx → дочерний спан становится root, трейс рвётся.
  • Потеря спана в горутине: запустил go func(){...} без передачи ctx → работа не связана с трейсом. Передавай ctx в горутину (но не используй уже отменённый ctx, если работа должна пережить запрос).
  • Head sampling пропускает редкие ошибки: решение принято до того, как стало известно, что запрос упал/тормозил. Для «всегда ловить ошибки» нужен tail sampling.
  • Tail sampling — память и late spans: коллектор буферизует трейсы; спаны, пришедшие после принятия решения, теряются; долгие трейсы раздувают буфер.
  • Partial traces: несогласованный sampling между сервисами (разные политики, нет ParentBased) → часть спанов записана, часть нет → битый трейс.
  • Clock skew: рассинхрон часов между хостами искажает timeline — дочерний спан «раньше» родителя, отрицательные gaps. Бэкенды частично корректируют, но точность страдает.
  • RecordErrorSetStatus: первый добавляет event, но не делает спан failed; нужно ещё SetStatus(codes.Error, ...), иначе error-rate и tail-policy не сработают.
  • Слишком много спанов = шум + стоимость хранения + оверхед. Не оборачивай каждую функцию; спан — это значимая граница (RPC, БД, важная стадия).
  • trace_id в логе есть, а трейса нет: трейс не прошёл sampling (флаг 0), а лог пишется всегда → ссылка ведёт в пустоту. Логируй sampled-флаг или принимай это как норму.

Вопросы на собеседовании#

В: Что несёт traceparent и какой бит критичен для распределённого sampling? О: version-trace_id-span_id-trace-flags. trace_id общий на весь трейс, span_id текущего спана (станет parent’ом следующего hop’а), а в trace-flags младший бит = sampled. Именно он переносит решение сэмплера между сервисами: с ParentBased downstream уважает этот флаг, поэтому трейс семплируется целиком или не семплируется вовсе.

В: Head vs tail sampling — trade-offs и где tail реализуется? О: Head решает на старте в SDK — дёшево, но не знает исхода запроса, поэтому теряет редкие медленные/ошибочные трейсы. Tail решает после завершения трейса в gateway-коллекторе, который буферизует все спаны трейса, и может оставить именно ошибочные/медленные. Цена tail — память на буфер, сложность сборки всего трейса и проблема late spans. На практике часто комбинируют: небольшой head-sample + tail для error/slow.

В: Зачем span links, если есть parent-child? О: Parent-child = «вызвал и ждёт результат», одно дерево. Links нужны для many-to-one и async: batch-воркер обрабатывает много сообщений (links на все producer-спаны), fan-in агрегация, и producer/consumer через очередь (consumer связан с producer link’ом, а не parent, т.к. выполняется не внутри него). Без links эти связи либо теряются, либо ломают дерево.

В: Как найти, что оптимизировать, по трейсу? О: Найти critical path — самую длинную цепочку зависимых спанов, она определяет общую латенси; ускорение параллельных веток короче её ничего не даст. Затем смотреть gaps (сеть/lock/GC/очередь), N+1 (лесенка одинаковых БД-спанов) и fan-out tail (самый медленный из параллельных). Атрибуты и events дают причину.

В: Чем RecordError отличается от SetStatus и почему это важно для tail sampling? О: RecordError добавляет event-исключение, но спан остаётся Unset/Ok. SetStatus(codes.Error, ...) помечает спан как failed. Tail-политики и error-rate в UI смотрят именно на status, поэтому без SetStatus ошибочный трейс не попадёт в «оставить все ошибки» и не отразится в метриках ошибок. Нужны оба.

В: Как обеспечить, чтобы один трейс не оказался «полузаписанным»? О: Consistent sampling: ParentBased(TraceIDRatioBased(p)) на всех сервисах. Корень принимает решение по детерминированному хешу trace_id, потомки уважают флаг sampled из traceparent. Так все сервисы согласованно пишут или не пишут спаны одного трейса. Без ParentBased каждый сервис решает независимо → partial traces.

В: Как трейсы коррелируются с логами и метриками? О: trace_id/span_id кладутся в каждую лог-запись (через ctx-aware Handler) → из лога открыть трейс. Метрики связываются через exemplars: точки гистограммы латенси несут trace_id примера, клик ведёт к конкретному медленному трейсу. Внутри трейса span events заменяют часть логов. Всё крутится вокруг общего trace_id.

На что копают на senior+#

  • Архитектура tail sampling: gateway-collector, буферизация по trace_id, политики (latency/error/composite), проблема late spans и долгих трейсов, память и масштабирование.
  • Consistent sampling across services: ParentBased, детерминированный хеш trace_id, головной семплинг + согласование с метриками.
  • Репрезентативность при sampling: если считать RPS/error-rate из сэмплированных трейсов — нужна коррекция на sampling rate (или метрики должны идти отдельным неосэмплированным каналом).
  • Critical path analysis и автоматическое выявление аномалий по трейсам.
  • Clock skew и его влияние на timeline; стратегии нормализации.
  • Cost/cardinality трейсинга: где экономить, span vs event, ограничение глубины/числа спанов, exemplars вместо хранения всех трейсов.
  • Корреляция трёх сигналов end-to-end в Grafana Tempo/Loki/Mimir и в OTel Collector.