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