Модуль: 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, и RecordError ≠ SetStatus.
Теория#
Модель данных#
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.
Span links#
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-based | Tail-based | |
|---|---|---|
| Когда решение | На старте корневого спана | После завершения всего трейса |
| Где | В приложении (SDK) | В коллекторе (gateway) |
| Критерии | Вероятность / rate / parent | latency, 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. Бэкенды частично корректируют, но точность страдает.
RecordError≠SetStatus: первый добавляет 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.