2 lines
18 KiB
JSON
2 lines
18 KiB
JSON
{"index":7,"slug":"editorial-2027-10-field-long-form-interview","title":"Trace Context в HTTP: как сохранить запрос на границе proxy","excerpt":"Если traceparent исчезает или не проходит проверку, gateway и сервис перестают видеть один запрос. Разбираем контракт proxy, безопасный fallback и проверку через реальный HTTP-маршрут.","contentHtml":"<p>Симптом появляется во время сбоя: gateway сообщает timeout, backend пишет 500, а записи нельзя связать одним запросом. Инженер видит несколько похожих событий и не понимает, относятся ли они к одной операции. Цена ошибки — лишний поиск по логам, неверная гипотеза о медленном сервисе и более долгий разбор пользовательской проблемы.</p><p>Чаще ломается не сама трассировка, а граница между компонентами. Proxy не пропускает заголовок, middleware начинает новый trace вместо продолжения, либо сервис принимает строку с неверным форматом. Главный вопрос статьи — как доказать, что один HTTP-запрос сохранил контекст на этой границе, не превратив trace-id в право доступа или бизнес-идентификатор.</p><h2>Короткий ответ: задайте контракт на границе</h2><p>W3C Trace Context задаёт переносимые HTTP-заголовки <code>traceparent</code> и <code>tracestate</code>. Первый несёт общий trace-id, идентификатор родительской операции и flags; второй хранит необязательное состояние конкретных систем трассировки. Контекст связывает участки обработки, но не гарантирует наличие span, корректность логирования или доставку telemetry.</p><p>На границе нужно заранее выбрать один из трёх режимов. Обычный proxy пересылает валидный контекст. Инструментированный proxy участвует в трассе, создаёт свой span и меняет parent-id для следующего участка. Защитный gateway может начать новую трассу на доверенной границе. Последний вариант разрывает внешнюю корреляцию по решению безопасности, поэтому его нельзя выдавать за «потерю заголовка».</p><h2>Выбор режима: что именно делает proxy</h2><table><thead><tr><th>Режим</th><th>Что уходит дальше</th><th>Цена</th><th>Когда применять</th></tr></thead><tbody><tr><td>Forward</td><td>Валидные <code>traceparent</code> и <code>tracestate</code>; proxy не создаёт span</td><td>Дешевле, но задержка внутри proxy не видна отдельным участком</td><td>Тонкий reverse proxy без собственной инструментализации</td></tr><tr><td>Participate</td><td>Тот же trace-id, новый parent-id и span proxy</td><td>Нужны SDK, sampling и единые правила имён</td><td>Когда задержка маршрутизации входит в SLO сервиса</td></tr><tr><td>Restart</td><td>Новый локальный trace; внешний контекст не становится родителем</td><td>Корреляция через границу теряется</td><td>Явная trust boundary или защита от злоупотребления входным контекстом</td></tr></tbody></table><p>Для обычной внутренней HTTP-границы начинайте с <em>forward</em> или <em>participate</em>. Владелец proxy должен записать режим в конфигурации и интеграционном тесте. Иначе команда будет спорить по логам, где разные trace-id могут быть как дефектом, так и намеренным restart.</p><h2>Механизм: формат, извлечение и новый span</h2><p>В формате версии <code>00</code> значение <code>traceparent</code> содержит четыре поля: <code>version-trace-id-parent-id-trace-flags</code>. Это две hex-цифры версии, 32 lowercase hex-цифры trace-id, 16 lowercase hex-цифр parent-id и две lowercase hex-цифры flags. Trace-id и parent-id не могут состоять из одних нулей. Имя HTTP-заголовка регистронезависимо, но отправлять его следует в lowercase; uppercase внутри значений — уже другая проверка.</p><p>Версия <code>00</code> — формат, который проверяет пример ниже. Спецификация описывает правила для будущих версий: pass-through-компонент не должен без причины разбирать неизвестное расширение, а инструмент, который участвует в трассе, обязан иметь политику обработки более высокой версии. Поэтому «не поддерживаем version» — это проектное решение парсера, а не универсальное требование W3C.</p><p>Извлечение выполняет propagator, а не случайный middleware с <code>split('-')</code>. Невалидный carrier не должен приводить к исключению и не должен записываться в контекст как новый родитель. Если валидного входящего контекста нет, SDK создаёт локальный trace по своей политике. Для валидного удалённого контекста текущий сервис создаёт свой span и передаёт downstream новый parent-id, сохраняя trace-id.</p><p>Proxy проверяется отдельно от библиотеки. Сначала выясните, пропускает ли его allow-list заголовок, затем проверьте лимит размера и поведение при нескольких значениях. Нормализация регистра имени — нормальна для HTTP, а удаление поля, непреднамеренный restart или разные правила для HTTP/1.1 и HTTP/2 — уже часть вашего runtime-контракта. Локальный unit test парсера этого не доказывает.</p><p>Trace-id не объясняет задержку сам по себе. Для поиска места ожидания нужны границы span, временные отметки и статус ошибки. OpenTelemetry отдельно определяет HTTP span и его атрибуты; если компоненты пишут только общий идентификатор, связь событий появится, но длительность отдельных участков останется неизвестной.</p><figure><img src=\"/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg\" alt=\"Клиент передаёт traceparent через gateway, gateway выбирает режим передачи, а сервис проверяет формат перед созданием локального span\"/><figcaption>Сначала проверьте режим proxy, затем формат и создание локального span. Заголовок связывает события, но не является авторизацией.</figcaption></figure><h2>Самодостаточный пример проверки version 00</h2><p>Этот фрагмент можно сохранить как отдельный файл и запустить в Node.js без пакетов. Он проверяет только синтаксический контракт версии <code>00</code>: не создаёт span, не обращается к collector и не утверждает, что заголовок дошёл через proxy. Причина отказа короткая, поэтому её можно считать в метрике без сохранения полного внешнего значения.</p><pre><code>function parseTraceparent(value) { if (typeof value !== 'string') return { ok: false, reason: 'missing-or-not-string' }; const parts = value.split('-'); if (parts.length !== 4) return { ok: false, reason: 'field-count' }; const [version, traceId, parentId, flags] = parts; if (!/^[0-9a-f]{2}$/.test(version) || version === 'ff') return { ok: false, reason: 'version-invalid' }; if (version !== '00') return { ok: false, reason: 'version-unsupported-by-example' }; if (!/^[0-9a-f]{32}$/.test(traceId) || /^0+$/.test(traceId)) return { ok: false, reason: 'trace-id-invalid' }; if (!/^[0-9a-f]{16}$/.test(parentId) || /^0+$/.test(parentId)) return { ok: false, reason: 'parent-id-invalid' }; if (!/^[0-9a-f]{2}$/.test(flags)) return { ok: false, reason: 'trace-flags-invalid' }; return { ok: true, version, traceId, parentId, flags }; } const fixtures = [['valid', '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'], ['uppercase-value', '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01'], ['zero-parent', '00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01']]; for (const [name, value] of fixtures) { const result = parseTraceparent(value); console.log(name, result.ok ? 'accepted' : result.reason); } // valid accepted // uppercase-value trace-id-invalid // zero-parent parent-id-invalid</code></pre><p>Uppercase trace-id здесь отклоняется, хотя имя заголовка <code>TraceParent</code> само по себе допустимо. Это controlled fallback, а не HTTP 500: сервис не использует повреждённую строку как родительский контекст. В production коде такую проверку лучше отдать официальному propagator выбранного SDK, а fixture оставить как контракт интеграции и регрессионный тест.</p><h2>Диагностика по симптому</h2><table><thead><tr><th>Симптом</th><th>Вероятная граница</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>В gateway и service разные trace-id</td><td>Proxy удалил заголовок, либо выбран restart</td><td>Сравнить raw-заголовок до и после proxy и прочитать режим</td><td>Исправить allow-list или документировать trust boundary</td></tr><tr><td>Контекст есть только локально</td><td>Runtime применяет другой маршрут, лимит или фильтр</td><td>Повторить запрос через тот же gateway с теми же правилами</td><td>Добавить integration test gateway + service</td></tr><tr><td>Валидный на вид заголовок отклонён</td><td>Uppercase в значении, длина, нулевой id или version</td><td>Прогнать fixture на каждое поле и записать reason</td><td>Отбросить вход без 500; не логировать полное значение</td></tr><tr><td>По trace-id не находится событие</td><td>Другой ключ, sampling или задержка доставки</td><td>Проверить структурное поле, span и exporter</td><td>Развести correlation gap и отсутствие telemetry</td></tr><tr><td>После HTTP-запроса теряется связь с очередью</td><td>HTTP carrier не перенесён в message carrier</td><td>Проверить envelope или headers сообщения</td><td>Настроить propagator брокера и отдельный link при необходимости</td></tr></tbody></table><h2>Пошаговый runbook</h2><ol><li><strong>Зафиксируйте границы.</strong> Выберите один запрос и выпишите client, gateway, service и downstream. Для каждого перехода укажите ожидаемый режим: forward, participate или restart. Результат — короткая схема, с которой можно сравнить логи.</li><li><strong>Снимите вход и выход.</strong> В тестовой среде запишите наличие и значение <code>traceparent</code> до proxy и на входе сервиса. Не передавайте в логах пользовательские данные и не делайте trace-id секретом.</li><li><strong>Проверьте транспорт.</strong> Убедитесь, что маршрут proxy разрешает заголовок, не переписывает его неожиданно и одинаково работает для реального протокола. Если заголовок исчез до сервиса, backend-библиотека пока не подозревается.</li><li><strong>Проверьте извлечение.</strong> Прогоните валидный fixture, uppercase в значении, нулевые id, неверную длину, <code>ff</code> и выбранную политику для более высокой версии. Для каждого случая зафиксируйте expected decision.</li><li><strong>Проверьте span.</strong> На валидном входе trace-id должен продолжиться, а текущая операция получить новый локальный span-id. На невалидном входе должен появиться новый локальный trace или controlled fallback SDK, а не исключение из HTTP handler.</li><li><strong>Сверьте наблюдаемость.</strong> Сведите логи к одному структурному ключу, сравните trace-id и parent-id, а отсутствие записи проверьте с учётом sampling и задержки exporter. Одного совпадения trace-id недостаточно для вывода о latency.</li><li><strong>Прогоните маршрут.</strong> Выполните end-to-end запрос через реальный proxy и повторите его после изменения конфигурации. Сохраните raw headers, решение извлечения и ссылки на spans; только после этого меняйте rollout или лимиты.</li></ol><h2>Ограничения и критерий готовности</h2><p>Этот подход проверяет HTTP carrier и передачу контекста между компонентами. Он не проверяет подпись, доверие к отправителю, полноту collector, backend sampling или форматы контекста Kafka и другой очереди. Для асинхронного сообщения нужен отдельный message carrier; иногда правильнее использовать span link, а не притворяться прямым parent-child. Повтор операции и бизнес-связь требуют отдельного безопасного operation-id.</p><p>Готовность доказана, когда интеграционный тест через реальный proxy подтверждает выбранный режим, валидный context получает ожидаемый локальный span, а повреждённый вход приводит к controlled fallback без 500. Логи используют единый trace-id key, span boundaries позволяют искать задержку, а документация явно говорит, где сделан restart. Если эти условия не проверены, проблема «trace-id пропал» остаётся гипотезой.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://www.w3.org/TR/trace-context-2/\" target=\"_blank\" rel=\"noopener\">W3C Trace Context Level 2</a> — формат полей, правила invalid context, forwarding и обработки версий.</li><li><a href=\"https://opentelemetry.io/docs/specs/otel/context/api-propagators/\" target=\"_blank\" rel=\"noopener\">OpenTelemetry Propagators API</a> — extract/inject, требования к W3C propagator и поведение при непарсящем carrier.</li><li><a href=\"https://opentelemetry.io/docs/specs/semconv/http/http-spans/\" target=\"_blank\" rel=\"noopener\">OpenTelemetry semantic conventions for HTTP spans</a> — границы HTTP client/server spans и атрибуты для анализа задержки.</li></ul>"}
|