{ "index": 263, "slug": "editorial-2020-09-mechanism-tracing-basics", "title": "Trace context без иллюзий: как связать запрос и найти задержку", "excerpt": "Если gateway и downstream видят разные trace, waterfall превращается в набор несвязанных чисел. Разбираем traceparent, parent/child span, синтетический пример и безопасный порядок проверки.", "contentHtml": "

Симптом появляется после первого внедрения структурных логов: gateway сообщает о запросе, catalog сообщает о своей операции, inventory сообщает о таймауте, но нельзя доказать, что эти записи относятся к одной истории. Время ответа — 240 миллисекунд, а в логах видны 200, 170 и 70 миллисекунд. Команда выбирает самый большой показатель и меняет timeout. Цена ошибки — лишние повторы, перегрузка downstream и задержка, которую так и не измерили.

\n

Причина обычно не в dashboard. Контекст запроса потерялся на границе между процессами или parent/child связали неправильно. Trace-id заменили новым значением, span-id скопировали из родителя, а вложенные duration сложили повторно. Тезис простой: трассировка становится полезной только тогда, когда система сохраняет один trace-id, создаёт новый span на каждой операции, передаёт текущий span как parent и проверяет интервалы до диагноза.

\n

Что именно связывает trace context

\n

Trace — логическая история запроса. Span — одна операция внутри этой истории. У span есть имя, начало, конец, span-id и ссылка на parent. Trace-id общий для всех связанных span. Span-id различает gateway, catalog и inventory. Parent-id отвечает на вопрос «какая операция породила эту работу», но не заменяет trace-id.

\n

На HTTP-границе контекст нужно превратить в переносимые данные. W3C Trace Context описывает для этого заголовок traceparent. В учебной версии 00 он имеет четыре части: версию, trace-id, parent-id и trace-flags. Получатель извлекает входной parent-id, создаёт новый span-id для своей операции и передаёт дальше уже свой span-id. Trace-id при этом остаётся прежним.

\n
Схема передачи trace context: gateway передаёт свой span-id, catalog создаёт дочерний span и передаёт дальше новый span-id при неизменном trace-id
На каждой синхронной границе меняется текущий span-id. Общий trace-id сохраняет принадлежность операций к одной истории.
\n

Это не бизнес-заголовок. В traceparent нельзя переносить токен, email, полный URL или текст исключения. Контекст должен описывать связь операций. Данные для логов и диагностики живут по отдельным правилам. Особенно опасно бездумно доверять входному контексту публичного клиента: внешний отправитель не должен одним флагом управлять внутренней стоимостью сбора.

\n

Минимальный пример

\n

Ниже — учебная модель. Она не открывает сеть, не подключается к collector и не показывает production-данные. В ней gateway создаёт root span, catalog становится его child, а inventory и pricing идут параллельно внутри catalog. Значения времени выбраны только для проверки арифметики.

\n
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';\nconst gateway = { spanId: 'a111111111111111', parentSpanId: null,\n  startMs: 0, endMs: 240 };\n\n// Gateway передаёт свой текущий span как parent следующей операции.\nconst traceparent =\n  `00-${traceId}-${gateway.spanId}-01`;\n\n// Catalog создаёт новый span, но сохраняет traceId.\nconst catalog = { spanId: 'b222222222222222',\n  parentSpanId: gateway.spanId, startMs: 20, endMs: 220 };\n\n// Дочерние операции catalog перекрываются во времени.\nconst pricing = { parentSpanId: catalog.spanId, startMs: 30, endMs: 70 };\nconst inventory = { parentSpanId: catalog.spanId, startMs: 30, endMs: 200 };
\n

В этой модели root длится 240 миллисекунд. Catalog занимает 200, pricing — 40, inventory — 170. Adapter внутри inventory может занимать 70 миллисекунд. Эти числа нельзя сложить: adapter уже входит в inventory, а pricing идёт параллельно с inventory. Inclusive duration показывает полный интервал операции вместе с ожиданием дочерних span. Он не показывает самостоятельное время без детей.

\n

Если gateway и catalog получили разные trace-id, дерево распалось. Если catalog сохранил span-id gateway как собственный, две операции стали неразличимы. Если parent у inventory ссылается на далёкого предка, а не на непосредственный catalog, визуальный граф может выглядеть правдоподобно, но причинность станет ложной. Проверка должна ловить эти ошибки на данных, а не по цветам интерфейса.

\n

Проверяем header до создания span

\n

Parser должен сначала проверить форму входа. Для version 00 нужны четыре части, lowercase hex, ненулевой trace-id длиной 32 символа и ненулевой parent-id длиной 16 символов. Неизвестную версию нельзя угадывать по первым символам. Невалидный контекст нужно отклонить или обработать по заранее описанной политике, а не превратить в доверенный parent.

\n
function parseTraceparent(value) {\n  const parts = String(value).split('-');\n  if (parts.length !== 4 || parts[0] !== '00') {\n    throw new Error('unsupported traceparent');\n  }\n\n  const [, traceId, parentId, flags] = parts;\n  if (!/^[0-9a-f]{32}$/.test(traceId) || /^0+$/.test(traceId)) {\n    throw new Error('invalid trace-id');\n  }\n  if (!/^[0-9a-f]{16}$/.test(parentId) || /^0+$/.test(parentId)) {\n    throw new Error('invalid parent-id');\n  }\n  if (!/^[0-9a-f]{2}$/.test(flags)) {\n    throw new Error('invalid trace-flags');\n  }\n  return { traceId, parentId, flags };\n}
\n

Функция ограничена учебной задачей: version 00, строковый carrier и базовая валидация. Она не заменяет библиотеку трассировки. Реальный adapter должен учитывать правила конкретного HTTP-клиента, сервера, прокси и фреймворка. Если middleware уже извлекает контекст и создаёт span, второй слой может породить дубликаты. Сначала нужно установить владельца extract, владельца inject и место создания root.

\n

Симптом → причина → проверка → действие

\n
Разбор типичных разрывов в одном синхронном trace
СимптомПричинаПроверкаДействие
Gateway и catalog имеют разные trace-idПолучатель создал новый root вместо childСверить trace-id и parent-id в обеих spanИсправить extract и создание child на границе
У двух операций один span-idПолучатель скопировал ID родителяПроверить уникальность span-id в одной историиГенерировать новый span-id для каждой операции
Заголовок принят, но граф пустойКонтекст передали, а span не записали или не экспортировалиРазделить проверку propagation, recording и exportДобавить отдельный тест на каждый слой
Сумма дочерних duration больше ответаПерекрывающиеся интервалы сложили как последовательныеНанести start/end на одну шкалу времениСчитать critical path и exclusive time, а не сумму строк
Нулевой или чужой trace-id проходит дальшеParser проверяет только число частейПодать отрицательные header-примеры до создания spanОстановить обработку или создать новый root по политике границы
\n

Как читать учебный waterfall

\n

Допустим, waterfall содержит пять span: gateway.handle от 0 до 240, catalog.lookup от 20 до 220, pricing.read от 30 до 70, inventory.fetch от 30 до 200 и inventory.adapter от 100 до 170 миллисекунд. У всех один trace-id. Каждый child имеет существующего parent и лежит внутри его интервала. Это минимальный набор условий, чтобы обсуждать дерево и время вместе.

\n

Поздний конец inventory — 200 миллисекунд. Pricing заканчивается на 70 и не удерживает catalog до его конца. Adapter заканчивается на 170 и находится внутри inventory. Поэтому учебный critical path проходит через gateway, catalog и inventory, а затем через adapter только как вложенный участок. Это не означает, что adapter — production bottleneck. Это означает лишь, что в данной модели он находится на поздней последовательной ветви.

\n

Exclusive time можно получить, вычтя объединение дочерних интервалов из интервала parent. Для inventory это 170 минус 70, то есть 100 миллисекунд вне adapter. Для root остаётся 40 миллисекунд вне catalog. Такой расчёт помогает не считать одно ожидание дважды, но требует общей шкалы времени и корректных границ. При clock skew, неполных timestamp и асинхронной очереди результат нельзя считать доказанным.

\n

Порядок действий

\n
  1. Выбрать одну синхронную границу, например gateway → catalog. Не начинать с массовой автоинструментации.
  2. Назначить владельцев: кто создаёт root, кто извлекает incoming context, кто создаёт child и кто внедряет outgoing context.
  3. Зафиксировать учебный trace-id, список span и ожидаемые parent-id. Не класть в идентификаторы пользовательские данные.
  4. Проверить положительный round-trip: inject сохраняет trace-id и текущий span-id, extract возвращает их без изменения.
  5. Проверить отрицательный путь: нулевые ID, неверную длину, uppercase, неизвестную версию и лишнее поле. До создания span вход должен получить предсказуемый отказ.
  6. Запустить controlled fixture с одной шкалой времени. Проверить один root, уникальность span-id, существование parent и containment child.
  7. Сделать transport test конкретного клиента или сервера. Проверить не только carrier, но и фактическую границу, где он проходит.
  8. Только после этого смотреть duration. Сначала — конец root, затем прямые children, перекрытия и собственное время.
  9. Записать, что не проверено: collector, sampling, storage, clock synchronization, очередь, retries и fan-out.
\n

Отрицательный путь и ограничения

\n

Зелёный пример показывает, как механизм работает при правильных данных. Нужнее отрицательный: нулевой trace-id должен быть отвергнут; изменённый parent-id не должен незаметно связать операцию с чужим span; неизвестная версия не должна интерпретироваться как version 00. Если входной контекст нельзя доверенно обработать, система должна иметь явное решение: отклонить его, пропустить операцию без связи или начать новый root.

\n

Trace context не создаёт наблюдаемость сам. Заголовок может пройти прокси, но span не попадёт в exporter. Exporter может работать, но библиотека не создаст span вокруг важной операции. Sampling может убрать часть истории. Эти случаи требуют разных проверок. Нельзя объявлять propagation исправной только потому, что строка header дошла до обработчика.

\n

Модель выше не подходит без изменений для очереди, fan-out, batch и продолжения работы после HTTP-ответа. У асинхронной операции может не быть одного parent, который полностью охватывает её время. Появляются links, отдельные правила корреляции и несколько часов. Пока эти условия не проверены, нельзя рисовать уверенный critical path по простой вложенности.

\n

Статья также не обещает production latency. Все интервалы в примере синтетические. Они нужны, чтобы проверить связность, арифметику и отрицательные сценарии. Реальный вывод требует transport test и trace, полученного в разрешённом окружении с известной версией библиотек.

\n

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

\n

Минимальный критерий такой: выбранная граница имеет владельца extract и inject; положительный round-trip сохраняет trace-id и меняет текущий span-id по правилам; отрицательные header-ы не создают ложные связи; fixture содержит один root и уникальные span-id; parent существует; child укладывается в parent там, где это предусмотрено моделью; duration не складывают поверх перекрытий; непроверенные production-условия перечислены.

\n

Если хотя бы один пункт неизвестен, результат нужно назвать ограниченно: «формат разобран», «учебное дерево связно» или «transport boundary прошла тест». Фраза «трассировка работает» шире доказательств. Готовность начинается там, где команда может повторить проверку, увидеть красный отрицательный путь и безопасно остановиться.

\n

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

\n" }