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 00 traceparent несёт 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 @@ + + Передача учебного trace context через две границы + Вертикальная схема показывает, как gateway создаёт span A, передаёт traceparent с общим trace-id и своим span-id, catalog создаёт child span B, а затем передаёт свой span-id к inventory. Trace-id остаётся общим. + + + Trace context на границе + Учебная version 00; без реального HTTP + + + + 1. gateway создаёт span A + trace-id: общий для истории + span-id: A · parent: нет + + + + + + inject: короткий carrier + traceparent: 00-trace-id-span-A-01 + trace-id не меняется + parent-id — текущий span вызывающей стороны + + + + + + + 2. catalog делает extract + создаёт span B с parent = A + trace-id остаётся тем же + + + + + + + 3. inventory получает span B + новый child получит parent = B + меняется span-id, не trace-id + + Не передаём URL, пользователя, token или body + 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 @@ + + Как читать critical path в учебной трассе + Вертикальная схема показывает, что duration родительского span включает дочерние интервалы. Критический путь состоит из exclusive сегментов gateway, catalog, inventory и adapter и даёт 240 миллисекунд. Pricing остаётся параллельной ветвью. + + + Critical path без двойного счёта + Только controlled fixture с одной шкалой + + + 1. Duration parent — inclusive + inventory: 170 ms + adapter: 70 ms внутри inventory + + + + + + 2. Не складываем 170 + 70 + интервалы пересекаются + иначе одно ожидание посчитано дважды + + + + + + 3. Выбираем позднюю цепочку + + gateway exclusive · 40 ms + + catalog exclusive · 30 ms + + inventory exclusive · 100 ms + + adapter exclusive · 70 ms + + + Pricing: параллельная ветвь + + 40 ms + не продлевает финал root + + + + + + 40 + 30 + 100 + 70 = 240 ms + совпадает с root duration fixture + 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 @@ + + Waterfall одного синтетического trace на шкале 240 миллисекунд + Вертикальная диаграмма показывает gateway, catalog, pricing, inventory и adapter. Pricing перекрывается с inventory. Критическая цепочка gateway, catalog, inventory и adapter выделена синим. + + + Один учебный waterfall + Синтетический trace; не production latency + + Root duration: 240 ms · одна logical clock + + + + + + + + 0 + 60 + 120 + 180 + 240 ms + + gateway.handle + 0–240 ms + + 240 ms · root + + + catalog.lookup + 20–220 ms + + 200 ms · child gateway + + + pricing.read + 30–70 ms + + 40 + параллельная ветвь + + inventory.fetch + 30–200 ms + + 170 ms · позднее завершение + + + inventory.adapter + 100–170 ms + + 70 ms + + + Критический путь: gateway → catalog → inventory → adapter + 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 '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(lines.join('\n')) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function dataTable(caption, headers, rows) { + const captionHtml = '' + escapeHtml(caption) + ''; + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + captionHtml + head + body + '
'; +} + +function sourceList(items) { + return ''; +} + +function visibleText(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function proseText(html) { + return visibleText( + html + .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
+      .replace(/
[\s\S]*?<\/figure>/g, '') + .replace(/
[\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'); +}