DarkRiDDeR15 мин

Trace context в сентябре 2020: parent span, header и граница передачи

НаблюдаемостьHTTPАрхитектура

Симптом механической ошибки простой: gateway и catalog пишут «свои» span, но у них разные trace-id либо catalog не знает parent. Цена — ложная причинность. На экране появляется несколько длительных операций, а команда не может доказать, были ли они частью одного запроса, шли ли параллельно и какая граница действительно задержала ответ. В такой ситуации опасно выбирать виновника по самому большому числу.

Причина обычно находится не в визуализации, а в контракте передачи. Кто-то заменил incoming context новым ID, передал весь request object вместо короткого carrier-а, записал parent-id как trace-id или не закрыл span. Проверка начинается с одной строки traceparent и одной пары parent/child, а действие — с того места, где сериализация и извлечение принадлежат владельцу transport boundary. Не с покупки платформы и не с глобального middleware.

Какой стандарт был доступен на дату статьи

На дату сентября 2020 года нужно говорить точно. Trace Context Level 1 стал W3C Recommendation 06 February 2020, поэтому называть сам traceparent «черновым заголовком» неверно. Документ определяет стандартные HTTP headers и format для передачи контекста между сервисами. Он не предписывает vendor, storage или единый интерфейс поиска trace. tracestate существует как опциональное расширение поставщика, но не нужен, чтобы в первом упражнении сохранить parent/child связь.

Совсем другой статус имели OpenTelemetry API, SDK и конкретные интеграции. Исторический release v0.5.0 спецификации датирован июнем 2020 года; стабильность Trace API официальный проект относит к началу 2021-го. Поэтому не буду приписывать сентябрю 2020 готовую экосистему со стабильной автоинструментацией, collector-конфигурацией и сервисной картой. В коде используются нейтральные функции parseTrainingTraceparent и createTrainingTraceparent, чтобы объяснить сам договор, а не API конкретной библиотеки.

Четыре поля W3C traceparent version 00 в учебной модели
ПолеФорма в RecommendationРоль на границеПроверка в модуле
version00 для рассматриваемого formatговорит parser-у, как читать следующие частипринимается только ровно 00; future version не угадывается
trace-id32 lowercase hex, не все нулисобирает один logical traceодинаков у всех пяти synthetic span
parent-id16 lowercase hex, не все нулиуказывает на текущую операцию вызывающей стороныcatalog получает его как parentSpanId
trace-flags00 или 01 в учебной version 00несёт флаг контекста; не является командой доверять любому клиентуfixture принимает только документированные значения и не превращает их в policy sampling

Trace-id отвечает не на тот же вопрос, что span-id

Trace-id — идентификатор всей логической истории. В нашем примере он остаётся 4bf92f3577b34da6a3ce929d0e0e4736 от gateway до inventory.adapter. Span-id — идентификатор одной операции: у gateway a111…, у catalog b222…. Когда gateway посылает контекст, в parent-id header-а лежит его текущий span-id. Catalog создаёт новый span-id, оставляет trace-id и ставит полученный ID в поле родителя. На следующей границе процедура повторяется уже с ID catalog.

Такое различение избавляет от двух симметричных дефектов. Если сделать каждый child с новым trace-id, нельзя собрать одну историю. Если оставить один span-id на все сервисы, нельзя отличить работу gateway от работы catalog. Внешний request_id может быть полезен логам, но он не заменяет ни trace-id, ни parent relationship. Его формат и жизнь часто другие; попытка склеить все три понятия делает поиск проще на один день и запутаннее после первой интеграции.

Вертикальная диаграмма: один trace-id остаётся общим, gateway передаёт свой span-id в synthetic traceparent, catalog создаёт child span и затем передаёт уже свой span-id к inventory; у каждого шага показана отдельная граница extract и inject
Parent-id в переносимом контексте относится к текущему вызывающему span. Получатель не копирует его как свой span-id, а создаёт новый child.

Parser должен отвергать форму до создания span

W3C version 00 ожидает нижний регистр, 32 hex-символа для trace-id и 16 для parent-id; нулевые идентификаторы недопустимы. Это не косметика. Если parser принимает строку с нулями или сокращённый ID и всё равно строит span, следующий waterfall внешне выглядит целым, но связность уже ложная. Если он тихо приводит uppercase к lowercase, он скрывает, откуда пришёл несовместимый carrier. В учебной функции такой header становится ошибкой и останавливает создание child.

const rootContext = {
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
  spanId: 'a111111111111111',
  traceFlags: '01',
};

// Учебный перенос к следующей границе, не реальный исходящий HTTP-запрос.
const traceparent = createTrainingTraceparent(rootContext);
const received = parseTrainingTraceparent(traceparent);

// Получатель создаёт новый span с parentSpanId = received.parentSpanId.
const catalogSpan = {
  spanId: 'b222222222222222',
  parentSpanId: received.parentSpanId,
  traceId: received.traceId,
};

Этот код намеренно не знает слово HTTP в runtime. Он не читает реальный request, не ставит response header и не меняет приложение. Carrier представлен строкой, потому что нам нужно проверить формат и переход значений. В конкретной реализации adapter рядом с HTTP client/server будет вызывать аналогичную serialization/extraction логику. Если framework уже делает это сам, сначала читают его документацию и пишут тест на один transport; двойной inject может создать лишние или конфликтующие span.

В Recommendation trace-flags несут рекомендацию о recording, а не право любому внешнему отправителю включить затратный сбор. Для version 00 fixture принимает только 00 и 01, но не вводит sampling policy. Решение о доверии входному контексту, лимитах и том, где создавать новый root, остаётся за проектом. Это особенно важно на публичной границе: внешний caller не должен управлять внутренними затратами просто потому, что прислал похожую строку.

Parent/child — это причина, а не отступ в JSON

Parent/child связь отвечает на вопрос «какая операция породила эту работу?». В простой синхронной модели child целиком лежит внутри времени parent. Gateway ждёт catalog, catalog ждёт inventory, inventory ждёт adapter. Эта вложенность даёт дереву порядок. Если pricing и inventory запускаются рядом, они оба могут быть child catalog, но их интервалы перекрываются. Дерево говорит о происхождении, а ось времени — о том, какая ветка фактически продлевает root.

Для очереди или batch это предположение может быть неверным. Один consumer может обработать несколько сообщений, а один follow-up может жить после ответа исходного HTTP-запроса. Там нельзя притворяться, что у span всегда один честный родитель, который целиком его охватывает. В документации OpenTelemetry для таких связей обсуждаются links, но в сентябре 2020 я не превращаю этот термин в рецепт. Текущий пакет сознательно ограничен одним синхронным учебным деревом, потому что только для него fixture проверяет interval containment.

Что именно означает связь в одном синхронном waterfall
НаблюдениеКорректный выводНекорректный выводПроверка
У catalog.lookup parent = gatewaycatalog вызван в рамках gateway историиcatalog сам по себе занял всё время gatewaychild лежит внутри 0–240 ms root
pricing и inventory имеют одного parentветви созданы в одном catalog spanих duration надо сложитьинтервалы 30–70 и 30–200 перекрываются
adapter parent = inventoryadapter часть inventory операции170 ms inventory плюс 70 ms adapter дают 240 msadapter 100–170 находится внутри inventory
trace-id одинаковspan можно читать как одну исторювсе нужные границы уже инструментированыпосмотреть ожидаемый список span и отрицательный сценарий

Duration — inclusive время, пока не доказано другое

Duration span — разность его finish и start. У gateway это 240 ms, у catalog 200 ms, у inventory 170 ms, у adapter 70 ms. В обычной трассе duration parent чаще всего inclusive: ожидание дочерних операций уже находится внутри него. Поэтому общий response time не получают сложением всех строк в waterfall. Такое сложение повторно считает одно и то же время и легко превращает 240 ms учебного запроса в воображаемые 680 ms.

function readSpanDuration(span) {
  if (span.endMs <= span.startMs) throw new Error('invalid interval');
  return span.endMs - span.startMs;
}

// У parent duration включает время child span.
const inventory = { startMs: 30, endMs: 200 };
const adapter = { startMs: 100, endMs: 170 };

readSpanDuration(inventory); // 170 ms, inclusive
readSpanDuration(adapter);   // 70 ms, child внутри inventory

// Нельзя складывать 170 + 70: это два пересекающихся интервала.

В модуле exclusive время считается как интервал span за вычетом объединения прямых child-интервалов. У root остаётся 40 ms вне catalog, у catalog — 30 ms вне child-интервалов, у inventory — 100 ms вне adapter, у adapter — 70 ms. Для выбранной цепочки эти exclusive отрезки дают 240 ms root-а. Это простая арифметика на одном logical clock. Она не делает fixture универсальным алгоритмом critical path для любого trace store и не обещает точность после clock skew или неполного instrumentation.

Как fixture выбирает critical path

В этом дереве root имеет единственного поздно заканчивающегося child — catalog. У catalog позднее заканчивается inventory, а у inventory — adapter. Поэтому fixture выбирает gateway.handle → catalog.lookup → inventory.fetch → inventory.adapter. Pricing заканчивается раньше и идёт параллельно с inventory, так что не определяет момент окончания root. Здесь «critical» означает только: если сократить эту последовательную ветку в модели, root сможет закончиться раньше. Это не синоним «самая дорогая строка» и не назначение виноватого.

Алгоритм специально прозрачен: он выбирает child с самым поздним endMs, затем считает exclusive вклад выбранных span. Если в реальном trace два child завершаются одновременно, есть links, retries или неполные timestamps, такого правила недостаточно. Нужен другой вопрос: что именно измеряет инструмент, как синхронизированы часы и какие зависимости зафиксированы. Пока ответа нет, лучше показать несколько конкурирующих ветвей, чем поставить жирную стрелку «critical path» без доказательства.

Вертикальная схема чтения critical path: parent duration включает дочерний interval, поэтому inventory 170 ms и adapter 70 ms не суммируются; из exclusive сегментов gateway 40, catalog 30, inventory 100 и adapter 70 складывается root 240 ms, а pricing 40 ms остаётся параллельной ветвью
Критический путь читается по временной зависимости, а не по сумме всех видимых duration. Схема относится только к controlled fixture с одной шкалой времени.

Маршрут проверки transport boundary

  1. Назвать ровно одну синхронную границу, на которой потеря контекста мешает разбору: gateway → catalog или catalog → inventory.
  2. Зафиксировать, кто создаёт root, кто извлекает incoming context и кто создаёт child. Не поручать это одновременно middleware и прикладной функции.
  3. Проверить format version 00, lowercase hex, ненулевой trace-id и parent-id до создания child span.
  4. Сверить на controlled fixture: trace-id сохраняется, child получает текущий parent, все интервалы положительные.
  5. Отдельно решить политику входного недоверенного context и sampling; не использовать один trace-flags как готовое бизнес-правило.
  6. Только после этого выполнить изолированный transport test выбранной библиотеки и записать её версию рядом с проверкой.

Ограничения механизма

Header не создаёт trace сам по себе. Он может быть корректно передан, а exporter выключен; может существовать exporter, но часть библиотек не создаёт span; может быть выборка, в которой полный trace отсутствует. W3C Recommendation решает interoperability format, а не хранение и полноту. Поэтому в статье отсутствуют synthetic URL, token, реальная HTTP-команда и утверждение, будто доставка header-а уже проверена в какой-либо инфраструктуре.

Точно так же один child с большим inclusive duration не доказывает причину. Он говорит, что в пределах текущего instrumentation операция жила дольше остальных. Следующий шаг — посмотреть её прямые children, свой exclusive участок и условия завершения. Если общая шкала состоит из часов разных машин, сначала надо проверить timestamp source. Такой порядок сохраняет голос М3: автор уже связывает события между сервисами, но ещё не выдаёт учебное дерево за опыт эксплуатации общей платформы.

Проверяемые источники

  • W3C Trace Context Level 1 — Recommendation, 06 February 2020 — историческая Recommendation задаёт поля traceparent версии 00, правила проверки trace-id/parent-id и различает перенос контекста с участием в trace
  • OpenTelemetry Specification v0.5.0 — historical changelog — tagged revision от 2 июня 2020 года; версия до 1.0 фиксирует развивающуюся спецификацию, а не готовую кросс-языковую платформу
  • OpenTelemetry: Libraries — historical stability note — официальная документация относит стабильность Trace API к началу 2021 года; это редакционная граница, из-за которой статья сентября 2020 не обещает стабильный SDK