Симптом механической ошибки простой: 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 конкретной библиотеки.
| Поле | Форма в Recommendation | Роль на границе | Проверка в модуле |
|---|---|---|---|
version | 00 для рассматриваемого format | говорит parser-у, как читать следующие части | принимается только ровно 00; future version не угадывается |
trace-id | 32 lowercase hex, не все нули | собирает один logical trace | одинаков у всех пяти synthetic span |
parent-id | 16 lowercase hex, не все нули | указывает на текущую операцию вызывающей стороны | catalog получает его как parentSpanId |
trace-flags | 00 или 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. Его формат и жизнь часто другие; попытка склеить все три понятия делает поиск проще на один день и запутаннее после первой интеграции.
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.
| Наблюдение | Корректный вывод | Некорректный вывод | Проверка |
|---|---|---|---|
У catalog.lookup parent = gateway | catalog вызван в рамках gateway истории | catalog сам по себе занял всё время gateway | child лежит внутри 0–240 ms root |
| pricing и inventory имеют одного parent | ветви созданы в одном catalog span | их duration надо сложить | интервалы 30–70 и 30–200 перекрываются |
| adapter parent = inventory | adapter часть inventory операции | 170 ms inventory плюс 70 ms adapter дают 240 ms | adapter 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» без доказательства.
Маршрут проверки transport boundary
- Назвать ровно одну синхронную границу, на которой потеря контекста мешает разбору: gateway → catalog или catalog → inventory.
- Зафиксировать, кто создаёт root, кто извлекает incoming context и кто создаёт child. Не поручать это одновременно middleware и прикладной функции.
- Проверить format version 00, lowercase hex, ненулевой trace-id и parent-id до создания child span.
- Сверить на controlled fixture: trace-id сохраняется, child получает текущий parent, все интервалы положительные.
- Отдельно решить политику входного недоверенного context и sampling; не использовать один trace-flags как готовое бизнес-правило.
- Только после этого выполнить изолированный 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