DarkRiDDeR14 мин

Трассировка запроса: как собрать один учебный waterfall без платформы

НаблюдаемостьHTTPОтладка

Симптом знаком после появления структурных логов: для одного запроса уже можно найти несколько строк, но из них не видно, где именно прошли 240 миллисекунд. Цена ошибки — править timeout, повтор или базу по догадке. Такая правка может скрыть задержку на время, но следующий запрос снова оставит только разрозненные события. До действия нужен один связанный сценарий, а не новый dashboard.

В этой статье я не подключаю готовую платформу и не показываю trace из чьей-либо системы. Соберу один учебный запрос в памяти: gateway создаёт span, catalog получает его контекст, затем внутри catalog параллельно живут короткий pricing и длинный inventory. Цель скромная: проверить, что общий trace-id не теряется, parent-id указывает на вызывающий span, а waterfall позволяет назвать следующий участок для проверки.

Историческая граница сентября 2020 года

Сначала важно не смешать два слоя. Trace Context Level 1 уже не был черновиком: W3C опубликовал Recommendation 6 февраля 2020 года. Она описывает переносимый HTTP-представитель контекста traceparent и отдельный tracestate для данных конкретного поставщика. Это даёт общий формат передачи, но не рисует service map и не выбирает за нас storage, sampling, exporter или интерфейс разбора.

OpenTelemetry в этот момент полезно воспринимать как развивающуюся спецификацию и набор ранних реализаций, а не как стабильную кнопку «добавить наблюдаемость». Исторический tag v0.5.0 относится к июню 2020 года, а стабильность Trace API официальная документация позже относит к началу 2021-го. Поэтому ниже нет version-specific конфигурации collector-а, auto-instrumentation или обещания, что один пакет одинаково решит браузер, Node и каждую библиотеку. Есть минимальный контракт, который можно проверить отдельно.

Границы учебного контракта трассировки
ОбъектЧто означает в примереЧто не следует в него кластьКак проверяем
traceIdодна логическая история из пяти spanпользователя, email, URL с параметрамивсе span имеют одинаковое 32-hex значение
spanIdодна операция внутри историиназвание endpoint или текст ошибкикаждый ID уникален и состоит из 16 hex-символов
parentSpanIdпрямая причина появления child spanID далёкого предка или произвольный request_idparent существует и охватывает child по времени
durationразность учебных endMs - startMsобещание latency на другой машинеконец строго больше начала
traceparentперенос trace-id и текущего parent span через одну границусекрет, авторизационный заголовок или полный объект запросаparser принимает только version 00 и валидные ID

Один trace, а не набор красивых строк

Trace — это не ещё один вид лога. В учебной модели это дерево операций с общей причиной: пользовательское действие попало в gateway, gateway попросил catalog, а catalog вызвал inventory. Span — одна операция с именем, началом, концом и связью с родителем. Если gateway начал gateway.handle, а catalog создал новый случайный trace-id, дерево уже разорвано. В журнале могут остаться две хорошие строки, но вопрос «какой downstream блокировал исходный запрос?» останется без ответа.

Контекст нужен именно на границе. Внутри одной функции можно передать объект аргументом, но после транспорта соседний процесс не знает текущий span автоматически. В W3C version 00 поле traceparent состоит из четырёх частей: version, trace-id, parent-id и trace-flags. В нашем упражнении gateway сериализует trace-id и свой span-id. Catalog извлекает их, создаёт child span и уже его ID передаст дальше. Это не означает, что header принадлежит бизнес-API: это инфраструктурная граница, которую нужно ограничивать и проверять отдельно.

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

Минимальный header проверяем как данные

Все ID, имена сервисов, интервалы, header и экспорт ниже синтетические. Они собраны в памяти модуля и не являются trace, URL, HTTP-заголовком, аккаунтом или измерением из системы пользователя.

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,
};

Вызов createTrainingTraceparent() не открывает сеть. Он возвращает строку, которую сразу разбирает parseTrainingTraceparent(). Этот узкий round-trip ловит две разные ошибки. Первая — формат: случайно передали ID неправильной длины, верхний регистр или нулевое значение. Вторая — смысл: child span получил родителя не из извлечённого контекста. Если присутствует только первая проверка, можно получить валидную строку, которая связывает не те операции.

Я намеренно не добавляю сюда tracestate. W3C оставляет его для данных конкретной tracing-системы; в учебном упражнении нет такого владельца. Запись «на будущее» без того, кто её читает, превращается в неподтверждённый vendor contract. То же относится к baggage, произвольным полям пользователя и полным URL. Контекст должен связывать работу, а не становиться обходным каналом для данных, которые нельзя проверить или безопасно хранить.

Собираем waterfall с одним логическим временем

После того как связь существует, можно смотреть duration. В fixture пять интервалов на одной шкале: gateway.handle длится от 0 до 240 ms; catalog.lookup лежит внутри него от 20 до 220 ms; pricing.read занимает 30–70 ms; inventory.fetch — 30–200 ms; его adapter — 100–170 ms. Значения выбраны для объяснения и не являются latency, которую кто-то измерил.

Здесь легко ошибиться с арифметикой. Duration parent span включает время его child span. Поэтому inventory.fetch = 170 ms и inventory.adapter = 70 ms нельзя сложить и назвать 240 ms: adapter уже находится внутри inventory. Для одного учебного дерева можно отдельно посчитать exclusive время — интервалы parent, не перекрытые дочерними span. Это помогает объяснить путь, но не отменяет необходимости сравнить часы и инструментирование в реальном распределённом запуске.

Вертикальный waterfall синтетического trace на шкале 0–240 ms: gateway длится 240 ms, catalog 200 ms, pricing 40 ms параллельно inventory 170 ms, а adapter 70 ms вложен в inventory; критический путь выделен контрастным цветом
Короткий pricing идёт параллельно с inventory и не продлевает финал. Выделенная цепочка показывает, какую гипотезу проверять первой в учебной модели.

Фикстура доказывает только свой маленький договор

У revision-модуля есть in-memory fixture. Она создаёт синтетические span, валидирует одного root, проверяет parent/child containment, делает round-trip header-а и выбирает child, который заканчивается последним. Затем fixture складывает exclusive сегменты выбранной цепочки. Для именно этой вложенной модели сумма совпадает с root duration 240 ms. Так тест ловит перестановку parent-id, отрицательный интервал и ошибочное сложение пересекающихся duration до того, как текст станет инструкцией.

# Запускается только проверка учебной модели из этого revision-модуля.
node scripts/upgrade-2020-09.mjs --verify-fixture

# Важные части ожидаемого результата:
traceparentRoundTrip: true
childReceivedCurrentParent: true
criticalPathIsExpected: true
criticalPathExclusiveTimeMatchesRoot: true

Важно назвать и то, чего эта команда не доказывает. Она не проверяет реальный proxy, браузер, clock skew между хостами, collector, sampling или экспорт. Она не убеждается, что какой-либо framework автоматически сохранит контекст через callback и очередь. И она не решает вопрос стоимости хранения trace. Это полезный ограничитель: fixture проверяет форму одной истории, а внедрение начинается с одного настоящего маршрута и отдельного теста на каждой транспортной границе.

Как читать результат fixture, не расширяя его смысл
ПроверкаЧто подтверждаетЧего не подтверждаетСледующее действие
traceparentRoundTripстрока version 00 сохраняет trace-id и текущий span-idчто header дошёл через реальный reverse proxyдобавить изолированный transport test в конкретном сервисе
childReceivedCurrentParentcatalog в модели дочерний к gatewayчто все framework middleware создают правильные spanпроверить одну входящую и одну исходящую границу
criticalPathIsExpectedfixture выбрал поздно завершающуюся цепочкучто это единственный bottleneck в productionпроверить инструментирование выбранного участка
invalidHeaderRejectedнулевой trace-id не проходит parserполитику доверия к внешнему клиентуописать, где входной контекст принимается, а где создаётся заново

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

  1. Выбрать один учебный пользовательский путь и назвать цену задержки без диагноза: например, «результат ждёт один downstream, но логи не показывают порядок».
  2. Задать один trace-id и пять заранее перечисленных span. Не включать в ID данные пользователя, маршрут с параметрами или текст исключения.
  3. На первой границе создать root span; перед следующей границей сериализовать только version 00, trace-id, текущий span-id и flags.
  4. На получателе валидировать format, создать child с извлечённым parent-id и проверить, что trace-id не сменился.
  5. Запустить --verify-fixture; если red, исправить контракт или интервалы, а не переставлять таймауты.
  6. Только затем выбрать один реальный транспорт и добавить отдельный test, который проверяет передачу контекста без настоящих пользовательских данных.

Где этот приём останавливается

Этот материал не описывает полноценную distributed tracing platform. У него нет backend-а поиска, service map, collector configuration, retention, sampling policy и автоматической инструментации. В сентябре 2020 года было бы неправдоподобно написать, что эти куски уже одинаково стабильны во всех стеках. Нормальный следующий шаг — не массово оборачивать всё приложение, а выбрать одну синхронную границу, сохранить version/adapter рядом с тестом и сначала увидеть один честный waterfall.

Если задача включает очередь, fan-out или несколько родителей, дерево fixture уже недостаточно. Там появляются links, асинхронное время и отдельное решение о том, считать ли продолжение тем же trace. Если часы разных процессов не согласованы, абсолютное наложение на waterfall тоже требует проверки. В этих случаях причина звучит конкретно: модель слишком мала для новой границы. Действие тоже конкретно: не рисовать уверенный critical path до того, как появится проверяемый transport и время.

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

  • 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