8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 264,
|
||
"slug": "editorial-2020-09-practice-tracing-basics",
|
||
"title": "Трассировка запроса: как доказать задержку по одному trace",
|
||
"excerpt": "Разбираем учебный разрыв trace: как проверить traceparent на границах сервисов, не сложить перекрывающиеся span дважды и выбрать следующий измеримый сигнал.",
|
||
"contentHtml": "<p>После условного выката дежурный инженер получает два одинаковых запроса: один завершился за 40 миллисекунд, другой ждал почти секунду. Gateway, catalog и inventory записали события, но их нельзя уверенно собрать в одну историю: идентификаторы различаются, а времена запуска не совпадают. Это учебный incident, поэтому числа ниже придуманы для проверки метода, а не выданы за замер production.</p>\n<p>Первое действие — взять один запрос и сравнить контекст на каждой границе: какой <code>traceparent</code> отправил gateway, что извлёк catalog и какой span стал родителем следующего вызова. Цена ошибки здесь практическая: если сразу увеличить timeout или retry, можно скрыть потерю контекста и перенести задержку дальше. Задача статьи — показать, какие факты trace доказывает, а где нужен следующий сигнал.</p>\n<h2>Сценарий: один запрос и пять span</h2>\n<p>Разберём контролируемую схему. Gateway принимает HTTP-запрос и создаёт корневой span <code>gateway.handle</code>. Он вызывает catalog. Catalog создаёт <code>catalog.lookup</code> как дочернюю операцию и параллельно запускает <code>pricing.read</code> и <code>inventory.fetch</code>. Inventory вызывает <code>inventory.adapter</code>. Так получается пять span в одной простой trace-истории.</p>\n<p><code>trace-id</code> идентифицирует всю историю, а <code>span-id</code> — одну операцию. У каждого span может быть не более одного parent в обычном дереве, но дочерних span может быть несколько. Поэтому имя сервиса ещё ничего не доказывает: нужно проверить идентификаторы, parent-child связь и интервалы <code>start</code>/<code>end</code>. Ниже приведены только значения учебной модели.</p>\n<div class=\"table-scroll\"><table><caption>Симптом → возможная причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Возможная причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Строки нельзя собрать в один запрос</td><td>На границе создан новый root или потерян context</td><td>Сравнить trace-id во входящем и исходящем span</td><td>Проверить extract/inject и carrier</td></tr><tr><td>Child есть, но parent неизвестен</td><td>Span создан без активного Context</td><td>Сверить parent-span-id и момент создания</td><td>Создавать child из извлечённого Context</td></tr><tr><td>Сумма duration намного больше ответа</td><td>Вложенные или параллельные интервалы посчитали повторно</td><td>Сопоставить start/end и перекрытия</td><td>Искать критический путь, а не сумму строк</td></tr><tr><td>Trace обрывается после proxy или очереди</td><td>Carrier не прошёл через транспорт либо сработал sampling</td><td>Сравнить заголовок до отправки и после получения</td><td>Проверить конкретную boundary и полноту выборки</td></tr><tr><td>Самый длинный span не объясняет ответ</td><td>Он перекрывается с поздней веткой или слишком широк внутри себя</td><td>Найти последний end и следующий внутренний сигнал</td><td>Проверить адаптер, очередь или внешний ответ</td></tr></tbody></table></div>\n<h2>Контекст на HTTP-границе</h2>\n<p>Внутри процесса Context можно передать средствами SDK. После HTTP-вызова или сообщения очереди получатель не угадывает его по имени сервиса. Propagator записывает context в carrier, например в HTTP-заголовки, а принимающая сторона извлекает его обратно. Это две разные операции: <em>inject</em> выполняет отправитель, <em>extract</em> — получатель.</p>\n<p>В W3C Trace Context заголовок <code>traceparent</code> версии <code>00</code> имеет четыре поля: version, trace-id, parent-id и trace-flags. Для <code>trace-id</code> используется 32 строчных шестнадцатеричных символа, для parent-id — 16; нулевые значения недействительны. При некорректном контексте реализация должна его проигнорировать, а решение о новой root-истории принимает политика инструмента.</p>\n<p>Порядок для исходящего HTTP-вызова важен. Получив запрос, catalog сначала извлекает remote context и создаёт свой server span как child. Перед вызовом inventory он создаёт span исходящего обращения, обычно client span, делает его текущим и только потом inject-ит его context в новый carrier. Inventory извлекает carrier и создаёт свой server span. Тогда в исходящем заголовке меняется parent-id текущего вызова, а trace-id сохраняется.</p>\n<pre><code>traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\n\n// Схема операций SDK, без сетевого вызова:\nconst incoming = propagator.extract(request.headers);\nconst serverSpan = tracer.startSpan('catalog.lookup', { parent: incoming });\nconst outgoing = tracer.startSpan('inventory.fetch', { parent: serverSpan });\npropagator.inject(outgoing.context(), requestToInventory.headers);\n\n// На стороне inventory:\nconst remote = propagator.extract(requestToInventory.headers);\nconst inventorySpan = tracer.startSpan('inventory.server', { parent: remote });</code></pre>\n<p>Этот фрагмент показывает последовательность, но не является готовым API для конкретного языка: названия методов у SDK различаются. Он также не обещает, что proxy сохранит заголовок. Для воспроизводимой проверки нужно записать carrier непосредственно перед отправкой и сразу после extract на стороне получателя.</p>\n<figure><img src=\"/assets/editorial/2020/tracing-context-propagation-2020.svg\" alt=\"Схема передачи traceparent: gateway передаёт context в catalog, а catalog создаёт новый дочерний span для inventory\" loading=\"lazy\" /><figcaption>На каждой границе создаётся новый span, но при корректной propagation trace-id остаётся общим. Схема показывает контракт, а не сетевой прогон.</figcaption></figure>\n<h2>Waterfall: путь запроса, а не сумма строк</h2>\n<p>В учебной модели root <code>gateway.handle</code> идёт от 0 до 240 мс, <code>catalog.lookup</code> — от 20 до 220, <code>pricing.read</code> — от 30 до 70, а <code>inventory.fetch</code> — от 30 до 200. Внутри inventory адаптер занимает интервал от 100 до 170 мс. Pricing и inventory стартуют одновременно, поэтому их duration перекрываются.</p>\n<p>Inclusive duration родителя включает работу children. Если сложить 240, 200, 40, 170 и 70, получится 720 мс, хотя root завершается за 240. Это не задержка запроса. Правильный вопрос другой: какая последовательность интервалов удерживает ответ до конца? В этой модели поздний child catalog — inventory, а pricing остаётся параллельной веткой, не входящей в критический путь после 70 мс.</p>\n<p>Для этой простой шкалы критическая цепь раскладывается так: 0–20 мс до catalog, 20–30 мс до его children, 30–100 мс до adapter, 100–170 мс работы adapter, 170–200 мс после adapter, 200–220 мс после children и 220–240 мс до ответа. Сумма этих неперекрывающихся участков равна 240 мс. Это разложение относится к выбранной модели; для асинхронных links, retry и разных часов нужен другой анализ.</p>\n<figure><img src=\"/assets/editorial/2020/tracing-span-waterfall-2020.svg\" alt=\"Учебный waterfall от 0 до 240 миллисекунд: pricing идёт параллельно с inventory, а adapter вложен в inventory\" loading=\"lazy\" /><figcaption>Позднее завершение inventory определяет конец ветки catalog. Вложенный adapter уже входит в interval inventory и не должен добавляться второй раз.</figcaption></figure>\n<h2>Воспроизводимая проверка интервалов</h2>\n<p>Ниже — самодостаточный пример для Node.js без внешних пакетов. Он проверяет duration, наличие parent и то, какой child catalog завершился последним. Код не строит полноценный trace backend и не учитывает асинхронные links; его граница намеренно ограничена одной согласованной шкалой.</p>\n<pre><code>const spans = [\n { name: 'gateway.handle', parent: null, start: 0, end: 240 },\n { name: 'catalog.lookup', parent: 'gateway.handle', start: 20, end: 220 },\n { name: 'pricing.read', parent: 'catalog.lookup', start: 30, end: 70 },\n { name: 'inventory.fetch', parent: 'catalog.lookup', start: 30, end: 200 },\n { name: 'inventory.adapter', parent: 'inventory.fetch', start: 100, end: 170 },\n];\n\nconst byName = new Map(spans.map((span) => [span.name, span]));\nconst duration = (span) => span.end - span.start;\nconst invalid = spans.filter((span) => {\n const parent = span.parent ? byName.get(span.parent) : null;\n return parent && (span.start < parent.start || span.end > parent.end);\n});\nconst catalogChildren = spans.filter((span) => span.parent === 'catalog.lookup');\nconst latestChild = catalogChildren.reduce((latest, span) =>\n span.end > latest.end ? span : latest,\n);\n\nconsole.log({\n rootDuration: duration(byName.get('gateway.handle')),\n latestCatalogChild: latestChild.name,\n latestCatalogChildEnd: latestChild.end,\n invalidParentRanges: invalid.length,\n});\n// { rootDuration: 240, latestCatalogChild: 'inventory.fetch',\n// latestCatalogChildEnd: 200, invalidParentRanges: 0 }</code></pre>\n<p>Результат подтверждает три локальных факта: root длится 240 мс, inventory завершается позже pricing, каждый child находится внутри parent. Одна шкала — условие входных данных, а не вывод программы. Код не подтверждает, что catalog действительно получил traceparent, что sampling сохранил все span или что задержка внутри adapter вызвана базой. Для последнего вывода нужен собственный сигнал adapter.</p>\n<h2>Порядок действий</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите endpoint, диапазон задержки, код ответа, входные условия и время наблюдения.</li><li><strong>Выберите одну историю.</strong> Найдите конкретный trace по доступному идентификатору. Не смешивайте повторные попытки и записи разных часов.</li><li><strong>Проверьте root.</strong> Убедитесь, что корневой span имеет start и end, а sampling и экспорт не скрыли критическую часть.</li><li><strong>Проверьте transport boundary.</strong> Сравните исходящий carrier с тем, что реально извлёк получатель. Отметьте первую границу, где trace-id исчезает.</li><li><strong>Проверьте формат.</strong> Для W3C context проверьте version, длины полей, строчные hex-символы и запрет нулевых trace-id и parent-id.</li><li><strong>Проверьте parent-child.</strong> Сверьте ожидаемого родителя и убедитесь, что child не выходит за его интервал в выбранной модели.</li><li><strong>Постройте critical path.</strong> Отделите последовательные участки от параллельных. Inclusive duration не складывайте со временем вложенного span.</li><li><strong>Назовите следующий сигнал.</strong> Для широкого span выберите один fetch: запрос адаптера, размер очереди, внешний код ответа, профиль или источник времени.</li><li><strong>Повторите проверку.</strong> Повторите тот же сценарий после изменения и отдельно проверьте отрицательный путь: потерянный carrier, sampling или невалидный timestamp.</li></ol>\n<h2>Границы вывода и безопасность</h2>\n<p>Trace показывает наблюдаемую структуру и интервалы, но не автоматически бизнес-причину. Длинный span базы может быть следствием блокировки, плохого плана, холодного соединения или внешнего лимита. Если span заканчивается после parent, сначала зафиксируйте противоречие: это может быть раннее закрытие, асинхронная работа или неверно выбранная модель связи.</p>\n<p>Обычное дерево плохо описывает fan-out, очередь, retry и работу после ответа. Для операций с несколькими причинными источниками могут понадобиться links, а для разнесённых часов — проверка источника времени и допустимой погрешности. Отсутствие span не доказывает отсутствие работы: запись могла не попасть в sampling, экспорт мог задержаться или instrumentation могла не охватить участок.</p>\n<p>Не передавайте в trace context пароль, токен, email, тело запроса или полный URL с параметрами. Trace-id нужен для корреляции, а не для хранения полезной нагрузки. Если следующий сигнал содержит персональные данные, применяйте правила доступа и маскирования вашей системы наблюдаемости.</p>\n<h2>Критерий готовности</h2>\n<p>Разбор готов, когда для одного проверочного запроса выполнены четыре условия: записи связаны с root и имеют объяснимые parent-child отношения; на каждой транспортной границе указан фактически отправленный и полученный context; критический путь рассчитан без двойного счёта вложенных и параллельных интервалов; после изменения повторён тот же симптом и отрицательный путь. Если один факт неизвестен, честный результат — «причина пока не доказана» и конкретный следующий fetch.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.w3.org/TR/trace-context/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C Trace Context</a> — формат <code>traceparent</code>, ограничения trace-id и parent-id и правила обработки входящего context.</li><li><a href=\"https://opentelemetry.io/docs/specs/otel/trace/api/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry Tracing API</a> — модель Span, parent Context, root/child связи, timestamps и links.</li><li><a href=\"https://opentelemetry.io/docs/specs/otel/context/api-propagators/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry Propagators API</a> — обязанности inject/extract и работа Propagator с carrier на удалённых вызовах.</li></ul>"
|
||
}
|