Files
progcode/web/scripts/upgrade-2027-01.mjs
2026-07-31 22:26:56 +03:00

279 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const REFERENCES = Object.freeze({
trace: {
title: 'Trace Context — W3C Recommendation',
url: 'https://www.w3.org/TR/trace-context/',
version: 'Recommendation, 23 November 2021',
},
syslog: {
title: 'RFC 5424: The Syslog Protocol',
url: 'https://www.rfc-editor.org/rfc/rfc5424.html',
version: 'Standards Track, March 2009, DOI 10.17487/RFC5424',
},
http: {
title: 'RFC 9110: HTTP Semantics',
url: 'https://www.rfc-editor.org/rfc/rfc9110.html',
version: 'Internet Standard, June 2022, DOI 10.17487/RFC9110',
},
nistLogs: {
title: 'NIST SP 800-92: Guide to Computer Security Log Management',
url: 'https://csrc.nist.gov/pubs/sp/800/92/final',
version: 'September 2006, DOI 10.6028/NIST.SP.800-92',
},
});
function sources(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const reference = REFERENCES[key];
return '<li><a href="' + reference.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(reference.title) + '</a> — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
export function classifyWebSymptom(input) {
const status = Number(input?.status);
const body = String(input?.body ?? '');
const headers = Object.fromEntries(Object.entries(input?.headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)]));
if (status === 502 || status === 504) {
return { class: 'gateway-failure', check: headers['traceparent'] || headers['x-request-id'] ? 'сопоставить gateway и upstream по идентификатору' : 'сначала включить идентификатор запроса на границе', evidence: 'access-status-and-upstream-log' };
}
if (status === 401 || status === 403) return { class: 'access-failure', check: 'сверить схему аутентификации и решение авторизации', evidence: 'request-auth-and-policy' };
if (status === 200 && /empty|blank|undefined/i.test(body)) return { class: 'rendering-or-data-failure', check: 'разделить пустой ответ API и пустой DOM', evidence: 'response-body-and-rendered-output' };
return { class: 'unclassified', check: 'записать метод, путь, статус, размер ответа и время', evidence: 'structured-request-envelope' };
}
export function linkTraceRecords(records) {
const bySpan = new Map(records.map((record) => [record.spanId, record]));
return records.map((record) => ({
traceId: record.traceId,
spanId: record.spanId,
parent: record.parentSpanId ? bySpan.has(record.parentSpanId) ? 'present' : 'missing' : 'root',
service: record.service,
durationMs: record.durationMs,
}));
}
export function buildRequestTimeline(accessEvents, applicationEvents) {
const access = accessEvents.map((event) => ({ ...event, source: 'access' }));
const application = applicationEvents.map((event) => ({ ...event, source: 'application' }));
const all = [...access, ...application].sort((left, right) => left.at.localeCompare(right.at));
const requestIds = new Set(all.map((event) => event.requestId).filter(Boolean));
return [...requestIds].map((requestId) => {
const events = all.filter((event) => event.requestId === requestId);
const gateway = events.find((event) => event.source === 'access');
const app = events.find((event) => event.source === 'application');
const result = gateway?.status >= 500 && !app ? 'gateway-failed-before-app' : gateway?.status >= 500 ? 'app-error-reached-client' : 'request-complete';
return { requestId, result, events: events.map(({ source, at, status, message }) => ({ source, at, status, message })) };
});
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const practice = revision({
slug: 'editorial-2027-01-practice-debugging-decade',
title: 'Web-диагностика без прыжка к причине: симптом, проверка, действие',
categories: ['Инженерные практики', 'Надёжность'],
cover: '/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg',
excerpt: 'Как превратить 502, пустой ответ или отказ в короткий маршрут проверки, не меняя систему наугад.',
readingMinutes: 15,
}, [
p('Проблема web-диагностики обычно начинается с одного наблюдения: страница вернула 502, форма осталась пустой или запрос получил отказ. Инженер сразу называет виновный слой — базу, прокси или браузер — и начинает менять его. Цена такого прыжка измеряется не только временем. Случайный фикс убирает исходный сигнал, добавляет новый побочный эффект и оставляет следующему человеку только фразу «после изменения стало лучше».'),
p('Надёжный разбор начинается с конверта симптома. В нём есть метод, путь, статус, время, размер ответа, идентификатор запроса и граница, на которой запись получена. Эти поля не отвечают на вопрос «кто виноват». Они отвечают на более узкий вопрос: какой следующий сигнал отличит два правдоподобных объяснения. Такой порядок экономит часы: сначала уменьшаем пространство поиска, затем открываем конкретный лог или трассу.'),
h2('Симптом не равен причине'),
p('Статус HTTP описывает результат обмена на конкретной границе. 502 говорит клиенту, что шлюз получил недействительный ответ от upstream; он не говорит, почему upstream ответил так. Это может быть разрыв соединения, таймаут, ошибка маршрута или повреждённый ответ. Если в карточке инцидента оставить только «сервер упал», диагностика уже потеряла важное различие. В карточке должны соседствовать исходный статус и место, где он увиден.'),
p('То же относится к пустому экрану. Пустой HTML, ответ API с пустым массивом и ошибка рендера дают похожую картинку, но требуют разных проверок. Поэтому полезно сначала классифицировать внешний симптом, а не выбирать инструмент по привычке. Ниже показана маленькая функция для локального разбора учебного набора. Она не делает сетевых запросов: её задача — привести вход к следующему наблюдаемому шагу.'),
figure('/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg', 'Диагностический маршрут от web-симптома к проверяемому сигналу: конверт запроса, гипотеза, различающий признак и действие.', 'Схема показывает порядок работы с симптомом. На каждом переходе добавляется конкретный сигнал; сама схема не объявляет причину до проверки.'),
table('Минимальный конверт web-симптома', ['Поле', 'Пример', 'Что позволяет проверить', 'Чего не доказывает'], [
['Метод и путь', 'GET /checkout', 'какая операция повторяется', 'что именно сломано внутри'],
['Статус', '502', 'на какой границе возник отказ', 'причину ответа upstream'],
['Идентификатор', 'traceparent или request-id', 'связать записи разных слоёв', 'полноту цепочки'],
['Время и длительность', '12:04:18, 4.2 с', 'найти окно в журнале', 'что задержка была единственной причиной'],
['Тело и заголовки', 'empty, content-type', 'отличить данные от рендера', 'что пользователь увидел именно этот текст'],
]),
h2('Учебный локальный классификатор'),
p('Функция принимает только наблюдаемую форму ответа: статус, заголовки и тело. Для 502/504 она проверяет наличие traceparent или x-request-id и предлагает сопоставить gateway с upstream. Для 401/403 ведёт к аутентификации и политике доступа. Для 200 с пустым содержимым разделяет данные и DOM. Входы намеренно маленькие: так видно, какое поле повлияло на выбор.'),
code(`import { classifyWebSymptom } from './upgrade-2027-01.mjs';
const samples = [
{ status: 502, headers: { traceparent: '00-abc-123-01' }, body: 'Bad Gateway' },
{ status: 200, headers: { 'content-type': 'text/html' }, body: '<main>empty</main>' },
];
for (const sample of samples) console.log(classifyWebSymptom(sample));
// gateway-failure -> сопоставить gateway и upstream по идентификатору
// rendering-or-data-failure -> разделить пустой ответ API и пустой DOM`),
p('Ожидаемый результат — два разных маршрута, а не общий совет «посмотреть логи». Первый маршрут требует найти одинаковый идентификатор на двух границах. Второй требует сравнить тело ответа с фактически построенным DOM. Если добавить к объекту лишнее поле, функция его не использует: это полезное напоминание, что необработанный контекст не превращается в доказательство автоматически.'),
h2('Гипотеза должна иметь различающий сигнал'),
p('После классификации запишите две гипотезы в форме «если причина X, то при проверке Y увидим Z». Например: если gateway не получил ответ upstream, в access log будет 502, а в application log не будет записи с тем же request-id. Если приложение вернуло ошибку, обе записи появятся, но статусы и время будут различаться. Такая формулировка заставляет заранее назвать отрицательный результат. Без него любая найденная запись легко превращается в подтверждение первоначальной версии.'),
p('Идентификатор запроса здесь играет роль ключа соединения, а не печати достоверности. Он помогает собрать события, но не исключает потерю записи, повторную отправку или ошибку генератора идентификатора. Временное окно тоже не заменяет ключ: два запроса могут идти одновременно, а часы на разных узлах могут расходиться. Если ключ отсутствует, это отдельный результат диагностики, а не разрешение подставить ближайшую запись.'),
h2('Действия по порядку'),
ol([
'Сохранить метод, путь, статус, время, длительность, размер ответа и идентификатор на внешней границе.',
'Сформулировать две причины и для каждой назвать сигнал, который даст разные результаты.',
'Проверить access log и журнал следующего слоя в одном временном окне; не смешивать записи только по похожему пути.',
'Сопоставить идентификатор, parent/child-контекст и направление запроса; отдельно отметить пропущенные записи.',
'Внести изменение только после того, как найден слой и повторяемый признак; после изменения повторить тот же запрос.',
]),
h2('Ограничения и следующий шаг'),
p('Классификатор не устанавливает root cause и не заменяет расследование. Прокси может изменить статус, middleware — скрыть исходное тело, а sampled trace — не содержать нужного span. Для асинхронной очереди одного request-id тоже мало: понадобится связать producer, сообщение и consumer отдельными полями. В production нельзя выводить причину из одного совпадения времени или одного удачного повтора.'),
p('Следующий практический шаг — добавить в ваш журнал структурированные поля <code>request_id</code>, <code>trace_id</code>, <code>route</code>, <code>status</code> и <code>duration_ms</code>, а затем проверить один отказ по маршруту выше. Критерий готовности простой: другой инженер по конверту симптома понимает, какой запрос искать, где искать и какое наблюдение изменит решение.'),
], [
{ 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-заголовок <code>traceparent</code> и формат идентификаторов. Это полезный транспортный контракт: сервис может продолжить контекст, а оператор — искать его в нескольких компонентах. Но стандарт не требует, чтобы все внутренние работы были представлены 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 даёт внешний результат, но не раскрывает, был ли запрос принят приложением. Поэтому первой строкой карточки пишем узел и роль: <code>edge.status=502</code>, а не общее «сервер 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 полезен здесь не как готовая схема конкретного приложения, а как напоминание о структурированных полях и разделении источника, времени и сообщения. Поле <code>message</code> удобно читать человеку, но для соединения нужен отдельный ключ. Чем больше решений принимается по свободному тексту, тем выше стоимость следующего разбора.'),
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 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body);
});
const sample = classifyWebSymptom({ status: 502, headers: { traceparent: '00-a-b-01' }, body: 'Bad Gateway' });
return Object.freeze({ passed: articleChecks.filter(Boolean).length + (sample.class === 'gateway-failure' ? 1 : 0), total: articleChecks.length + 1, accepted: articleChecks.every(Boolean) && sample.class === 'gateway-failure', characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');