diff --git a/editorial/production/README.md b/editorial/production/README.md
index 5d897b1..c59f68f 100644
--- a/editorial/production/README.md
+++ b/editorial/production/README.md
@@ -1,6 +1,6 @@
# Производство редакционных партий
-На 31 июля 2026 года строгий аудит проходит 94 из 358 созданных материалов. Остальные 264 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
+На 31 июля 2026 года строгий аудит проходит 97 из 358 созданных материалов. Остальные 261 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
diff --git a/editorial/reviews/2020-09-draft.md b/editorial/reviews/2020-09-draft.md
new file mode 100644
index 0000000..568846f
--- /dev/null
+++ b/editorial/reviews/2020-09-draft.md
@@ -0,0 +1,165 @@
+# Сентябрь 2020 — тройное ревью автономного пакета P31 «Трассировка запроса»
+
+Статус: **принят независимым редактором в выпусковой набор**. В revision нет
+date и author; слой публикации сохраняет их из
+базового архива. Автономный авторский пакет не менял registry,
+articles.json, стандарт, очередь, package configuration или Git.
+
+Проверенные revision:
+
+- editorial-2020-09-practice-tracing-basics;
+- editorial-2020-09-mechanism-tracing-basics;
+- editorial-2020-09-field-tracing-basics.
+
+Модуль экспортирует ровно три revision. При --print-revisions
+stdout содержит только JSON. При --verify-fixture запускается
+только in-memory модель одного synthetic trace; она не открывает сеть, не
+читает production-журнал и не вызывает collector.
+
+## Проход 1. Факты, историческая рамка и механизм — пройдено
+
+| Утверждение или решение | Первичный / официальный источник | Зафиксированная граница |
+| --- | --- | --- |
+| Trace Context Level 1 — 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 traceparent, а не «готовую трассировочную платформу». |
+| Для version 00traceparent несёт 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 принимает только 00, lowercase hex, ненулевые ID и 00/01 flags; он не притворяется generic parser-ом будущих версий. |
+| В 2020 OpenTelemetry-specification ещё была до 1.0; tag v0.5.0 датирован 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;
+- catalog.lookup получает gateway как parent, а
+ inventory.fetch — catalog;
+- duration считается только как endMs - startMs;
+- child intervals полностью лежат внутри parent intervals в пределах fixture;
+- pricing и inventory перекрываются, поэтому их duration не суммируются;
+- выбранный fixture critical path —
+ gateway.handle → catalog.lookup → inventory.fetch → inventory.adapter;
+- exclusive segments выбранной цепочки равны 40 + 30 + 100 + 70 = 240 ms, то есть root duration;
+- invalid all-zero trace-id и зарезервированный 09 flags отвергаются.
+
+Выполнен --verify-fixture. Реальный результат содержит восемь
+истинных assertions: round-trip traceparent, получение текущего
+parent child-операцией, общий trace-id, положительные intervals, ожидаемый
+critical path, совпадение exclusive суммы с root и два негативных случая.
+Fixture использует только synthetic IDs, services, интервалы и header:
+training-gateway, training-catalog,
+training-inventory и
+00-4bf92f3577b34da6a3ce929d0e0e4736-a111111111111111-01.
+
+Вердикт прохода: **пройден**. Поправлена дополнительная техническая граница:
+version 00 fixture больше не принимает произвольный двухсимвольный
+flags byte; 01 остаётся примером 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 смысловых разделов, две таблицы с
+ caption/thead, две figure с осмысленными
+ alt/figcaption, два code/example блока,
+ нумерованный маршрут и три официальные или первичные ссылки.
+- Структура держит краткую последовательность «симптом → причина → проверка
+ → действие». Примеры не подменяют проверку общими словами о важности
+ наблюдаемости.
+- В текст не внесены современные обещания: нет production latency, реального
+ trace или URL, сервиса карт, стабильного tracing platform, готовой
+ collector-конфигурации, автоматического instrumentor-а или наблюдаемого
+ инцидента пользователя.
+- Используется исторически подходящий словарь М3: trace context, span,
+ transport boundary, fixture, overlap и clock skew раскрываются рядом с
+ конкретным действием. Автор учится связать события между сервисами, но не
+ пишет от лица владельца зрелой платформы.
+
+Вердикт прохода: **пройден**. Тексты сохраняют техническую плотность и
+соответствуют сентябрю 2020 года без анахронизмов.
+
+## Проход 3. Визуал, доступность и выпуск автономного пакета — пройдено в заданных границах
+
+- tracing-span-waterfall-2020.svg показывает пять intervals на
+ шкале 0–240 ms, overlap pricing/inventory и выделенную позднюю цепочку.
+- tracing-context-propagation-2020.svg показывает отдельные
+ шаги inject/extract, неизменность trace-id и смену текущего span-id без
+ имитации сетевого трафика.
+- tracing-critical-path-2020.svg показывает разницу inclusive и
+ exclusive duration, параллельный pricing и расчёт 240 ms.
+- У каждого SVG есть title, desc,
+ role="img", вертикальный viewBox, контрастные карточки и
+ короткие подписи. Нет JavaScript, foreignObject, внешних URL,
+ data URI или пользовательских данных.
+- Все три SVG отрендерены Sharp при ширине **375 px** и просмотрены вручную.
+ Текст, bars и нижние карточки читаемы; обрезания, наложения и горизонтальный
+ overflow внутри SVG не обнаружены. Waterfall оставлен вертикальным именно
+ для этой ширины.
+
+### Фактические команды и результаты
+
+
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
+
+| Проверка | Реальный результат |
+| --- | --- |
+| node --check | код завершения 0 |
+| Draft audit | PASS: 10 269 / 11 727 / 9 702 знаков тела |
+| In-memory fixture | 8 assertions вернули true; root duration и selected exclusive path равны 240 ms |
+| XML | все три SVG валидны, код завершения 0 |
+| 375 px visual preflight | выполнен через локальный Sharp-render и ручной просмотр; SVG читаемы, без clipping/overflow |
+| Scope/self-review | revision не переопределяет date/author; в работе изменены только пять разрешённых файлов 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 к
+web/data/editorial-revisions.mjs, не меняя базовый
+articles.json, даты или автора архивных записей. В 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 года сверена независимо по первичному
+тексту: она действительно описывает перенос контекста через
+traceparent, но не обещает готовую tracing-платформу. В отчёте не
+утверждается запуск реального HTTP, proxy, OpenTelemetry SDK/collector,
+browser или assistive technology.
+
+Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной
+публикационной операцией; Git остаётся источником её фактической записи.
diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs
index 7110594..8f9a7f2 100644
--- a/web/data/editorial-revisions.mjs
+++ b/web/data/editorial-revisions.mjs
@@ -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,
];
diff --git a/web/public/assets/editorial/2020/tracing-context-propagation-2020.svg b/web/public/assets/editorial/2020/tracing-context-propagation-2020.svg
new file mode 100644
index 0000000..d23963e
--- /dev/null
+++ b/web/public/assets/editorial/2020/tracing-context-propagation-2020.svg
@@ -0,0 +1,55 @@
+
diff --git a/web/public/assets/editorial/2020/tracing-critical-path-2020.svg b/web/public/assets/editorial/2020/tracing-critical-path-2020.svg
new file mode 100644
index 0000000..00c77e0
--- /dev/null
+++ b/web/public/assets/editorial/2020/tracing-critical-path-2020.svg
@@ -0,0 +1,60 @@
+
diff --git a/web/public/assets/editorial/2020/tracing-span-waterfall-2020.svg b/web/public/assets/editorial/2020/tracing-span-waterfall-2020.svg
new file mode 100644
index 0000000..b883389
--- /dev/null
+++ b/web/public/assets/editorial/2020/tracing-span-waterfall-2020.svg
@@ -0,0 +1,68 @@
+
diff --git a/web/scripts/upgrade-2020-09.mjs b/web/scripts/upgrade-2020-09.mjs
new file mode 100644
index 0000000..1fbdc8e
--- /dev/null
+++ b/web/scripts/upgrade-2020-09.mjs
@@ -0,0 +1,581 @@
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function paragraph(text) {
+ return '
[\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('Сначала важно не смешать два слоя. Trace Context Level 1 уже не был черновиком: W3C опубликовал Recommendation 6 февраля 2020 года. Она описывает переносимый HTTP-представитель контекста traceparent и отдельный tracestate для данных конкретного поставщика. Это даёт общий формат передачи, но не рисует service map и не выбирает за нас storage, sampling, exporter или интерфейс разбора.'),
+ paragraph('OpenTelemetry в этот момент полезно воспринимать как развивающуюся спецификацию и набор ранних реализаций, а не как стабильную кнопку «добавить наблюдаемость». Исторический tag v0.5.0 относится к июню 2020 года, а стабильность Trace API официальная документация позже относит к началу 2021-го. Поэтому ниже нет version-specific конфигурации collector-а, auto-instrumentation или обещания, что один пакет одинаково решит браузер, Node и каждую библиотеку. Есть минимальный контракт, который можно проверить отдельно.'),
+ dataTable(
+ 'Границы учебного контракта трассировки',
+ ['Объект', 'Что означает в примере', 'Что не следует в него класть', 'Как проверяем'],
+ [
+ ['traceId', 'одна логическая история из пяти span', 'пользователя, email, URL с параметрами', 'все span имеют одинаковое 32-hex значение'],
+ ['spanId', 'одна операция внутри истории', 'название endpoint или текст ошибки', 'каждый ID уникален и состоит из 16 hex-символов'],
+ ['parentSpanId', 'прямая причина появления child span', 'ID далёкого предка или произвольный request_id', 'parent существует и охватывает child по времени'],
+ ['duration', 'разность учебных endMs - startMs', 'обещание latency на другой машине', 'конец строго больше начала'],
+ ['traceparent', 'перенос trace-id и текущего parent span через одну границу', 'секрет, авторизационный заголовок или полный объект запроса', 'parser принимает только version 00 и валидные ID'],
+ ],
+ ),
+ heading('Один trace, а не набор красивых строк'),
+ paragraph('Trace — это не ещё один вид лога. В учебной модели это дерево операций с общей причиной: пользовательское действие попало в gateway, gateway попросил catalog, а catalog вызвал inventory. Span — одна операция с именем, началом, концом и связью с родителем. Если gateway начал gateway.handle, а catalog создал новый случайный trace-id, дерево уже разорвано. В журнале могут остаться две хорошие строки, но вопрос «какой downstream блокировал исходный запрос?» останется без ответа.'),
+ paragraph('Контекст нужен именно на границе. Внутри одной функции можно передать объект аргументом, но после транспорта соседний процесс не знает текущий span автоматически. В W3C version 00 поле traceparent состоит из четырёх частей: 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('Вызов createTrainingTraceparent() не открывает сеть. Он возвращает строку, которую сразу разбирает parseTrainingTraceparent(). Этот узкий round-trip ловит две разные ошибки. Первая — формат: случайно передали ID неправильной длины, верхний регистр или нулевое значение. Вторая — смысл: child span получил родителя не из извлечённого контекста. Если присутствует только первая проверка, можно получить валидную строку, которая связывает не те операции.'),
+ paragraph('Я намеренно не добавляю сюда tracestate. W3C оставляет его для данных конкретной tracing-системы; в учебном упражнении нет такого владельца. Запись «на будущее» без того, кто её читает, превращается в неподтверждённый vendor contract. То же относится к baggage, произвольным полям пользователя и полным URL. Контекст должен связывать работу, а не становиться обходным каналом для данных, которые нельзя проверить или безопасно хранить.'),
+ heading('Собираем waterfall с одним логическим временем'),
+ paragraph('После того как связь существует, можно смотреть duration. В fixture пять интервалов на одной шкале: gateway.handle длится от 0 до 240 ms; catalog.lookup лежит внутри него от 20 до 220 ms; pricing.read занимает 30–70 ms; inventory.fetch — 30–200 ms; его adapter — 100–170 ms. Значения выбраны для объяснения и не являются latency, которую кто-то измерил.'),
+ paragraph('Здесь легко ошибиться с арифметикой. Duration parent span включает время его child span. Поэтому inventory.fetch = 170 ms и inventory.adapter = 70 ms нельзя сложить и назвать 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, не расширяя его смысл',
+ ['Проверка', 'Что подтверждает', 'Чего не подтверждает', 'Следующее действие'],
+ [
+ ['traceparentRoundTrip', 'строка version 00 сохраняет trace-id и текущий span-id', 'что header дошёл через реальный reverse proxy', 'добавить изолированный transport test в конкретном сервисе'],
+ ['childReceivedCurrentParent', 'catalog в модели дочерний к gateway', 'что все framework middleware создают правильные span', 'проверить одну входящую и одну исходящую границу'],
+ ['criticalPathIsExpected', 'fixture выбрал поздно завершающуюся цепочку', 'что это единственный bottleneck в production', 'проверить инструментирование выбранного участка'],
+ ['invalidHeaderRejected', 'нулевой 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 не сменился.',
+ 'Запустить --verify-fixture; если 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. Проверка начинается с одной строки traceparent и одной пары parent/child, а действие — с того места, где сериализация и извлечение принадлежат владельцу transport boundary. Не с покупки платформы и не с глобального middleware.'),
+ heading('Какой стандарт был доступен на дату статьи'),
+ paragraph('На дату сентября 2020 года нужно говорить точно. Trace Context Level 1 стал W3C Recommendation 06 February 2020, поэтому называть сам traceparent «черновым заголовком» неверно. Документ определяет стандартные HTTP headers и format для передачи контекста между сервисами. Он не предписывает vendor, storage или единый интерфейс поиска trace. tracestate существует как опциональное расширение поставщика, но не нужен, чтобы в первом упражнении сохранить parent/child связь.'),
+ paragraph('Совсем другой статус имели OpenTelemetry API, SDK и конкретные интеграции. Исторический release v0.5.0 спецификации датирован июнем 2020 года; стабильность Trace API официальный проект относит к началу 2021-го. Поэтому не буду приписывать сентябрю 2020 готовую экосистему со стабильной автоинструментацией, collector-конфигурацией и сервисной картой. В коде используются нейтральные функции parseTrainingTraceparent и createTrainingTraceparent, чтобы объяснить сам договор, а не API конкретной библиотеки.'),
+ dataTable(
+ 'Четыре поля W3C traceparent version 00 в учебной модели',
+ ['Поле', 'Форма в Recommendation', 'Роль на границе', 'Проверка в модуле'],
+ [
+ ['version', '00 для рассматриваемого format', 'говорит parser-у, как читать следующие части', 'принимается только ровно 00; future version не угадывается'],
+ ['trace-id', '32 lowercase hex, не все нули', 'собирает один logical trace', 'одинаков у всех пяти synthetic span'],
+ ['parent-id', '16 lowercase hex, не все нули', 'указывает на текущую операцию вызывающей стороны', 'catalog получает его как parentSpanId'],
+ ['trace-flags', '00 или 01 в учебной version 00', 'несёт флаг контекста; не является командой доверять любому клиенту', 'fixture принимает только документированные значения и не превращает их в policy sampling'],
+ ],
+ ),
+ heading('Trace-id отвечает не на тот же вопрос, что span-id'),
+ paragraph('Trace-id — идентификатор всей логической истории. В нашем примере он остаётся 4bf92f3577b34da6a3ce929d0e0e4736 от gateway до inventory.adapter. Span-id — идентификатор одной операции: у gateway a111…, у catalog b222…. Когда gateway посылает контекст, в parent-id header-а лежит его текущий span-id. Catalog создаёт новый span-id, оставляет trace-id и ставит полученный ID в поле родителя. На следующей границе процедура повторяется уже с ID catalog.'),
+ paragraph('Такое различение избавляет от двух симметричных дефектов. Если сделать каждый child с новым trace-id, нельзя собрать одну историю. Если оставить один span-id на все сервисы, нельзя отличить работу gateway от работы catalog. Внешний request_id может быть полезен логам, но он не заменяет ни 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 00 ожидает нижний регистр, 32 hex-символа для trace-id и 16 для parent-id; нулевые идентификаторы недопустимы. Это не косметика. Если parser принимает строку с нулями или сокращённый ID и всё равно строит span, следующий waterfall внешне выглядит целым, но связность уже ложная. Если он тихо приводит uppercase к lowercase, он скрывает, откуда пришёл несовместимый carrier. В учебной функции такой header становится ошибкой и останавливает создание child.'),
+ codeBlock(traceparentCode),
+ paragraph('Этот код намеренно не знает слово HTTP в runtime. Он не читает реальный request, не ставит response header и не меняет приложение. Carrier представлен строкой, потому что нам нужно проверить формат и переход значений. В конкретной реализации adapter рядом с HTTP client/server будет вызывать аналогичную serialization/extraction логику. Если framework уже делает это сам, сначала читают его документацию и пишут тест на один transport; двойной inject может создать лишние или конфликтующие span.'),
+ paragraph('В Recommendation trace-flags несут рекомендацию о recording, а не право любому внешнему отправителю включить затратный сбор. Для version 00 fixture принимает только 00 и 01, но не вводит 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',
+ ['Наблюдение', 'Корректный вывод', 'Некорректный вывод', 'Проверка'],
+ [
+ ['У catalog.lookup 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 выбирает gateway.handle → catalog.lookup → inventory.fetch → inventory.adapter. Pricing заканчивается раньше и идёт параллельно с inventory, так что не определяет момент окончания root. Здесь «critical» означает только: если сократить эту последовательную ветку в модели, root сможет закончиться раньше. Это не синоним «самая дорогая строка» и не назначение виноватого.'),
+ paragraph('Алгоритм специально прозрачен: он выбирает child с самым поздним endMs, затем считает 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 gateway.handle от 0 до 240 ms. Его child catalog.lookup занимает 20–220 ms. У catalog два child: pricing.read от 30 до 70 ms и inventory.fetch от 30 до 200 ms. У inventory есть inventory.adapter от 100 до 170 ms. Все пять span имеют один trace-id; каждый non-root span ссылается на существующий parent; каждый child полностью лежит внутри родительского interval. Это наш вход, а не уже найденная причина.'),
+ dataTable(
+ 'Синтетический export одного учебного trace',
+ ['Span', 'Parent', 'Интервал на общей шкале', 'Duration', 'Что можно заключить'],
+ [
+ ['gateway.handle', 'root', '0–240 ms', '240 ms', 'конец-to-конец duration fixture; включает время catalog'],
+ ['catalog.lookup', 'gateway.handle', '20–220 ms', '200 ms', 'вызван из gateway; не равен отдельным 200 ms после gateway'],
+ ['pricing.read', 'catalog.lookup', '30–70 ms', '40 ms', 'короткая ветвь, которая завершается до inventory'],
+ ['inventory.fetch', 'catalog.lookup', '30–200 ms', '170 ms', 'длинная sibling-ветвь pricing; включает adapter'],
+ ['inventory.adapter', 'inventory.fetch', '100–170 ms', '70 ms', 'вложенная операция; её 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 делают договор явным. traceparentRoundTrip и childReceivedCurrentParent говорят о context. spanDurationIsEndMinusStart говорит о корректной форме интервалов. criticalPathIsExpected и criticalPathExclusiveTimeMatchesRoot относятся к именно этой модели. Если поменять 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 длится 170 ms', '«adapter добавил ещё 70 ms сверху»', 'adapter уже вложен в interval inventory', 'сравнить exclusive inventory и duration adapter'],
+ ['pricing длится 40 ms', '«pricing и inventory надо сложить»', 'они перекрываются с 30 по 70 ms', 'проверить, какая ветвь заканчивается последней'],
+ ['catalog заканчивается в 220 ms', '«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 выбирается не по максимальному числу в таблице, а по последовательности, которая заканчивается последней: gateway.handle → catalog.lookup → inventory.fetch → inventory.adapter. У 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. Получаем 40 + 30 + 100 + 70 = 240 ms. 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 задаёт traceparent с version 00, 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');
+}