279 lines
41 KiB
JavaScript
279 lines
41 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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');
|