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'); }