revise September 2020 tracing articles
Build and deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-07-31 11:47:30 +03:00
parent 8e16858f16
commit 6c96a2a338
7 changed files with 932 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 94 из 358 созданных материалов. Остальные 264 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 97 из 358 созданных материалов. Остальные 261 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+165
View File
@@ -0,0 +1,165 @@
# Сентябрь 2020 — тройное ревью автономного пакета P31 «Трассировка запроса»
Статус: **принят независимым редактором в выпусковой набор**. В revision нет
<code>date</code> и <code>author</code>; слой публикации сохраняет их из
базового архива. Автономный авторский пакет не менял registry,
<code>articles.json</code>, стандарт, очередь, package configuration или Git.
Проверенные revision:
- <code>editorial-2020-09-practice-tracing-basics</code>;
- <code>editorial-2020-09-mechanism-tracing-basics</code>;
- <code>editorial-2020-09-field-tracing-basics</code>.
Модуль экспортирует ровно три revision. При <code>--print-revisions</code>
stdout содержит только JSON. При <code>--verify-fixture</code> запускается
только in-memory модель одного synthetic trace; она не открывает сеть, не
читает production-журнал и не вызывает collector.
## Проход 1. Факты, историческая рамка и механизм — пройдено
| Утверждение или решение | Первичный / официальный источник | Зафиксированная граница |
| --- | --- | --- |
| <code>Trace Context Level 1</code> — W3C Recommendation от 06 февраля 2020 года, а не черновик сентября 2020 | [W3C Trace Context Level 1, 2020-02-06](https://www.w3.org/TR/2020/REC-trace-context-1-20200206/) | Тексты называют готовым именно переносимый format <code>traceparent</code>, а не «готовую трассировочную платформу». |
| Для version <code>00</code> <code>traceparent</code> несёт version, 32-hex trace-id, 16-hex parent-id и trace-flags; нулевые ID невалидны | [W3C Trace Context, sections 3.2–3.2.2](https://www.w3.org/TR/2020/REC-trace-context-1-20200206/) | Учебный parser принимает только <code>00</code>, lowercase hex, ненулевые ID и <code>00</code>/<code>01</code> flags; он не притворяется generic parser-ом будущих версий. |
| В 2020 OpenTelemetry-specification ещё была до 1.0; tag <code>v0.5.0</code> датирован 02 июня 2020 | [OpenTelemetry Specification v0.5.0 changelog](https://github.com/open-telemetry/opentelemetry-specification/blob/v0.5.0/CHANGELOG.md) | Пакет не приписывает сентябрю 2020 стабильный collector, auto-instrumentation, service map или version-independent SDK. |
| Официальная документация относит стабильность Trace API к началу 2021 года | [OpenTelemetry Libraries](https://opentelemetry.io/docs/concepts/instrumentation/libraries/) | Это редакционная историческая сверка: текст сентября 2020 остаётся transport-neutral учебным разбором, а не ретроспективной инструкцией по современной экосистеме. |
Техническая модель повторно сверена после авторского прохода:
- один synthetic trace-id связывает ровно пять span;
- <code>catalog.lookup</code> получает gateway как parent, а
<code>inventory.fetch</code> — catalog;
- duration считается только как <code>endMs - startMs</code>;
- child intervals полностью лежат внутри parent intervals в пределах fixture;
- pricing и inventory перекрываются, поэтому их duration не суммируются;
- выбранный fixture critical path —
<code>gateway.handle → catalog.lookup → inventory.fetch → inventory.adapter</code>;
- exclusive segments выбранной цепочки равны <code>40 + 30 + 100 + 70 = 240 ms</code>, то есть root duration;
- invalid all-zero trace-id и зарезервированный <code>09</code> flags отвергаются.
Выполнен <code>--verify-fixture</code>. Реальный результат содержит восемь
истинных assertions: round-trip <code>traceparent</code>, получение текущего
parent child-операцией, общий trace-id, положительные intervals, ожидаемый
critical path, совпадение exclusive суммы с root и два негативных случая.
Fixture использует только synthetic IDs, services, интервалы и header:
<code>training-gateway</code>, <code>training-catalog</code>,
<code>training-inventory</code> и
<code>00-4bf92f3577b34da6a3ce929d0e0e4736-a111111111111111-01</code>.
Вердикт прохода: **пройден**. Поправлена дополнительная техническая граница:
version <code>00</code> fixture больше не принимает произвольный двухсимвольный
flags byte; <code>01</code> остаётся примером sampled bit, но не policy,
которой должен доверять публичный вход.
## Проход 2. Редактура, голос М3 и плотность — пройдено
| Revision | Проблема и цена в первых двух абзацах | Главный вопрос | Голос и следующий шаг |
| --- | --- | --- | --- |
| Практика | Логи есть, но 240 ms не разложены; правка timeout/retry/базы по догадке увеличивает стоимость следующего сбоя | Как сохранить один context и увидеть один учебный waterfall | М3 связывает границы gateway, catalog и inventory; сначала проверяет carrier и fixture, затем предлагает один transport test. |
| Механизм | Разные trace-id или потерянный parent создают ложную причинность и неправильного «виновника» | Что реально несут trace-id, span-id, parent-id, flags и duration | М3 описывает W3C contract и прямую ответственность transport boundary, не выдавая carrier за бизнес-API. |
| Полевой разбор | Самый заметный span провоцирует оптимизацию не той ветви | Как отделить inclusive duration от critical path одной истории | М3 разбирает controlled fixture, называет overlap и выбирает одну следующую проверку вместо диагноза production-системы. |
- Draft gate после финальной технической правки измерил основной текст без
источников: практика — **10 269** знаков, механизм — **11 727**, полевой
разбор — **9 702**. Все три текста попадают в диапазон 5 000–15 000.
- В каждом revision есть 8–9 смысловых разделов, две таблицы с
<code>caption</code>/<code>thead</code>, две figure с осмысленными
<code>alt</code>/<code>figcaption</code>, два code/example блока,
нумерованный маршрут и три официальные или первичные ссылки.
- Структура держит краткую последовательность «симптом → причина → проверка
→ действие». Примеры не подменяют проверку общими словами о важности
наблюдаемости.
- В текст не внесены современные обещания: нет production latency, реального
trace или URL, сервиса карт, стабильного tracing platform, готовой
collector-конфигурации, автоматического instrumentor-а или наблюдаемого
инцидента пользователя.
- Используется исторически подходящий словарь М3: trace context, span,
transport boundary, fixture, overlap и clock skew раскрываются рядом с
конкретным действием. Автор учится связать события между сервисами, но не
пишет от лица владельца зрелой платформы.
Вердикт прохода: **пройден**. Тексты сохраняют техническую плотность и
соответствуют сентябрю 2020 года без анахронизмов.
## Проход 3. Визуал, доступность и выпуск автономного пакета — пройдено в заданных границах
- <code>tracing-span-waterfall-2020.svg</code> показывает пять intervals на
шкале 0–240 ms, overlap pricing/inventory и выделенную позднюю цепочку.
- <code>tracing-context-propagation-2020.svg</code> показывает отдельные
шаги inject/extract, неизменность trace-id и смену текущего span-id без
имитации сетевого трафика.
- <code>tracing-critical-path-2020.svg</code> показывает разницу inclusive и
exclusive duration, параллельный pricing и расчёт 240 ms.
- У каждого SVG есть <code>title</code>, <code>desc</code>,
<code>role="img"</code>, вертикальный viewBox, контрастные карточки и
короткие подписи. Нет JavaScript, <code>foreignObject</code>, внешних URL,
data URI или пользовательских данных.
- Все три SVG отрендерены Sharp при ширине **375 px** и просмотрены вручную.
Текст, bars и нижние карточки читаемы; обрезания, наложения и горизонтальный
overflow внутри SVG не обнаружены. Waterfall оставлен вертикальным именно
для этой ширины.
### Фактические команды и результаты
<pre><code>cd web
node --check scripts/upgrade-2020-09.mjs
npm run audit:draft -- scripts/upgrade-2020-09.mjs
node scripts/upgrade-2020-09.mjs --verify-fixture
xmllint --noout \
public/assets/editorial/2020/tracing-span-waterfall-2020.svg \
public/assets/editorial/2020/tracing-context-propagation-2020.svg \
public/assets/editorial/2020/tracing-critical-path-2020.svg</code></pre>
| Проверка | Реальный результат |
| --- | --- |
| <code>node --check</code> | код завершения 0 |
| Draft audit | PASS: 10 269 / 11 727 / 9 702 знаков тела |
| In-memory fixture | 8 assertions вернули <code>true</code>; root duration и selected exclusive path равны 240 ms |
| XML | все три SVG валидны, код завершения 0 |
| 375 px visual preflight | выполнен через локальный Sharp-render и ручной просмотр; SVG читаемы, без clipping/overflow |
| Scope/self-review | revision не переопределяет <code>date</code>/<code>author</code>; в работе изменены только пять разрешённых файлов P31 |
Не запускались browser-review опубликенной страницы, реальный HTTP,
reverse proxy, browser instrumentation, OpenTelemetry SDK/collector,
exporter, clock synchronization, CI, production build, deployment или
assistive-technology проверка. Наличие осмысленных alt-текстов и подписей не
заменяет проверку скринридером.
## Итог
Статус: **тройное ревью пройдено, пакет P31 принят к отдельной публикации**.
После трёх проходов зафиксированы три существенные границы качества:
1. W3C Trace Context Recommendation 2020 отделена от незрелости ранней
OpenTelemetry-экосистемы; статья не обещает современный platform-level
результат.
2. Fixture проверяет не только красивую схему: он связывает trace context,
parent/child relation, duration и exclusive critical path в одном
синтетическом запросе.
3. SVG переделаны как крупные вертикальные схемы и отдельно проверены на
ширине 375 px; публикационный слой, архив и чужие незакоммиченные файлы
намеренно не затронуты.
## Независимая интеграционная приёмка
Основной редактор 31 июля 2026 года подключил три revision к
<code>web/data/editorial-revisions.mjs</code>, не меняя базовый
<code>articles.json</code>, даты или автора архивных записей. В registry стало
88 revision. Отдельно выполнены:
| Проверка после интеграции | Реальный результат |
| --- | --- |
| Строгий audit трёх slug | PASS: 10 269 / 11 727 / 9 702 знаков; у каждой статьи есть figures, tables и code examples |
| Production build | PASS: Next.js собрал 374 статические страницы |
| Независимый mobile visual review | PASS: основной редактор повторно просмотрел три SVG после Sharp-рендера в 375 px; clipping, overlap и overflow не обнаружены |
W3C Recommendation от 6 февраля 2020 года сверена независимо по первичному
тексту: она действительно описывает перенос контекста через
<code>traceparent</code>, но не обещает готовую tracing-платформу. В отчёте не
утверждается запуск реального HTTP, proxy, OpenTelemetry SDK/collector,
browser или assistive technology.
Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной
публикационной операцией; Git остаётся источником её фактической записи.
+2
View File
@@ -27,6 +27,7 @@ import { revisions as may2020Revisions } from '../scripts/upgrade-2020-05.mjs';
import { revisions as june2020Revisions } from '../scripts/upgrade-2020-06.mjs';
import { revisions as july2020Revisions } from '../scripts/upgrade-2020-07.mjs';
import { revisions as august2020Revisions } from '../scripts/upgrade-2020-08.mjs';
import { revisions as september2020Revisions } from '../scripts/upgrade-2020-09.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -59,4 +60,5 @@ export const editorialRevisions = [
...june2020Revisions,
...july2020Revisions,
...august2020Revisions,
...september2020Revisions,
];
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1230" role="img" aria-labelledby="title desc">
<title id="title">Передача учебного trace context через две границы</title>
<desc id="desc">Вертикальная схема показывает, как gateway создаёт span A, передаёт traceparent с общим trace-id и своим span-id, catalog создаёт child span B, а затем передаёт свой span-id к inventory. Trace-id остаётся общим.</desc>
<style>
.bg { fill: #071a2b; }
.card { fill: #0d2a42; stroke: #35607f; stroke-width: 2; }
.blue { fill: #31b8ff; }
.cyan { fill: #b8ecff; }
.muted { fill: #b6d0e4; font-family: Inter, Arial, sans-serif; }
.text { fill: #eaf6ff; font-family: Inter, Arial, sans-serif; }
.title { font-size: 32px; font-weight: 700; }
.subtitle { font-size: 21px; }
.label { font-size: 27px; font-weight: 700; }
.meta { font-size: 21px; font-weight: 600; }
.code { fill: #eaf6ff; font-family: Menlo, Consolas, monospace; font-size: 20px; }
</style>
<rect class="bg" width="720" height="1230" rx="34" />
<text class="text title" x="48" y="66">Trace context на границе</text>
<text class="muted subtitle" x="48" y="104">Учебная version 00; без реального HTTP</text>
<rect class="card" x="42" y="140" width="636" height="150" rx="20" />
<circle class="blue" cx="94" cy="204" r="24" />
<text class="text label" x="136" y="194">1. gateway создаёт span A</text>
<text class="muted meta" x="136" y="230">trace-id: общий для истории</text>
<text class="muted meta" x="136" y="262">span-id: A · parent: нет</text>
<line class="blue" x1="360" y1="310" x2="360" y2="350" stroke-width="8" stroke-linecap="round" />
<polygon class="blue" points="344,344 376,344 360,370" />
<rect class="card" x="42" y="390" width="636" height="174" rx="20" />
<text class="text label" x="70" y="432">inject: короткий carrier</text>
<text class="code" x="70" y="474">traceparent: 00-trace-id-span-A-01</text>
<text class="muted meta" x="70" y="514">trace-id не меняется</text>
<text class="muted meta" x="70" y="544">parent-id — текущий span вызывающей стороны</text>
<line class="blue" x1="360" y1="584" x2="360" y2="624" stroke-width="8" stroke-linecap="round" />
<polygon class="blue" points="344,618 376,618 360,644" />
<rect class="card" x="42" y="664" width="636" height="166" rx="20" />
<circle class="blue" cx="94" cy="730" r="24" />
<text class="text label" x="136" y="720">2. catalog делает extract</text>
<text class="muted meta" x="136" y="756">создаёт span B с parent = A</text>
<text class="muted meta" x="136" y="790">trace-id остаётся тем же</text>
<line class="blue" x1="360" y1="850" x2="360" y2="890" stroke-width="8" stroke-linecap="round" />
<polygon class="blue" points="344,884 376,884 360,910" />
<rect class="card" x="42" y="930" width="636" height="188" rx="20" />
<circle class="blue" cx="94" cy="994" r="24" />
<text class="text label" x="136" y="984">3. inventory получает span B</text>
<text class="muted meta" x="136" y="1020">новый child получит parent = B</text>
<text class="muted meta" x="136" y="1054">меняется span-id, не trace-id</text>
<rect class="blue" x="70" y="1076" width="580" height="2" />
<text class="cyan meta" x="70" y="1106">Не передаём URL, пользователя, token или body</text>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280" role="img" aria-labelledby="title desc">
<title id="title">Как читать critical path в учебной трассе</title>
<desc id="desc">Вертикальная схема показывает, что duration родительского span включает дочерние интервалы. Критический путь состоит из exclusive сегментов gateway, catalog, inventory и adapter и даёт 240 миллисекунд. Pricing остаётся параллельной ветвью.</desc>
<style>
.bg { fill: #071a2b; }
.card { fill: #0d2a42; stroke: #35607f; stroke-width: 2; }
.blue { fill: #31b8ff; }
.orange { fill: #f6b756; }
.text { fill: #eaf6ff; font-family: Inter, Arial, sans-serif; }
.muted { fill: #b6d0e4; font-family: Inter, Arial, sans-serif; }
.title { font-size: 32px; font-weight: 700; }
.subtitle { font-size: 21px; }
.label { font-size: 27px; font-weight: 700; }
.meta { font-size: 21px; font-weight: 600; }
.big { font-size: 30px; font-weight: 700; }
</style>
<rect class="bg" width="720" height="1280" rx="34" />
<text class="text title" x="48" y="66">Critical path без двойного счёта</text>
<text class="muted subtitle" x="48" y="104">Только controlled fixture с одной шкалой</text>
<rect class="card" x="42" y="140" width="636" height="144" rx="20" />
<text class="text label" x="70" y="184">1. Duration parent — inclusive</text>
<text class="muted meta" x="70" y="222">inventory: 170 ms</text>
<text class="muted meta" x="70" y="254">adapter: 70 ms внутри inventory</text>
<line class="orange" x1="360" y1="304" x2="360" y2="338" stroke-width="8" stroke-linecap="round" />
<polygon class="orange" points="344,332 376,332 360,358" />
<rect class="card" x="42" y="378" width="636" height="154" rx="20" />
<text class="text label" x="70" y="422">2. Не складываем 170 + 70</text>
<text class="muted meta" x="70" y="462">интервалы пересекаются</text>
<text class="muted meta" x="70" y="498">иначе одно ожидание посчитано дважды</text>
<line class="blue" x1="360" y1="552" x2="360" y2="586" stroke-width="8" stroke-linecap="round" />
<polygon class="blue" points="344,580 376,580 360,606" />
<rect class="card" x="42" y="626" width="636" height="266" rx="20" />
<text class="text label" x="70" y="670">3. Выбираем позднюю цепочку</text>
<rect class="blue" x="72" y="706" width="542" height="44" rx="11" />
<text class="bg meta" x="96" y="736">gateway exclusive · 40 ms</text>
<rect class="blue" x="98" y="760" width="468" height="44" rx="11" />
<text class="bg meta" x="122" y="790">catalog exclusive · 30 ms</text>
<rect class="blue" x="124" y="814" width="394" height="44" rx="11" />
<text class="bg meta" x="148" y="844">inventory exclusive · 100 ms</text>
<rect class="blue" x="150" y="868" width="320" height="44" rx="11" />
<text class="bg meta" x="174" y="898">adapter exclusive · 70 ms</text>
<rect class="card" x="42" y="930" width="636" height="126" rx="20" />
<text class="text label" x="70" y="974">Pricing: параллельная ветвь</text>
<rect class="orange" x="70" y="994" width="150" height="34" rx="9" />
<text class="bg meta" x="94" y="1018">40 ms</text>
<text class="muted meta" x="242" y="1018">не продлевает финал root</text>
<line class="blue" x1="360" y1="1076" x2="360" y2="1110" stroke-width="8" stroke-linecap="round" />
<polygon class="blue" points="344,1104 376,1104 360,1130" />
<rect class="card" x="42" y="1150" width="636" height="84" rx="20" />
<text class="text big" x="70" y="1202">40 + 30 + 100 + 70 = 240 ms</text>
<text class="muted meta" x="70" y="1228">совпадает с root duration fixture</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1260" role="img" aria-labelledby="title desc">
<title id="title">Waterfall одного синтетического trace на шкале 240 миллисекунд</title>
<desc id="desc">Вертикальная диаграмма показывает gateway, catalog, pricing, inventory и adapter. Pricing перекрывается с inventory. Критическая цепочка gateway, catalog, inventory и adapter выделена синим.</desc>
<style>
.bg { fill: #071a2b; }
.panel { fill: #0d2a42; stroke: #35607f; stroke-width: 2; }
.axis { stroke: #89aac2; stroke-width: 3; }
.tick { stroke: #486d87; stroke-width: 2; }
.critical { fill: #31b8ff; }
.parallel { fill: #f6b756; }
.text { fill: #eaf6ff; font-family: Inter, Arial, sans-serif; }
.muted { fill: #b6d0e4; font-family: Inter, Arial, sans-serif; }
.title { font-size: 32px; font-weight: 700; }
.subtitle { font-size: 21px; }
.label { font-size: 26px; font-weight: 700; }
.meta { font-size: 21px; font-weight: 600; }
.small { font-size: 20px; }
</style>
<rect class="bg" width="720" height="1260" rx="34" />
<text class="text title" x="48" y="66">Один учебный waterfall</text>
<text class="muted subtitle" x="48" y="104">Синтетический trace; не production latency</text>
<rect class="panel" x="42" y="132" width="636" height="72" rx="16" />
<text class="text meta" x="68" y="178">Root duration: 240 ms · одна logical clock</text>
<line class="axis" x1="116" y1="248" x2="630" y2="248" />
<line class="tick" x1="116" y1="236" x2="116" y2="260" />
<line class="tick" x1="245" y1="236" x2="245" y2="260" />
<line class="tick" x1="373" y1="236" x2="373" y2="260" />
<line class="tick" x1="502" y1="236" x2="502" y2="260" />
<line class="tick" x1="630" y1="236" x2="630" y2="260" />
<text class="muted small" x="106" y="224">0</text>
<text class="muted small" x="228" y="224">60</text>
<text class="muted small" x="351" y="224">120</text>
<text class="muted small" x="480" y="224">180</text>
<text class="muted small" x="600" y="224">240 ms</text>
<text class="text label" x="48" y="310">gateway.handle</text>
<text class="muted meta" x="48" y="340">0–240 ms</text>
<rect class="critical" x="116" y="362" width="514" height="48" rx="12" />
<text class="bg meta" x="140" y="394">240 ms · root</text>
<line class="axis" x1="137" y1="428" x2="137" y2="466" />
<text class="text label" x="48" y="492">catalog.lookup</text>
<text class="muted meta" x="48" y="522">20–220 ms</text>
<rect class="critical" x="159" y="544" width="429" height="48" rx="12" />
<text class="bg meta" x="183" y="576">200 ms · child gateway</text>
<line class="axis" x1="180" y1="610" x2="180" y2="650" />
<text class="text label" x="48" y="676">pricing.read</text>
<text class="muted meta" x="48" y="706">30–70 ms</text>
<rect class="parallel" x="180" y="728" width="86" height="48" rx="12" />
<text class="bg meta" x="196" y="760">40</text>
<text class="muted small" x="284" y="760">параллельная ветвь</text>
<text class="text label" x="48" y="838">inventory.fetch</text>
<text class="muted meta" x="48" y="868">30–200 ms</text>
<rect class="critical" x="180" y="890" width="364" height="48" rx="12" />
<text class="bg meta" x="204" y="922">170 ms · позднее завершение</text>
<line class="axis" x1="330" y1="956" x2="330" y2="996" />
<text class="text label" x="48" y="1022">inventory.adapter</text>
<text class="muted meta" x="48" y="1052">100–170 ms</text>
<rect class="critical" x="330" y="1074" width="150" height="48" rx="12" />
<text class="bg meta" x="356" y="1106">70 ms</text>
<rect class="panel" x="42" y="1152" width="636" height="68" rx="16" />
<text class="text meta" x="68" y="1194">Критический путь: gateway → catalog → inventory → adapter</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+581
View File
@@ -0,0 +1,581 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const captionHtml = '<caption>' + escapeHtml(caption) + '</caption>';
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + captionHtml + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function visibleText(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function proseText(html) {
return visibleText(
html
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
.replace(/<div class="table-scroll">[\s\S]*?<\/div>/g, ''),
);
}
function createRevision(meta, bodyParts, sources) {
const bodyHtml = bodyParts.join('\n');
const proseLength = proseText(bodyHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary or official sources are required');
}
return {
...meta,
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
proseLength,
};
}
const w3cTraceContext2020 = {
title: 'W3C Trace Context Level 1 — Recommendation, 06 February 2020',
url: 'https://www.w3.org/TR/2020/REC-trace-context-1-20200206/',
note: 'историческая Recommendation задаёт поля traceparent версии 00, правила проверки trace-id/parent-id и различает перенос контекста с участием в trace',
};
const otelSpecV05 = {
title: 'OpenTelemetry Specification v0.5.0 — historical changelog',
url: 'https://github.com/open-telemetry/opentelemetry-specification/blob/v0.5.0/CHANGELOG.md',
note: 'tagged revision от 2 июня 2020 года; версия до 1.0 фиксирует развивающуюся спецификацию, а не готовую кросс-языковую платформу',
};
const otelTraceApiHistory = {
title: 'OpenTelemetry: Libraries — historical stability note',
url: 'https://opentelemetry.io/docs/concepts/instrumentation/libraries/',
note: 'официальная документация относит стабильность Trace API к началу 2021 года; это редакционная граница, из-за которой статья сентября 2020 не обещает стабильный SDK',
};
const traceIdPattern = /^[0-9a-f]{32}$/;
const spanIdPattern = /^[0-9a-f]{16}$/;
const traceFlagsPattern = /^[0-9a-f]{2}$/;
function assertNonZeroHex(value, label) {
if (!value || /^0+$/.test(value)) {
throw new Error(label + ' must not be all zeroes');
}
}
function assertVersion00TraceFlags(traceFlags) {
if (!traceFlagsPattern.test(traceFlags) || (traceFlags !== '00' && traceFlags !== '01')) {
throw new Error('Unsupported trace-flags for version 00 training fixture');
}
}
/**
* Учебный parser ровно для W3C traceparent version 00.
* Это не замена SDK и не adapter для реального сервиса.
*/
export function parseTrainingTraceparent(headerValue) {
const value = String(headerValue || '');
const parts = value.split('-');
if (parts.length !== 4 || parts[0] !== '00') {
throw new Error('Expected a version 00 training traceparent');
}
const [, traceId, parentSpanId, traceFlags] = parts;
if (!traceIdPattern.test(traceId)) throw new Error('Invalid trace-id');
if (!spanIdPattern.test(parentSpanId)) throw new Error('Invalid parent-id');
assertVersion00TraceFlags(traceFlags);
assertNonZeroHex(traceId, 'trace-id');
assertNonZeroHex(parentSpanId, 'parent-id');
return { version: '00', traceId, parentSpanId, traceFlags };
}
/**
* Возвращает синтетический traceparent для следующей учебной границы.
* spanId — текущий span вызывающей стороны; получатель создаёт нового child.
*/
export function createTrainingTraceparent({ traceId, spanId, traceFlags = '01' }) {
if (!traceIdPattern.test(traceId)) throw new Error('Invalid trace-id for injection');
if (!spanIdPattern.test(spanId)) throw new Error('Invalid span-id for injection');
assertVersion00TraceFlags(traceFlags);
assertNonZeroHex(traceId, 'trace-id');
assertNonZeroHex(spanId, 'span-id');
return '00-' + traceId + '-' + spanId + '-' + traceFlags;
}
function childrenOf(spans, parentSpanId) {
return spans.filter((span) => span.parentSpanId === parentSpanId);
}
function intervalUnionLength(intervals) {
const ordered = [...intervals]
.map(({ startMs, endMs }) => ({ startMs, endMs }))
.sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
let total = 0;
let current = null;
for (const interval of ordered) {
if (!current || interval.startMs > current.endMs) {
if (current) total += current.endMs - current.startMs;
current = interval;
continue;
}
current.endMs = Math.max(current.endMs, interval.endMs);
}
if (current) total += current.endMs - current.startMs;
return total;
}
function exclusiveDuration(span, spans) {
const children = childrenOf(spans, span.spanId);
const childrenInsideParent = children.map((child) => ({
startMs: Math.max(span.startMs, child.startMs),
endMs: Math.min(span.endMs, child.endMs),
}));
return span.endMs - span.startMs - intervalUnionLength(childrenInsideParent);
}
function validateTrainingTrace(spans) {
const bySpanId = new Map(spans.map((span) => [span.spanId, span]));
const roots = spans.filter((span) => span.parentSpanId === null);
if (roots.length !== 1) throw new Error('Training trace must have one root span');
if (bySpanId.size !== spans.length) throw new Error('Training trace has duplicate span ids');
const traceId = roots[0].traceId;
for (const span of spans) {
if (!traceIdPattern.test(span.traceId) || span.traceId !== traceId) {
throw new Error('All training spans must share one valid trace-id');
}
if (!spanIdPattern.test(span.spanId)) throw new Error('Invalid training span-id');
if (!Number.isFinite(span.startMs) || !Number.isFinite(span.endMs) || span.endMs <= span.startMs) {
throw new Error('Span duration must be positive');
}
if (span.parentSpanId !== null) {
const parent = bySpanId.get(span.parentSpanId);
if (!parent) throw new Error('Child span has no parent');
if (span.startMs < parent.startMs || span.endMs > parent.endMs) {
throw new Error('Child span must stay inside its parent interval in this fixture');
}
}
}
return { root: roots[0], bySpanId, traceId };
}
function chooseCriticalPath(root, spans) {
const path = [];
let current = root;
while (current) {
path.push(current);
const children = childrenOf(spans, current.spanId)
.sort((left, right) => right.endMs - left.endMs || right.startMs - left.startMs);
current = children[0] || null;
}
return path;
}
/**
* Проверяем один искусственный waterfall с одним logical clock.
* Здесь нет сетевого вызова, collector-а, production trace или данных пользователя.
*/
export function runTracingFixture() {
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const spans = [
{ traceId, spanId: 'a111111111111111', parentSpanId: null, name: 'gateway.handle', service: 'training-gateway', startMs: 0, endMs: 240 },
{ traceId, spanId: 'b222222222222222', parentSpanId: 'a111111111111111', name: 'catalog.lookup', service: 'training-catalog', startMs: 20, endMs: 220 },
{ traceId, spanId: 'c333333333333333', parentSpanId: 'b222222222222222', name: 'pricing.read', service: 'training-catalog', startMs: 30, endMs: 70 },
{ traceId, spanId: 'd444444444444444', parentSpanId: 'b222222222222222', name: 'inventory.fetch', service: 'training-inventory', startMs: 30, endMs: 200 },
{ traceId, spanId: 'e555555555555555', parentSpanId: 'd444444444444444', name: 'inventory.adapter', service: 'training-inventory', startMs: 100, endMs: 170 },
];
const { root } = validateTrainingTrace(spans);
const traceparent = createTrainingTraceparent({ traceId, spanId: root.spanId, traceFlags: '01' });
const extracted = parseTrainingTraceparent(traceparent);
const criticalPath = chooseCriticalPath(root, spans);
const criticalPathNames = criticalPath.map((span) => span.name);
const pathExclusiveDurationMs = criticalPath
.map((span) => exclusiveDuration(span, spans))
.reduce((total, duration) => total + duration, 0);
let invalidHeaderRejected = false;
try {
parseTrainingTraceparent('00-00000000000000000000000000000000-a111111111111111-01');
} catch {
invalidHeaderRejected = true;
}
let reservedFlagRejected = false;
try {
createTrainingTraceparent({ traceId, spanId: root.spanId, traceFlags: '09' });
} catch {
reservedFlagRejected = true;
}
return {
traceparent,
spans: spans.map((span) => ({
...span,
durationMs: span.endMs - span.startMs,
exclusiveDurationMs: exclusiveDuration(span, spans),
})),
criticalPath: criticalPathNames,
rootDurationMs: root.endMs - root.startMs,
pathExclusiveDurationMs,
assertions: {
traceparentRoundTrip: extracted.traceId === traceId && extracted.parentSpanId === root.spanId,
childReceivedCurrentParent: spans.find((span) => span.name === 'catalog.lookup').parentSpanId === extracted.parentSpanId,
everySpanHasSameTrace: spans.every((span) => span.traceId === traceId),
spanDurationIsEndMinusStart: spans.every((span) => span.endMs - span.startMs > 0),
criticalPathIsExpected: criticalPathNames.join(' > ') === 'gateway.handle > catalog.lookup > inventory.fetch > inventory.adapter',
criticalPathExclusiveTimeMatchesRoot: pathExclusiveDurationMs === root.endMs - root.startMs,
invalidHeaderRejected,
reservedFlagRejected,
},
};
}
const syntheticNotice = 'Все ID, имена сервисов, интервалы, header и экспорт ниже синтетические. Они собраны в памяти модуля и не являются trace, URL, HTTP-заголовком, аккаунтом или измерением из системы пользователя.';
const traceparentCode = [
"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,',
'};',
];
const fixtureCommandCode = [
'# Запускается только проверка учебной модели из этого revision-модуля.',
'node scripts/upgrade-2020-09.mjs --verify-fixture',
'',
'# Важные части ожидаемого результата:',
'traceparentRoundTrip: true',
'childReceivedCurrentParent: true',
'criticalPathIsExpected: true',
'criticalPathExclusiveTimeMatchesRoot: true',
];
const mechanismCode = [
'function readSpanDuration(span) {',
' if (span.endMs <= span.startMs) throw new Error(\'invalid interval\');',
' return span.endMs - span.startMs;',
'}',
'',
'// У parent duration включает время child span.',
"const inventory = { startMs: 30, endMs: 200 };",
"const adapter = { startMs: 100, endMs: 170 };",
'',
'readSpanDuration(inventory); // 170 ms, inclusive',
'readSpanDuration(adapter); // 70 ms, child внутри inventory',
'',
'// Нельзя складывать 170 + 70: это два пересекающихся интервала.',
];
const practiceArticle = createRevision(
{
slug: 'editorial-2020-09-practice-tracing-basics',
title: 'Трассировка запроса: как собрать один учебный waterfall без платформы',
categories: ['Наблюдаемость', 'HTTP', 'Отладка'],
cover: '/assets/editorial/2020/tracing-span-waterfall-2020.svg',
excerpt: 'Как провести один синтетический trace context через учебные границы, увидеть parent/child span и не выдать раннюю практику 2020 года за готовую tracing-платформу.',
readingMinutes: 14,
},
[
paragraph('Симптом знаком после появления структурных логов: для одного запроса уже можно найти несколько строк, но из них не видно, где именно прошли 240 миллисекунд. Цена ошибки — править timeout, повтор или базу по догадке. Такая правка может скрыть задержку на время, но следующий запрос снова оставит только разрозненные события. До действия нужен один связанный сценарий, а не новый dashboard.'),
paragraph('В этой статье я не подключаю готовую платформу и не показываю trace из чьей-либо системы. Соберу один учебный запрос в памяти: gateway создаёт span, catalog получает его контекст, затем внутри catalog параллельно живут короткий pricing и длинный inventory. Цель скромная: проверить, что общий trace-id не теряется, parent-id указывает на вызывающий span, а waterfall позволяет назвать следующий участок для проверки.'),
heading('Историческая граница сентября 2020 года'),
paragraph('Сначала важно не смешать два слоя. <code>Trace Context Level 1</code> уже не был черновиком: W3C опубликовал Recommendation 6 февраля 2020 года. Она описывает переносимый HTTP-представитель контекста <code>traceparent</code> и отдельный <code>tracestate</code> для данных конкретного поставщика. Это даёт общий формат передачи, но не рисует service map и не выбирает за нас storage, sampling, exporter или интерфейс разбора.'),
paragraph('OpenTelemetry в этот момент полезно воспринимать как развивающуюся спецификацию и набор ранних реализаций, а не как стабильную кнопку «добавить наблюдаемость». Исторический tag <code>v0.5.0</code> относится к июню 2020 года, а стабильность Trace API официальная документация позже относит к началу 2021-го. Поэтому ниже нет version-specific конфигурации collector-а, auto-instrumentation или обещания, что один пакет одинаково решит браузер, Node и каждую библиотеку. Есть минимальный контракт, который можно проверить отдельно.'),
dataTable(
'Границы учебного контракта трассировки',
['Объект', 'Что означает в примере', 'Что не следует в него класть', 'Как проверяем'],
[
['<code>traceId</code>', 'одна логическая история из пяти span', 'пользователя, email, URL с параметрами', 'все span имеют одинаковое 32-hex значение'],
['<code>spanId</code>', 'одна операция внутри истории', 'название endpoint или текст ошибки', 'каждый ID уникален и состоит из 16 hex-символов'],
['<code>parentSpanId</code>', 'прямая причина появления child span', 'ID далёкого предка или произвольный request_id', 'parent существует и охватывает child по времени'],
['<code>duration</code>', 'разность учебных <code>endMs - startMs</code>', 'обещание latency на другой машине', 'конец строго больше начала'],
['<code>traceparent</code>', 'перенос trace-id и текущего parent span через одну границу', 'секрет, авторизационный заголовок или полный объект запроса', 'parser принимает только version <code>00</code> и валидные ID'],
],
),
heading('Один trace, а не набор красивых строк'),
paragraph('Trace — это не ещё один вид лога. В учебной модели это дерево операций с общей причиной: пользовательское действие попало в gateway, gateway попросил catalog, а catalog вызвал inventory. Span — одна операция с именем, началом, концом и связью с родителем. Если gateway начал <code>gateway.handle</code>, а catalog создал новый случайный trace-id, дерево уже разорвано. В журнале могут остаться две хорошие строки, но вопрос «какой downstream блокировал исходный запрос?» останется без ответа.'),
paragraph('Контекст нужен именно на границе. Внутри одной функции можно передать объект аргументом, но после транспорта соседний процесс не знает текущий span автоматически. В W3C version <code>00</code> поле <code>traceparent</code> состоит из четырёх частей: version, trace-id, parent-id и trace-flags. В нашем упражнении gateway сериализует trace-id и свой span-id. Catalog извлекает их, создаёт child span и уже его ID передаст дальше. Это не означает, что header принадлежит бизнес-API: это инфраструктурная граница, которую нужно ограничивать и проверять отдельно.'),
figure(
'/assets/editorial/2020/tracing-context-propagation-2020.svg',
'Вертикальная схема одного учебного trace context: gateway создаёт span A, передаёт синтетический traceparent, catalog создаёт span B с parent A и передаёт уже свой span-id дальше к inventory при неизменном trace-id',
'На каждой границе меняется текущий span-id, а trace-id остаётся общим. Схема показывает контракт передачи, а не реальный HTTP-запрос.',
),
heading('Минимальный header проверяем как данные'),
paragraph(syntheticNotice),
codeBlock(traceparentCode),
paragraph('Вызов <code>createTrainingTraceparent()</code> не открывает сеть. Он возвращает строку, которую сразу разбирает <code>parseTrainingTraceparent()</code>. Этот узкий round-trip ловит две разные ошибки. Первая — формат: случайно передали ID неправильной длины, верхний регистр или нулевое значение. Вторая — смысл: child span получил родителя не из извлечённого контекста. Если присутствует только первая проверка, можно получить валидную строку, которая связывает не те операции.'),
paragraph('Я намеренно не добавляю сюда <code>tracestate</code>. W3C оставляет его для данных конкретной tracing-системы; в учебном упражнении нет такого владельца. Запись «на будущее» без того, кто её читает, превращается в неподтверждённый vendor contract. То же относится к baggage, произвольным полям пользователя и полным URL. Контекст должен связывать работу, а не становиться обходным каналом для данных, которые нельзя проверить или безопасно хранить.'),
heading('Собираем waterfall с одним логическим временем'),
paragraph('После того как связь существует, можно смотреть duration. В fixture пять интервалов на одной шкале: <code>gateway.handle</code> длится от 0 до 240 ms; <code>catalog.lookup</code> лежит внутри него от 20 до 220 ms; <code>pricing.read</code> занимает 30–70 ms; <code>inventory.fetch</code> — 30–200 ms; его adapter — 100–170 ms. Значения выбраны для объяснения и не являются latency, которую кто-то измерил.'),
paragraph('Здесь легко ошибиться с арифметикой. Duration parent span включает время его child span. Поэтому <code>inventory.fetch = 170 ms</code> и <code>inventory.adapter = 70 ms</code> нельзя сложить и назвать 240 ms: adapter уже находится внутри inventory. Для одного учебного дерева можно отдельно посчитать exclusive время — интервалы parent, не перекрытые дочерними span. Это помогает объяснить путь, но не отменяет необходимости сравнить часы и инструментирование в реальном распределённом запуске.'),
figure(
'/assets/editorial/2020/tracing-span-waterfall-2020.svg',
'Вертикальный waterfall синтетического trace на шкале 0–240 ms: gateway длится 240 ms, catalog 200 ms, pricing 40 ms параллельно inventory 170 ms, а adapter 70 ms вложен в inventory; критический путь выделен контрастным цветом',
'Короткий pricing идёт параллельно с inventory и не продлевает финал. Выделенная цепочка показывает, какую гипотезу проверять первой в учебной модели.',
),
heading('Фикстура доказывает только свой маленький договор'),
paragraph('У revision-модуля есть in-memory fixture. Она создаёт синтетические span, валидирует одного root, проверяет parent/child containment, делает round-trip header-а и выбирает child, который заканчивается последним. Затем fixture складывает exclusive сегменты выбранной цепочки. Для именно этой вложенной модели сумма совпадает с root duration 240 ms. Так тест ловит перестановку parent-id, отрицательный интервал и ошибочное сложение пересекающихся duration до того, как текст станет инструкцией.'),
codeBlock(fixtureCommandCode),
paragraph('Важно назвать и то, чего эта команда не доказывает. Она не проверяет реальный proxy, браузер, clock skew между хостами, collector, sampling или экспорт. Она не убеждается, что какой-либо framework автоматически сохранит контекст через callback и очередь. И она не решает вопрос стоимости хранения trace. Это полезный ограничитель: fixture проверяет форму одной истории, а внедрение начинается с одного настоящего маршрута и отдельного теста на каждой транспортной границе.'),
dataTable(
'Как читать результат fixture, не расширяя его смысл',
['Проверка', 'Что подтверждает', 'Чего не подтверждает', 'Следующее действие'],
[
['<code>traceparentRoundTrip</code>', 'строка version 00 сохраняет trace-id и текущий span-id', 'что header дошёл через реальный reverse proxy', 'добавить изолированный transport test в конкретном сервисе'],
['<code>childReceivedCurrentParent</code>', 'catalog в модели дочерний к gateway', 'что все framework middleware создают правильные span', 'проверить одну входящую и одну исходящую границу'],
['<code>criticalPathIsExpected</code>', 'fixture выбрал поздно завершающуюся цепочку', 'что это единственный bottleneck в production', 'проверить инструментирование выбранного участка'],
['<code>invalidHeaderRejected</code>', 'нулевой trace-id не проходит parser', 'политику доверия к внешнему клиенту', 'описать, где входной контекст принимается, а где создаётся заново'],
],
),
heading('Маршрут первой проверки'),
orderedList([
'Выбрать один учебный пользовательский путь и назвать цену задержки без диагноза: например, «результат ждёт один downstream, но логи не показывают порядок».',
'Задать один trace-id и пять заранее перечисленных span. Не включать в ID данные пользователя, маршрут с параметрами или текст исключения.',
'На первой границе создать root span; перед следующей границей сериализовать только version 00, trace-id, текущий span-id и flags.',
'На получателе валидировать format, создать child с извлечённым parent-id и проверить, что trace-id не сменился.',
'Запустить <code>--verify-fixture</code>; если red, исправить контракт или интервалы, а не переставлять таймауты.',
'Только затем выбрать один реальный транспорт и добавить отдельный test, который проверяет передачу контекста без настоящих пользовательских данных.',
]),
heading('Где этот приём останавливается'),
paragraph('Этот материал не описывает полноценную distributed tracing platform. У него нет backend-а поиска, service map, collector configuration, retention, sampling policy и автоматической инструментации. В сентябре 2020 года было бы неправдоподобно написать, что эти куски уже одинаково стабильны во всех стеках. Нормальный следующий шаг — не массово оборачивать всё приложение, а выбрать одну синхронную границу, сохранить version/adapter рядом с тестом и сначала увидеть один честный waterfall.'),
paragraph('Если задача включает очередь, fan-out или несколько родителей, дерево fixture уже недостаточно. Там появляются links, асинхронное время и отдельное решение о том, считать ли продолжение тем же trace. Если часы разных процессов не согласованы, абсолютное наложение на waterfall тоже требует проверки. В этих случаях причина звучит конкретно: модель слишком мала для новой границы. Действие тоже конкретно: не рисовать уверенный critical path до того, как появится проверяемый transport и время.'),
],
[w3cTraceContext2020, otelSpecV05, otelTraceApiHistory],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2020-09-mechanism-tracing-basics',
title: 'Trace context в сентябре 2020: parent span, header и граница передачи',
categories: ['Наблюдаемость', 'HTTP', 'Архитектура'],
cover: '/assets/editorial/2020/tracing-context-propagation-2020.svg',
excerpt: 'Разбираем механизм trace context: что несёт W3C traceparent version 00, как child получает parent и почему duration родителя нельзя складывать с duration вложенной операции.',
readingMinutes: 15,
},
[
paragraph('Симптом механической ошибки простой: gateway и catalog пишут «свои» span, но у них разные trace-id либо catalog не знает parent. Цена — ложная причинность. На экране появляется несколько длительных операций, а команда не может доказать, были ли они частью одного запроса, шли ли параллельно и какая граница действительно задержала ответ. В такой ситуации опасно выбирать виновника по самому большому числу.'),
paragraph('Причина обычно находится не в визуализации, а в контракте передачи. Кто-то заменил incoming context новым ID, передал весь request object вместо короткого carrier-а, записал parent-id как trace-id или не закрыл span. Проверка начинается с одной строки <code>traceparent</code> и одной пары parent/child, а действие — с того места, где сериализация и извлечение принадлежат владельцу transport boundary. Не с покупки платформы и не с глобального middleware.'),
heading('Какой стандарт был доступен на дату статьи'),
paragraph('На дату сентября 2020 года нужно говорить точно. <code>Trace Context Level 1</code> стал W3C Recommendation 06 February 2020, поэтому называть сам <code>traceparent</code> «черновым заголовком» неверно. Документ определяет стандартные HTTP headers и format для передачи контекста между сервисами. Он не предписывает vendor, storage или единый интерфейс поиска trace. <code>tracestate</code> существует как опциональное расширение поставщика, но не нужен, чтобы в первом упражнении сохранить parent/child связь.'),
paragraph('Совсем другой статус имели OpenTelemetry API, SDK и конкретные интеграции. Исторический release <code>v0.5.0</code> спецификации датирован июнем 2020 года; стабильность Trace API официальный проект относит к началу 2021-го. Поэтому не буду приписывать сентябрю 2020 готовую экосистему со стабильной автоинструментацией, collector-конфигурацией и сервисной картой. В коде используются нейтральные функции <code>parseTrainingTraceparent</code> и <code>createTrainingTraceparent</code>, чтобы объяснить сам договор, а не API конкретной библиотеки.'),
dataTable(
'Четыре поля W3C traceparent version 00 в учебной модели',
['Поле', 'Форма в Recommendation', 'Роль на границе', 'Проверка в модуле'],
[
['<code>version</code>', '<code>00</code> для рассматриваемого format', 'говорит parser-у, как читать следующие части', 'принимается только ровно <code>00</code>; future version не угадывается'],
['<code>trace-id</code>', '32 lowercase hex, не все нули', 'собирает один logical trace', 'одинаков у всех пяти synthetic span'],
['<code>parent-id</code>', '16 lowercase hex, не все нули', 'указывает на текущую операцию вызывающей стороны', 'catalog получает его как <code>parentSpanId</code>'],
['<code>trace-flags</code>', '<code>00</code> или <code>01</code> в учебной version 00', 'несёт флаг контекста; не является командой доверять любому клиенту', 'fixture принимает только документированные значения и не превращает их в policy sampling'],
],
),
heading('Trace-id отвечает не на тот же вопрос, что span-id'),
paragraph('Trace-id — идентификатор всей логической истории. В нашем примере он остаётся <code>4bf92f3577b34da6a3ce929d0e0e4736</code> от gateway до inventory.adapter. Span-id — идентификатор одной операции: у gateway <code>a111…</code>, у catalog <code>b222…</code>. Когда gateway посылает контекст, в parent-id header-а лежит его текущий span-id. Catalog создаёт новый span-id, оставляет trace-id и ставит полученный ID в поле родителя. На следующей границе процедура повторяется уже с ID catalog.'),
paragraph('Такое различение избавляет от двух симметричных дефектов. Если сделать каждый child с новым trace-id, нельзя собрать одну историю. Если оставить один span-id на все сервисы, нельзя отличить работу gateway от работы catalog. Внешний <code>request_id</code> может быть полезен логам, но он не заменяет ни trace-id, ни parent relationship. Его формат и жизнь часто другие; попытка склеить все три понятия делает поиск проще на один день и запутаннее после первой интеграции.'),
figure(
'/assets/editorial/2020/tracing-context-propagation-2020.svg',
'Вертикальная диаграмма: один trace-id остаётся общим, gateway передаёт свой span-id в synthetic traceparent, catalog создаёт child span и затем передаёт уже свой span-id к inventory; у каждого шага показана отдельная граница extract и inject',
'Parent-id в переносимом контексте относится к текущему вызывающему span. Получатель не копирует его как свой span-id, а создаёт новый child.',
),
heading('Parser должен отвергать форму до создания span'),
paragraph('W3C version <code>00</code> ожидает нижний регистр, 32 hex-символа для trace-id и 16 для parent-id; нулевые идентификаторы недопустимы. Это не косметика. Если parser принимает строку с нулями или сокращённый ID и всё равно строит span, следующий waterfall внешне выглядит целым, но связность уже ложная. Если он тихо приводит uppercase к lowercase, он скрывает, откуда пришёл несовместимый carrier. В учебной функции такой header становится ошибкой и останавливает создание child.'),
codeBlock(traceparentCode),
paragraph('Этот код намеренно не знает слово <code>HTTP</code> в runtime. Он не читает реальный request, не ставит response header и не меняет приложение. Carrier представлен строкой, потому что нам нужно проверить формат и переход значений. В конкретной реализации adapter рядом с HTTP client/server будет вызывать аналогичную serialization/extraction логику. Если framework уже делает это сам, сначала читают его документацию и пишут тест на один transport; двойной inject может создать лишние или конфликтующие span.'),
paragraph('В Recommendation trace-flags несут рекомендацию о recording, а не право любому внешнему отправителю включить затратный сбор. Для version <code>00</code> fixture принимает только <code>00</code> и <code>01</code>, но не вводит sampling policy. Решение о доверии входному контексту, лимитах и том, где создавать новый root, остаётся за проектом. Это особенно важно на публичной границе: внешний caller не должен управлять внутренними затратами просто потому, что прислал похожую строку.'),
heading('Parent/child — это причина, а не отступ в JSON'),
paragraph('Parent/child связь отвечает на вопрос «какая операция породила эту работу?». В простой синхронной модели child целиком лежит внутри времени parent. Gateway ждёт catalog, catalog ждёт inventory, inventory ждёт adapter. Эта вложенность даёт дереву порядок. Если pricing и inventory запускаются рядом, они оба могут быть child catalog, но их интервалы перекрываются. Дерево говорит о происхождении, а ось времени — о том, какая ветка фактически продлевает root.'),
paragraph('Для очереди или batch это предположение может быть неверным. Один consumer может обработать несколько сообщений, а один follow-up может жить после ответа исходного HTTP-запроса. Там нельзя притворяться, что у span всегда один честный родитель, который целиком его охватывает. В документации OpenTelemetry для таких связей обсуждаются links, но в сентябре 2020 я не превращаю этот термин в рецепт. Текущий пакет сознательно ограничен одним синхронным учебным деревом, потому что только для него fixture проверяет interval containment.'),
dataTable(
'Что именно означает связь в одном синхронном waterfall',
['Наблюдение', 'Корректный вывод', 'Некорректный вывод', 'Проверка'],
[
['У <code>catalog.lookup</code> parent = gateway', 'catalog вызван в рамках gateway истории', 'catalog сам по себе занял всё время gateway', 'child лежит внутри 0–240 ms root'],
['pricing и inventory имеют одного parent', 'ветви созданы в одном catalog span', 'их duration надо сложить', 'интервалы 30–70 и 30–200 перекрываются'],
['adapter parent = inventory', 'adapter часть inventory операции', '170 ms inventory плюс 70 ms adapter дают 240 ms', 'adapter 100–170 находится внутри inventory'],
['trace-id одинаков', 'span можно читать как одну исторю', 'все нужные границы уже инструментированы', 'посмотреть ожидаемый список span и отрицательный сценарий'],
],
),
heading('Duration — inclusive время, пока не доказано другое'),
paragraph('Duration span — разность его finish и start. У gateway это 240 ms, у catalog 200 ms, у inventory 170 ms, у adapter 70 ms. В обычной трассе duration parent чаще всего inclusive: ожидание дочерних операций уже находится внутри него. Поэтому общий response time не получают сложением всех строк в waterfall. Такое сложение повторно считает одно и то же время и легко превращает 240 ms учебного запроса в воображаемые 680 ms.'),
codeBlock(mechanismCode),
paragraph('В модуле exclusive время считается как интервал span за вычетом объединения прямых child-интервалов. У root остаётся 40 ms вне catalog, у catalog — 30 ms вне child-интервалов, у inventory — 100 ms вне adapter, у adapter — 70 ms. Для выбранной цепочки эти exclusive отрезки дают 240 ms root-а. Это простая арифметика на одном logical clock. Она не делает fixture универсальным алгоритмом critical path для любого trace store и не обещает точность после clock skew или неполного instrumentation.'),
heading('Как fixture выбирает critical path'),
paragraph('В этом дереве root имеет единственного поздно заканчивающегося child — catalog. У catalog позднее заканчивается inventory, а у inventory — adapter. Поэтому fixture выбирает <code>gateway.handle → catalog.lookup → inventory.fetch → inventory.adapter</code>. Pricing заканчивается раньше и идёт параллельно с inventory, так что не определяет момент окончания root. Здесь «critical» означает только: если сократить эту последовательную ветку в модели, root сможет закончиться раньше. Это не синоним «самая дорогая строка» и не назначение виноватого.'),
paragraph('Алгоритм специально прозрачен: он выбирает child с самым поздним <code>endMs</code>, затем считает exclusive вклад выбранных span. Если в реальном trace два child завершаются одновременно, есть links, retries или неполные timestamps, такого правила недостаточно. Нужен другой вопрос: что именно измеряет инструмент, как синхронизированы часы и какие зависимости зафиксированы. Пока ответа нет, лучше показать несколько конкурирующих ветвей, чем поставить жирную стрелку «critical path» без доказательства.'),
figure(
'/assets/editorial/2020/tracing-critical-path-2020.svg',
'Вертикальная схема чтения critical path: parent duration включает дочерний interval, поэтому inventory 170 ms и adapter 70 ms не суммируются; из exclusive сегментов gateway 40, catalog 30, inventory 100 и adapter 70 складывается root 240 ms, а pricing 40 ms остаётся параллельной ветвью',
'Критический путь читается по временной зависимости, а не по сумме всех видимых duration. Схема относится только к controlled fixture с одной шкалой времени.',
),
heading('Маршрут проверки transport boundary'),
orderedList([
'Назвать ровно одну синхронную границу, на которой потеря контекста мешает разбору: gateway → catalog или catalog → inventory.',
'Зафиксировать, кто создаёт root, кто извлекает incoming context и кто создаёт child. Не поручать это одновременно middleware и прикладной функции.',
'Проверить format version 00, lowercase hex, ненулевой trace-id и parent-id до создания child span.',
'Сверить на controlled fixture: trace-id сохраняется, child получает текущий parent, все интервалы положительные.',
'Отдельно решить политику входного недоверенного context и sampling; не использовать один trace-flags как готовое бизнес-правило.',
'Только после этого выполнить изолированный transport test выбранной библиотеки и записать её версию рядом с проверкой.',
]),
heading('Ограничения механизма'),
paragraph('Header не создаёт trace сам по себе. Он может быть корректно передан, а exporter выключен; может существовать exporter, но часть библиотек не создаёт span; может быть выборка, в которой полный trace отсутствует. W3C Recommendation решает interoperability format, а не хранение и полноту. Поэтому в статье отсутствуют synthetic URL, token, реальная HTTP-команда и утверждение, будто доставка header-а уже проверена в какой-либо инфраструктуре.'),
paragraph('Точно так же один child с большим inclusive duration не доказывает причину. Он говорит, что в пределах текущего instrumentation операция жила дольше остальных. Следующий шаг — посмотреть её прямые children, свой exclusive участок и условия завершения. Если общая шкала состоит из часов разных машин, сначала надо проверить timestamp source. Такой порядок сохраняет голос М3: автор уже связывает события между сервисами, но ещё не выдаёт учебное дерево за опыт эксплуатации общей платформы.'),
],
[w3cTraceContext2020, otelSpecV05, otelTraceApiHistory],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2020-09-field-tracing-basics',
title: 'Разбор учебной трассы: как прочитать duration и critical path',
categories: ['Наблюдаемость', 'Отладка', 'Производительность'],
cover: '/assets/editorial/2020/tracing-critical-path-2020.svg',
excerpt: 'Пошаговый разбор одного синтетического waterfall: проверяем trace context, parent/child span, duration и critical path без фальшивого production-инцидента.',
readingMinutes: 15,
},
[
paragraph('Симптом полевого разбора звучит так: «один запрос выглядит долгим, но каждая команда называет другой участок». Цена поспешной реакции — оптимизировать pricing, потому что его строка заметна, или увеличить timeout inventory, потому что он самый длинный. Оба действия могут ничего не изменить, если не доказано, как span связаны во времени и какая ветка удерживает ответ до конца.'),
paragraph('Ниже нет production-инцидента и реальных latency. Это разбор controlled fixture из revision-модуля: пять span на одной логической шкале 0–240 ms. Именно ограничение делает вывод проверяемым. Мы можем увидеть trace-id, проверить parent/child и посчитать exclusive отрезки. Мы не можем из этой модели объявить, что любой inventory сервис медленный, что в системе есть service map или что collector уже получает такой export.'),
heading('Фиксируем исходные данные до диагноза'),
paragraph(syntheticNotice),
paragraph('Fixture строит root <code>gateway.handle</code> от 0 до 240 ms. Его child <code>catalog.lookup</code> занимает 20–220 ms. У catalog два child: <code>pricing.read</code> от 30 до 70 ms и <code>inventory.fetch</code> от 30 до 200 ms. У inventory есть <code>inventory.adapter</code> от 100 до 170 ms. Все пять span имеют один trace-id; каждый non-root span ссылается на существующий parent; каждый child полностью лежит внутри родительского interval. Это наш вход, а не уже найденная причина.'),
dataTable(
'Синтетический export одного учебного trace',
['Span', 'Parent', 'Интервал на общей шкале', 'Duration', 'Что можно заключить'],
[
['<code>gateway.handle</code>', 'root', '<code>0–240 ms</code>', '<code>240 ms</code>', 'конец-to-конец duration fixture; включает время catalog'],
['<code>catalog.lookup</code>', '<code>gateway.handle</code>', '<code>20–220 ms</code>', '<code>200 ms</code>', 'вызван из gateway; не равен отдельным 200 ms после gateway'],
['<code>pricing.read</code>', '<code>catalog.lookup</code>', '<code>30–70 ms</code>', '<code>40 ms</code>', 'короткая ветвь, которая завершается до inventory'],
['<code>inventory.fetch</code>', '<code>catalog.lookup</code>', '<code>30–200 ms</code>', '<code>170 ms</code>', 'длинная sibling-ветвь pricing; включает adapter'],
['<code>inventory.adapter</code>', '<code>inventory.fetch</code>', '<code>100–170 ms</code>', '<code>70 ms</code>', 'вложенная операция; её duration уже входит в inventory'],
],
),
heading('Сначала проверяем связность, потом смотрим длительность'),
paragraph('Первый вопрос не «кто медленный?», а «это один trace?». Ответ в модели двойной: trace-id совпадает у всех записей, а parent-id образует одно дерево. Внешний header fixture создаётся из span gateway и затем разбирается обратно. Catalog получает этот span-id как parent. Такая проверка не доказывает транспорт, но отделяет проблему контекста от проблемы duration. Если trace-id расходится, дальнейшая арифметика бессмысленна: мы сравниваем несколько историй, а не одну.'),
paragraph('Второй вопрос — «можно ли сравнить время?». Здесь да, потому что fixture использует одну logical clock и проверяет containment. В настоящем распределённом процессе timestamp могут брать разные хосты, а часы расходятся. Тогда визуальное перекрытие может быть свойством clock skew, а не параллельности. Не надо прятать эту границу под графиком. Перед тем как спорить о десяти миллисекундах между сервисами, нужно знать источник времени и отдельно проверить его в выбранной среде.'),
codeBlock(fixtureCommandCode),
paragraph('Команда выводит структуру, а boolean assertions делают договор явным. <code>traceparentRoundTrip</code> и <code>childReceivedCurrentParent</code> говорят о context. <code>spanDurationIsEndMinusStart</code> говорит о корректной форме интервалов. <code>criticalPathIsExpected</code> и <code>criticalPathExclusiveTimeMatchesRoot</code> относятся к именно этой модели. Если поменять parent inventory или сделает adapter длиннее своего parent, fixture остановится. Это лучше, чем исправить SVG вручную и потерять смысл примера.'),
heading('Рисуем waterfall так, чтобы увидеть параллельность'),
figure(
'/assets/editorial/2020/tracing-span-waterfall-2020.svg',
'Waterfall учебного trace на шкале 0–240 ms: root gateway охватывает catalog, внутри catalog pricing 30–70 ms перекрывается с inventory 30–200 ms, а adapter 100–170 ms расположен внутри inventory; выделена последовательность до последнего завершения',
'Главное наблюдение на схеме — overlap. Pricing не добавляется после inventory: оба span стартуют в 30 ms и относятся к одному catalog.',
),
paragraph('Схема сразу останавливает ошибку «сложим все duration». Нельзя взять 240 + 200 + 40 + 170 + 70 и назвать результатом 720 ms: каждый child лежит внутри ancestor. Нельзя также вычеркнуть catalog, потому что в нём «нет собственной работы»: у него остаются отрезки 20–30 и 200–220 ms, а его child задают порядок пути. Правильный результат для fixture — 240 ms root. Остальные duration нужны, чтобы разложить эти 240, а не увеличить их.'),
dataTable(
'Чтение waterfall: факт, риск интерпретации, действие',
['Факт в trace', 'Неверный диагноз', 'Почему он неверен', 'Следующая проверка'],
[
['inventory длится <code>170 ms</code>', '«adapter добавил ещё 70 ms сверху»', 'adapter уже вложен в interval inventory', 'сравнить exclusive inventory и duration adapter'],
['pricing длится <code>40 ms</code>', '«pricing и inventory надо сложить»', 'они перекрываются с 30 по 70 ms', 'проверить, какая ветвь заканчивается последней'],
['catalog заканчивается в <code>220 ms</code>', '«catalog — единственная причина 240 ms»', 'root имеет 40 ms вне catalog', 'посмотреть root exclusive segments'],
['один trace-id у всех span', '«все реальные границы покрыты»', 'fixture заранее перечисляет только пять span', 'сверить ожидаемый список границ в отдельном transport test'],
],
),
heading('Ищем critical path без двойного счёта'),
paragraph('В controlled fixture critical path выбирается не по максимальному числу в таблице, а по последовательности, которая заканчивается последней: <code>gateway.handle → catalog.lookup → inventory.fetch → inventory.adapter</code>. У catalog pricing завершается в 70 ms, а inventory — в 200 ms, поэтому именно inventory удерживает его до финала. У inventory adapter завершает работу в 170 ms, поэтому он остаётся внутри выбранной последовательности. Это учебная модель одной causal tree, не общий алгоритм для любого backend-а.'),
paragraph('Чтобы проверить сумму, fixture вычисляет exclusive время. Root имеет 40 ms вне catalog. Catalog имеет 30 ms вне объединения pricing/inventory. Inventory имеет 100 ms вне adapter. Adapter имеет собственные 70 ms. Получаем <code>40 + 30 + 100 + 70 = 240 ms</code>. Pricing не входит в эту сумму как отдельная последовательная задержка: его 40 ms перекрыты inventory. Теперь можно сказать не «inventory виноват», а точнее: «в учебном дереве поздняя ветвь проходит через inventory; дальше надо проверить его прямой adapter и его own segment».') ,
figure(
'/assets/editorial/2020/tracing-critical-path-2020.svg',
'Схема critical path для учебного trace: gateway 40 ms exclusive, catalog 30 ms exclusive, inventory 100 ms exclusive и adapter 70 ms exclusive образуют 240 ms root; pricing 40 ms показан параллельной ветвью и не складывается с inventory',
'Exclusive арифметика здесь нужна только как проверка разложения одного root duration. Она не превращает контролируемый пример в производственное измерение.',
),
heading('Header объясняет, почему это один trace'),
paragraph('Waterfall не возникает из названий span. Ему нужна корректная цепочка контекста. W3C Recommendation 2020 задаёт <code>traceparent</code> с version <code>00</code>, trace-id, parent-id и trace-flags. В fixture gateway serializes свой span-id как parent-id; catalog parses эту строку, создаёт child и сохраняет trace-id. Header ниже синтетический; он не отправляется по HTTP, не взят из access log и не содержит данные пользователя.'),
codeBlock(traceparentCode),
paragraph('Это также объясняет предел разбора. Если catalog сам создаст новый root, то inventory может быть медленным, но мы не докажем связь с gateway из одного trace. Если header повреждён, parser должен отказаться от этой формы, а не сделать вид, что parent известен. Если всё-таки нужен новый root на границе доверия, связь можно проектировать отдельно; нельзя подменять этот выбор нечаянным форматом ID. Проверка context первична, потому что без неё duration остаются просто числами на разных листах.'),
heading('Маршрут разбора одного подозрительного пути'),
orderedList([
'Сформулировать симптом и цену без причины: «лог одной операции есть, но путь до ответа не виден; неверная правка timeout увеличит стоимость следующего сбоя».',
'Собрать только один trace и выписать его span с trace-id, span-id, parent-id, start и end. Сначала проверить одну logical history.',
'Проверить format incoming context: version 00, ненулевые lowercase IDs и создание нового child span, а не повторный ID родителя.',
'Построить waterfall на общей шкале. Отметить overlap, не складывать inclusive parent duration с child duration.',
'Найти child, который завершает parent последним, и разложить выбранную цепочку на exclusive сегменты только там, где модель это допускает.',
'Выбрать одну следующую проверку: transport propagation, прямой adapter или источник timestamp. Не объявлять виновника до этой проверки.',
]),
heading('Исторические и технические ограничения'),
paragraph('Trace Context Level 1 к сентябрю 2020 уже был Recommendation, но это стандарт переносимого контекста, а не соглашение о том, какие span автоматически создаст каждый framework. OpenTelemetry в этот период нельзя описывать как полностью стабильный tracing stack: историческая версия спецификации была до 1.0, а стабильность Trace API относится к началу 2021. Поэтому в пакете нет реальной collector-конфигурации и pretend-export в OTLP. Синтетический JSON fixture — удобный объект для проверки связей, а не формат, который обещает принять внешний backend.'),
paragraph('Critical path здесь зависит от трёх предпосылок: одно дерево, вложенные интервалы и общая шкала времени. Нарушить любую из них легко: asynchronous job может жить после root, batch может иметь несколько причин, а часы двух машин могут расходиться. Тогда честный результат разбора — «не хватает модели», а не выделенная красная полоса. Это и есть полезное развитие автора М3: он учится связывать события через сервисные границы, но оставляет границы метода рядом с выводом.'),
],
[w3cTraceContext2020, otelSpecV05, otelTraceApiHistory],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
const fixture = runTracingFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
throw new Error('Tracing fixture assertions failed');
}
process.stdout.write(JSON.stringify(fixture, null, 2) + '\n');
}