function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } const p = (text) => '
' + text + '
'; const h2 = (text) => '' + escapeHtml(text) + '';
const ol = (items) => '| ' + cell + ' | ').join('') + '
|---|
| ' + cell + ' | ').join('') + '
request_id, trace_id, route, status и duration_ms, а затем проверить один отказ по маршруту выше. Критерий готовности простой: другой инженер по конверту симптома понимает, какой запрос искать, где искать и какое наблюдение изменит решение.'),
], [
{ key: 'http', use: 'Определение семантики статусов 502, 504, 401 и 403.', boundary: 'RFC описывает HTTP-обмен, но не знает топологию конкретного приложения и не устанавливает его причину отказа.' },
{ key: 'trace', use: 'Правила полей traceparent и переноса контекста между HTTP-границами.', boundary: 'Стандарт помогает связать контекст, но не гарантирует, что каждый сервис записал span или что связь доказывает причинность.' },
{ key: 'nistLogs', use: 'Практика управления журналами: содержимое, время, источник и пригодность записи для анализа.', boundary: 'Руководство не является журналом приложения и не даёт данных о конкретном инциденте.' },
]);
const mechanism = revision({
slug: 'editorial-2027-01-mechanism-debugging-decade',
title: 'Trace ID связывает события, но не доказывает причину',
categories: ['Архитектура', 'Наблюдаемость'],
cover: '/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg',
excerpt: 'Как читать trace, log и metric вместе и не превращать совпадение идентификатора в причинный вывод.',
readingMinutes: 16,
}, [
p('Проблема распределённой диагностики выглядит убедительно: в двух журналах найден один trace ID, рядом стоят одинаковые timestamps, а один span заметно длиннее остальных. Из этого легко сделать вывод, что найден виновник. Цена ошибки — неверный rollback или оптимизация не того участка. В распределённом маршруте идентификатор говорит «эти записи относятся к одному контексту», но не говорит «эта запись вызвала задержку». Между двумя утверждениями есть несколько проверок.'),
p('Механизм нужно разделить на три слоя. Log содержит сообщение и локальное состояние процесса. Span описывает операцию и её границы во времени. Metric агрегирует множество наблюдений и теряет часть контекста. Смешать эти формы — значит использовать ответ одного инструмента для вопроса другого. Хороший разбор сначала проверяет, что записи относятся к одному trace, затем — что parent/child связи и интервалы совместимы, и только после этого формулирует ограниченный вывод.'),
h2('Что гарантирует идентификатор'),
p('W3C Trace Context стандартизирует HTTP-заголовок traceparent и формат идентификаторов. Это полезный транспортный контракт: сервис может продолжить контекст, а оператор — искать его в нескольких компонентах. Но стандарт не требует, чтобы все внутренние работы были представлены span-ами. Библиотека может не создать span для очереди, фона или локального cache. Отсутствие записи значит «в этом источнике её нет», а не «операции не было».'),
p('У trace есть и временная граница. Parent span может завершиться до того, как дочерняя работа закончилась, если связь отражает асинхронную передачу. Два span-а в одном trace могут быть соседями в маршруте, но не причиной друг друга. Поэтому при чтении надо видеть имя операции, service.name, parentSpanId, start и end, а не только цветную линию в интерфейсе.'),
figure('/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg', 'Матрица различий между логом, span, метрикой и корреляционным ключом: форма записи, полезный вопрос и недопустимый вывод.', 'Схема отделяет связь событий от причинности. Она показывает, какой дополнительный контекст нужен до технического решения.'),
table('Какой инструмент отвечает на какой вопрос', ['Источник', 'Сильная сторона', 'Проверить рядом', 'Не заключать автоматически'], [
['Log', 'локальное сообщение и состояние', 'время, источник, schema, request-id', 'что сообщение объясняет весь маршрут'],
['Span', 'граница операции и длительность', 'parent, kind, status, attributes', 'что самый длинный span вызвал всё'],
['Metric', 'частота и распределение', 'окно, population, labels', 'что агрегат указывает на один запрос'],
['Trace ID', 'поиск общего контекста', 'пропагация и sampling', 'что цепочка полна и причинна'],
]),
h2('Локальный граф связей'),
p('Ниже функция строит минимальное представление родительских связей по массиву span-ов. Она отмечает root, найденного parent и missing parent. Это предметный пример: результат помогает увидеть разрыв контекста в конкретной цепочке. Он не рисует красивый trace и не назначает виновника. Входы — именно те поля, которые должны быть сохранены при экспорте данных.'),
code(`import { linkTraceRecords } from './upgrade-2027-01.mjs';
const records = [
{ traceId: 't-7', spanId: 's-gateway', service: 'gateway', parentSpanId: '', durationMs: 22 },
{ traceId: 't-7', spanId: 's-api', service: 'api', parentSpanId: 's-gateway', durationMs: 81 },
{ traceId: 't-7', spanId: 's-db', service: 'db', parentSpanId: 's-missing', durationMs: 4 },
];
console.log(linkTraceRecords(records));
// gateway: root; api: present; db: missing`),
p('Ожидаемый результат показывает разрыв у db. Это уже полезная находка: прежде чем говорить о задержке, надо понять, откуда взялся span без parent. Возможны потеря span, неправильное поле или независимая работа, ошибочно попавшая в trace. Ни одна из версий не следует из массива сама; функция лишь не даёт скрыть дырку за сплошной линией.'),
h2('Время, статус и семантика'),
p('Длительность span сравнивают внутри одной временной шкалы и одной операции. Если gateway ждёт upstream 800 мс, это не означает, что upstream потратил 800 мс на вычисление: туда может входить соединение, очередь, retry и чтение ответа. В attributes нужны хотя бы тип операции, результат и причина окончания. Для HTTP это могут быть status_code, method и route; для базы — операция и имя зависимости без чувствительных параметров.'),
p('Log полезен, когда в нём есть структурированные поля, а не только строка сообщения. RFC 5424 отделяет header, structured data и message, что хорошо совпадает с задачей корреляции. Но формат журнала не гарантирует доставку: transport может отбросить или обрезать запись. Поэтому «в журнале не найдено» — это результат проверки качества источника, а не доказательство отсутствия события.'),
h2('Действия по порядку'),
ol([
'Проверить, что trace-id и span-id имеют ожидаемый формат и не меняются при переходе между сервисами.',
'Построить parent/child граф и отметить root, missing parent, duplicate span-id и операции без service.name.',
'Сверить интервалы start/end с локальными timestamps; отдельно учесть async, retry и очередь.',
'Сопоставить span с application log по span-id или request-id, а metric использовать только как фон для population.',
'Сформулировать вывод в узкой форме: «этот участок наблюдался дольше» или «связь потеряна», не «он был причиной всего отказа».',
]),
h2('Ограничения и следующий шаг'),
p('Sampling, tail-based filtering и ошибки clock skew меняют картину. Trace может не включать retry или consumer, а log collector — получить записи в другом порядке. Данные с персональными параметрами нельзя бездумно передавать в общий контур наблюдаемости. Наконец, даже полная trace-цепочка описывает наблюдаемую последовательность, но не контрфактический вопрос: что произошло бы без конкретного вызова.'),
p('Следующий шаг — выбрать один критичный маршрут и зафиксировать контракт полей для gateway, application и dependency span. Добавьте проверку на missing parent и отдельную метрику пропущенного контекста. После этого повторите разбор: качество решения растёт не от количества экранов, а от уменьшения числа неразличимых объяснений.'),
], [
{ key: 'trace', use: 'Формат traceparent и правила передачи контекста между HTTP-сервисами.', boundary: 'Спецификация не определяет внутреннюю модель span, sampling, очередь и причинность.' },
{ key: 'syslog', use: 'Структурированные поля и границы syslog-сообщения используются как пример дисциплины логирования.', boundary: 'RFC не гарантирует доставку конкретного журнала и не описывает trace-связи приложения.' },
{ key: 'http', use: 'Семантика HTTP-операции и статуса отделена от длительности внутренних работ.', boundary: 'HTTP Semantics не описывает конкретный backend, tracer или способ агрегации.' },
]);
const field = revision({
slug: 'editorial-2027-01-field-debugging-decade',
title: 'Разбор 502 в поле: собрать цепочку из access и application log',
categories: ['Надёжность', 'Практика команд'],
cover: '/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg',
excerpt: 'Полевой маршрут для 502: какие записи собрать, как связать их request-id и где остановиться при разрыве цепочки.',
readingMinutes: 15,
}, [
p('Проблема полевой заметки о 502 — не в нехватке терминов. Ошибка появляется, когда в неё заносят только внешний статус и сразу называют его причиной: «упал API». Цена такой записи практическая: следующий инженер ищет неисправность в приложении, хотя шлюз мог не установить соединение, или чинит upstream, хотя приложение уже вернуло понятный отказ. Без цепочки событий полевой разбор превращается в пересказ экрана.'),
p('Для одного запроса нужны как минимум две записи: access на границе и application в сервисе. Их соединяют request-id или traceparent, а не только время и путь. В каждой записи должны быть timestamp, route, status и длительность; в application log — операция и безопасное описание ошибки. Если второй записи нет, это не повод заполнить пропуск догадкой. Это отдельная ветка: отказ произошёл до приложения или запись потерялась.'),
h2('Начинаем с внешней границы'),
p('RFC 9110 описывает 502 как ситуацию, в которой gateway или proxy получил недействительный ответ от upstream. Для полевой диагностики важен субъект статуса: где именно его увидел клиент. Access log gateway даёт внешний результат, но не раскрывает, был ли запрос принят приложением. Поэтому первой строкой карточки пишем узел и роль: edge.status=502, а не общее «сервер 502».'),
p('Затем ищем application event с тем же идентификатором в небольшом окне. Совпадение найдено — проверяем, что время и route согласуются, а статус приложения объясняет внешний ответ. Совпадения нет — проверяем timeout, фильтр коллектора, другой формат id и потерю записи. Такой разбор занимает меньше времени, чем просмотр всего журнала, потому что каждая проверка меняет одну гипотезу.'),
figure('/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg', 'Петля полевого разбора 502: внешний access event, поиск application event по идентификатору, проверка времени и отдельная ветка для разрыва.', 'Диаграмма показывает, что отсутствие application записи — результат проверки цепочки, а не разрешение назвать приложение причиной.'),
table('Матрица полевой проверки 502', ['Наблюдение', 'Следующая проверка', 'Рабочий вывод', 'Нельзя писать'], [
['502 в edge, application не найден', 'timeout, collector, формат id', 'цепочка разорвана до подтверждения слоя', '«приложение упало»'],
['502 в edge, app 500 с тем же id', 'статус и время app', 'ошибка дошла до приложения', 'что найден root cause'],
['502 в edge, app 200', 'retry, cache, proxy mapping', 'границы преобразуют результат', 'что app ответил клиенту 200'],
['access не содержит id', 'конфигурация structured fields', 'ключ корреляции неполон', 'соединять по ближайшему времени'],
]),
h2('Учебный сборщик цепочки'),
p('Функция ниже принимает два локальных массива и возвращает отдельную карточку на каждый request-id. Входы специально похожи на structured log, но не являются выгрузкой системы. Ожидаемый результат различает «gateway отказал до приложения», «ошибка приложения дошла до клиента» и обычное завершение. Это конкретная операционная техника: она показывает, какие поля нужны для первого прохода и как не потерять разрыв.'),
code(`import { buildRequestTimeline } from './upgrade-2027-01.mjs';
const edge = [
{ requestId: 'r-1', at: '12:00:01.100', status: 502, message: 'upstream timeout' },
{ requestId: 'r-2', at: '12:00:02.100', status: 502, message: 'bad response' },
];
const app = [
{ requestId: 'r-2', at: '12:00:02.080', status: 500, message: 'db unavailable' },
];
console.log(buildRequestTimeline(edge, app).map(({ requestId, result }) => ({ requestId, result })));
// r-1: gateway-failed-before-app; r-2: app-error-reached-client`),
p('Для r-1 нет application event, поэтому функция не называет базу или приложение виновником. Для r-2 есть согласованная запись, но вывод всё ещё ограничен: ошибка приложения достигла внешнего ответа, а почему база недоступна — отдельный вопрос. В реальном коде добавьте проверку схемы, исключите секреты и сохраните raw-поля рядом с нормализованными.'),
h2('Как читать время и повтор'),
p('Время в разных сервисах может иметь разную точность и сдвиг. Если access и application разделены десятками миллисекунд, это повод сверить clock sync и точку записи, а не автоматически отвергнуть связь. Повторный запрос тоже не обязан повторить тот же маршрут: gateway может выбрать другой upstream, а retry — создать новый идентификатор. В карточке держите request-id каждого повтора отдельно.'),
p('RFC 5424 полезен здесь не как готовая схема конкретного приложения, а как напоминание о структурированных полях и разделении источника, времени и сообщения. Поле message удобно читать человеку, но для соединения нужен отдельный ключ. Чем больше решений принимается по свободному тексту, тем выше стоимость следующего разбора.'),
h2('Действия по порядку'),
ol([
'Скопировать из edge только одну попытку запроса: timestamp, route, method, status, duration и request-id.',
'Найти application events по точному id и ограниченному временному окну; сохранить число найденных записей.',
'Сопоставить status, route и длительность, затем отметить разрыв, retry или преобразование на proxy.',
'Проверить зависимость только после подтверждения, что приложение действительно получило запрос.',
'Сформулировать итог как наблюдение и следующий тест: например, «нет app записи; проверить timeout и collector», а не как окончательный root cause.',
]),
h2('Ограничения и следующий шаг'),
p('Журнал может быть неполным из-за sampling, сбоя коллектора, буферизации или редактирования чувствительных полей. Один request-id может встретиться в retry, если система повторно использует контекст; это надо проверить по span-id и attempt. Нельзя публиковать токены, email, тело формы и сырые заголовки. Для юридически чувствительных систем храните безопасный fingerprint и ссылку на закрытый источник.'),
p('Следующий шаг — добавить в runbook три обязательных запроса: найти edge event, найти application event, проверить отсутствие/наличие dependency event. После одного реального разбора измерьте долю карточек, где цепочка собирается без ручного поиска по времени. Это покажет качество полей, а не только удобство инструмента.'),
], [
{ key: 'http', use: 'Семантика 502 и место, где gateway сообщает о недействительном ответе upstream.', boundary: 'RFC не определяет топологию edge/application и не подтверждает конкретный отказ.' },
{ key: 'syslog', use: 'Разделение заголовка, structured data и message поддерживает выбор полей для безопасного журнала.', boundary: 'Стандарт не гарантирует полноту, порядок доставки и наличие записей в конкретном collector.' },
{ key: 'trace', use: 'Traceparent и request context используются как ключи соединения событий на HTTP-границах.', boundary: 'Наличие идентификатора не доказывает, что цепочка полна или что найденная запись была причиной.' },
]);
export const revisions = Object.freeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() {
const articleChecks = revisions.map((item) => {
const body = bodyText(item.contentHtml);
return body.length >= 5000 && body.length <= 15000 && /