{ "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 и проверяет интервалы до диагноза.
\nTrace — логическая история запроса. Span — одна операция внутри этой истории. У span есть имя, начало, конец, span-id и ссылка на parent. Trace-id общий для всех связанных span. Span-id различает gateway, catalog и inventory. Parent-id отвечает на вопрос «какая операция породила эту работу», но не заменяет trace-id.
\nНа HTTP-границе контекст нужно превратить в переносимые данные. W3C Trace Context описывает заголовок traceparent. В используемой здесь версии 00 он имеет четыре поля: version, trace-id, parent-id и trace-flags. Parent-id — это идентификатор span вызывающей стороны. Получатель извлекает его, создаёт новый span-id для своей операции и передаёт дальше уже свой span-id. Trace-id при этом остаётся прежним. Дополнительный заголовок tracestate может переносить vendor-specific данные, но не меняет эту базовую связь.
Это не бизнес-заголовок. В traceparent нельзя переносить токен, email, полный URL или текст исключения. Контекст описывает связь операций. Данные для логов и диагностики живут по отдельным правилам. Особенно опасно бездумно доверять входному контексту публичного клиента: trace-flags могут быть подсказкой для sampling, но не должны одним битом управлять внутренней стоимостью сбора.
Ниже — учебная модель. Она не открывает сеть, не подключается к collector и не показывает production-данные. Gateway создаёт root span, catalog становится его child, а inventory и pricing идут параллельно внутри catalog. Значения времени выбраны только для проверки арифметики.
\nconst 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, визуальный граф может выглядеть правдоподобно, но причинность станет ложной. Проверка должна ловить эти ошибки на данных, а не по цветам интерфейса.
\nParser должен сначала проверить форму входа. Этот учебный вариант поддерживает только version 00: нужны четыре части, lowercase hex, ненулевой trace-id длиной 32 символа и ненулевой parent-id длиной 16 символов. Для другой версии нельзя угадывать формат по первым символам и молча разбирать его как 00. В production нужно следовать правилам поддерживаемой версии или доверить разбор библиотеке. Невалидный контекст нужно отклонить либо обработать по заранее описанной политике, а не превратить в доверенный parent.
\nfunction 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 и базовая валидация. Она не заменяет библиотеку трассировки и не доказывает, что parent-id существует в хранилище. Реальный adapter должен учитывать правила конкретного HTTP-клиента, сервера, прокси и фреймворка. Если middleware уже извлекает контекст и создаёт span, второй слой может породить дубликаты. Сначала нужно установить владельца extract, владельца inject и место создания root.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| 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 по политике границы |
Допустим, 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 и лежит внутри его интервала. Это минимальный набор условий, чтобы обсуждать дерево и время вместе.
Ответ заканчивается на 240 миллисекунде из-за root span; среди дочерних веток позже всех заканчивается inventory — на 200. Pricing завершается на 70 и не удерживает catalog до его конца. Adapter занимает участок внутри inventory с 100 до 170, но сам по себе не становится доказанным bottleneck: inventory продолжается ещё 30 миллисекунд. Поэтому в этом примере нужно говорить о поздней ветке inventory, а не приписывать всю её длительность adapter.
\nExclusive time можно получить, вычтя объединение дочерних интервалов из интервала parent. Для inventory это 170 минус 70, то есть 100 миллисекунд вне adapter. Для root остаётся 40 миллисекунд вне catalog. Такой расчёт помогает не считать одно ожидание дважды, но требует общей шкалы времени и корректных границ. При clock skew, неполных timestamp и асинхронной очереди результат нельзя считать доказанным.
\nЗелёный пример показывает, как механизм работает при правильных данных. Нужнее отрицательный: нулевой trace-id должен быть отвергнут; изменённый parent-id должен сделать связь подозрительной, а не считаться подтверждением существующего родителя; неизвестная версия не должна интерпретироваться как version 00. Если входной контекст нельзя доверенно обработать, система должна иметь явное решение: отклонить его, продолжить операцию без связи или начать новый root.
\nTrace context не создаёт наблюдаемость сам. Заголовок может пройти прокси, но span не попадёт в exporter. Exporter может работать, но библиотека не создаст span вокруг важной операции. Sampling может убрать часть истории. Эти случаи требуют разных проверок. Нельзя объявлять propagation исправной только потому, что строка header дошла до обработчика.
\nМодель выше не подходит без изменений для очереди, fan-out, batch и продолжения работы после HTTP-ответа. У асинхронной операции может не быть одного parent, который полностью охватывает её время. Появляются links, отдельные правила корреляции и несколько шкал времени. Пока эти условия не проверены, нельзя рисовать уверенный critical path по простой вложенности.
\nСтатья также не обещает production latency. Все интервалы в примере синтетические. Они нужны, чтобы проверить связность, арифметику и отрицательные сценарии. Реальный вывод требует transport test и trace, полученного в разрешённом окружении с известной версией библиотек.
\nМинимальный критерий такой: выбранная граница имеет владельца extract и inject; положительный round-trip сохраняет trace-id и меняет текущий span-id по правилам; отрицательные header-ы не создают ложных связей; fixture содержит один root и уникальные span-id; parent существует; child укладывается в parent там, где это предусмотрено моделью; duration не складывают поверх перекрытий; непроверенные production-условия перечислены.
\nЕсли хотя бы один пункт неизвестен, результат нужно назвать ограниченно: «формат разобран», «учебное дерево связно» или «transport boundary прошла тест». Фраза «трассировка работает» шире доказательств. Готовность начинается там, где команда может повторить проверку, увидеть красный отрицательный путь и безопасно остановиться.
\n