const escapeHtml = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); const p = (text) => `

${text}

`; const h2 = (text) => `

${text}

`; const code = (text) => `
${escapeHtml(text)}
`; const ol = (items) => `
    ${items.map((item) => `
  1. ${item}
  2. `).join('')}
`; const figure = (src, alt, caption) => `
${alt}
${caption}
`; const table = (caption, headers, rows) => `
${headers.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${caption}
${cell}
${cell}
`; function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } 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>[\s\S]*$/, '')); } const REFERENCES = deepFreeze({ http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' }, tls: { title: 'RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3', url: 'https://www.rfc-editor.org/rfc/rfc8446.html', version: 'IETF, август 2018 года, RFC 8446, Standards Track' }, pkix: { title: 'RFC 5280 — Internet X.509 Public Key Infrastructure Certificate and CRL Profile', url: 'https://www.rfc-editor.org/rfc/rfc5280.html', version: 'IETF, май 2008 года, RFC 5280, Standards Track' }, trace: { title: 'W3C Trace Context Level 1', url: 'https://www.w3.org/TR/2021/REC-trace-context-1-20211123/', version: 'W3C Recommendation, 23 ноября 2021 года, Trace Context Level 1' }, }); function sources(entries) { return ``; } export function allocateTimeoutBudget({ totalMs, dnsMs = 0, tlsMs = 0, requestMs = 0 }) { if (![totalMs, dnsMs, tlsMs, requestMs].every((value) => Number.isFinite(value) && value >= 0)) return { ok: false, reason: 'durations-must-be-non-negative' }; const spentMs = dnsMs + tlsMs + requestMs; return { ok: spentMs <= totalMs, spentMs, remainingMs: Math.max(0, totalMs - spentMs), reason: spentMs <= totalMs ? 'within-deadline' : 'deadline-exceeded' }; } export function validateCertificateWindow({ host, sans = [], notBefore, notAfter, now }) { if (typeof host !== 'string' || !Array.isArray(sans) || !notBefore || !notAfter || !now) return { ok: false, reason: 'certificate-fields-missing' }; const current = Date.parse(now); const start = Date.parse(notBefore); const end = Date.parse(notAfter); if (![current, start, end].every(Number.isFinite)) return { ok: false, reason: 'certificate-date-invalid' }; if (current < start || current > end) return { ok: false, reason: 'certificate-outside-validity-window' }; if (!sans.includes(host)) return { ok: false, reason: 'host-not-listed-in-san' }; return { ok: true, reason: 'certificate-window-and-san-match' }; } function isLowerHex(value, length) { return typeof value === 'string' && value.length === length && /^[0-9a-f]+$/.test(value); } export function parseTraceparent(header) { if (typeof header !== 'string') return { ok: false, reason: 'traceparent-must-be-string' }; const parts = header.split('-'); if (parts.length !== 4) return { ok: false, reason: 'traceparent-must-have-four-fields' }; const [version, traceId, parentId, flags] = parts; if (!isLowerHex(version, 2) || version === 'ff') return { ok: false, reason: 'version-invalid' }; if (!isLowerHex(traceId, 32) || /^0+$/.test(traceId)) return { ok: false, reason: 'trace-id-invalid' }; if (!isLowerHex(parentId, 16) || /^0+$/.test(parentId)) return { ok: false, reason: 'parent-id-invalid' }; if (!isLowerHex(flags, 2)) return { ok: false, reason: 'flags-invalid' }; return { ok: true, version, traceId, parentId, flags }; } 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 deepFreeze({ ...meta, contentHtml, proseLength }); } const httpRefs = [ { key: 'http', use: 'Определяет границы HTTP-запроса, ответа и ошибки обмена, на которые опирается разбор задержки.', boundary: 'Не измеряет DNS, TLS или работу конкретного клиента; эти интервалы нужно наблюдать отдельно.' }, { key: 'tls', use: 'Описывает handshake TLS 1.3 и позволяет считать установление защищённого соединения отдельной фазой.', boundary: 'Не задаёт таймауты приложения, повторные попытки и настройки конкретной TLS-библиотеки.' }, ]; const practice = revision({ slug: 'editorial-2027-10-practice-long-form-interview', title: 'HTTP-таймауты: как разложить общий deadline на измеримые фазы', categories: ['HTTP', 'Надёжность'], cover: '/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg', excerpt: 'Разбираем timeout по фазам DNS, TCP, TLS, отправки и чтения, чтобы не лечить один общий симптом неправильным retry.', readingMinutes: 15, }, [ p('Проблема выглядит просто: запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Цена ошибки — пользователь получает «сервис недоступен», хотя сервер мог завершить операцию; либо клиент повторяет запись, не зная, успел ли первый запрос попасть в обработчик. Один общий timeout не объясняет, где потрачено время.'), p('Причина — смешать connect timeout, TLS handshake, отправку тела, ожидание первого байта и чтение ответа в одну настройку. При этом разные библиотеки называют поля по-разному, а повторная попытка получает новый полный бюджет. Решение начинается с единого deadline запроса и наблюдаемого разложения этого бюджета по фазам.'), h2('Deadline — это граница операции, а не число для каждой фазы'), p('Общий deadline отвечает на вопрос: до какого момента результат операции имеет смысл для вызывающего кода. Фазовые таймауты отвечают на другой вопрос: сколько можно ждать конкретный переход. Если каждому этапу дать по 800 мс, цепочка из DNS, TCP, TLS и чтения незаметно превращается в несколько секунд. Если выставить connect в 50 мс для сети с редким холодным DNS, ошибка будет ложной.'), p('Хорошая модель хранит абсолютный момент окончания или остаток бюджета, а не только независимые таймеры. Перед началом каждой фазы вычисляется remaining = deadline - now. Если остатка нет, библиотека должна завершить операцию до сетевого вызова. Это предотвращает ситуацию, в которой чтение получает ещё 300 мс после того, как общий deadline уже истёк.'), table('Фазы HTTP-запроса и сигналы', ['Фаза', 'Что измеряем', 'Типичный симптом', 'Действие'], [ ['DNS', 'время разрешения имени', 'первый запрос медленный', 'кэш, resolver и лимит DNS'], ['TCP connect', 'установление соединения', 'ошибка до TLS', 'маршрут, pool, connect timeout'], ['TLS handshake', 'обмен и проверка сертификата', 'connect есть, ответа нет', 'цепочка, crypto, TLS budget'], ['Request write', 'отправка заголовков и тела', 'upload зависает', 'размер тела, backpressure'], ['TTFB/read', 'ожидание ответа и чтение', 'сервер медленный', 'server time, read timeout, payload'], ]), h2('Почему повторная попытка опасна'), p('Timeout не сообщает, что сервер ничего не сделал. Для GET повтор часто безопаснее при корректной семантике ресурса, но для POST это зависит от идемпотентности операции и ключа дедупликации. Если клиент запускает повтор до окончания исходного запроса, два обработчика могут изменить состояние. Поэтому deadline должен попадать в журнал вместе с методом, идентификатором операции и фазой остановки.'), p('Вторая ловушка — retry с тем же самым полным бюджетом. При трёх попытках по 800 мс пользователь ждёт 2,4 секунды плюс задержки, а upstream получает утроенную нагрузку. Бюджет можно делить между попытками, но сначала нужно доказать, что повтор допустим. Для записи полезнее короткий результат «неизвестно, завершилось ли» и проверка статуса операции, чем агрессивная отправка.'), figure('/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg', 'Карта HTTP-запроса: общий deadline проходит через DNS, TCP, TLS и чтение; каждая фаза получает только оставшееся время и собственный сигнал.', 'Схема отделяет бюджет операции от отдельных измерений. Ошибка фазы не должна превращаться в общий диагноз без соответствующего сигнала.'), h2('Runnable-пример: проверяем бюджет без сети'), p('Функция ниже получает общий бюджет и длительности уже измеренных фаз. Она возвращает сумму, остаток и причину. Это учебный пример: он не запускает socket и не заменяет таймеры HTTP-клиента, зато показывает инвариант, который удобно тестировать независимо от сети. Ожидаемый результат для превышения — deadline-exceeded и нулевой остаток.'), code(`import { allocateTimeoutBudget } from './upgrade-2027-10.mjs'; const within = allocateTimeoutBudget({ totalMs: 800, dnsMs: 42, tlsMs: 88, requestMs: 510, }); const late = allocateTimeoutBudget({ totalMs: 800, dnsMs: 120, tlsMs: 210, requestMs: 560, }); console.log(within.ok, within.remainingMs); console.log(late.ok, late.reason, late.remainingMs); // true 160 // false deadline-exceeded 0`), h2('Порядок диагностики'), ol([ 'Запишите общий deadline и отдельно отметьте, в какой момент клиент завершил ожидание. Не начинайте с увеличения числа.', 'Добавьте интервалы DNS, TCP, TLS, отправки и чтения в одну запись запроса. Одинаковые имена фаз важнее конкретной библиотеки.', 'Проверьте, передаётся ли остаток бюджета на следующую фазу. Новый таймер не должен начинаться после истечения абсолютного deadline.', 'Сопоставьте фазу отказа с методом. Для записи проверьте идемпотентность и возможность узнать результат по идентификатору операции.', 'Разделите connect/read timeout и deadline в конфигурации. Каждый параметр должен иметь владельца и тест на граничное значение.', 'Проверьте cold и warm соединение отдельно: pool может скрыть TLS в одном случае и показать его в другом.', ]), h2('Что измерение не доказывает'), p('Большой TTFB не доказывает, что сервер «медленный»: время могло уйти на proxy, очередь или повторный TLS. Малый TTFB не гарантирует быструю загрузку всего тела. Сетевой timeout также не равен HTTP-статусу: клиент может закрыть соединение, а сервер продолжить обработчик. Поэтому метрики фаз нужно связывать с серверным временем и размером ответа, но не подменять ими друг друга.'), p('Точное разделение требует, чтобы библиотека действительно сообщала фазы. Если она отдаёт только total duration, не называйте реконструированные интервалы фактами. Можно начать с instrumented adapter или событий connection pool, а пока фиксировать только известную границу: «клиент прекратил ждать через N мс». Это менее эффектно, но безопаснее для решения.'), h2('Ограничения и следующий шаг'), p('Функция не учитывает jitter часов, системные очереди, HTTP/2 multiplexing и работу proxy. В боевом клиенте deadline должен отменять все вложенные операции, иначе сокет продолжит жить после ответа вызывающему коду. Учебные числа не являются нормативными значениями для конкретной сети.'), p('Следующий шаг — выбрать один endpoint, измерить пять фаз на холодном и тёплом соединении и построить таблицу бюджета для успешного и просроченного запроса. Только после этого меняйте timeout или retry. Для записи добавьте отдельную проверку идемпотентности и способ узнать, был ли первый запрос принят.'), ], httpRefs); const certificateRefs = [ { key: 'tls', use: 'Описывает TLS 1.3 как протокол защищённого канала и задаёт место проверки сертификата внутри handshake.', boundary: 'Не перечисляет доверенные корни конкретной ОС и не гарантирует настройку вашего клиента.' }, { key: 'pkix', use: 'Описывает X.509-поля, цепочку сертификации, расширения и правила проверки имени/срока.', boundary: 'Не заменяет системное хранилище доверия, отзыв сертификата и политику конкретного домена.' }, ]; const mechanism = revision({ slug: 'editorial-2027-10-mechanism-long-form-interview', title: 'TLS-сертификат: почему «curl работает» не закрывает проверку', categories: ['Security', 'HTTP'], cover: '/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg', excerpt: 'Разбираем цепочку доверия, срок действия и SAN: какие проверки проходят до HTTP и почему один успешный клиент ничего не доказывает для другого.', readingMinutes: 16, }, [ p('Проблема обычно выглядит противоречиво: браузер открывает адрес, а сервисный клиент получает certificate error; либо один контейнер подключается, а второй — нет. Цена ошибки — отключить проверку TLS «временно», потерять имя хоста в диагностике и превратить сетевую проблему в уязвимость.'), p('Причина в том, что «сертификат валиден» — это не одна проверка. Клиент строит цепочку до доверенного корня, проверяет период действия, имя назначения и ограничения сертификата. Разные хранилища корней, SNI, proxy и часы системы меняют результат. Нужно разделить слой протокола, X.509-структуру и локальную политику доверия.'), h2('Что проверяет клиент до HTTP'), p('TLS handshake создаёт защищённый канал, но доверие к peer не появляется из шифрования автоматически. Сертификат содержит открытый ключ, имя и подпись издателя; клиент проверяет цепочку и применимость к назначенному хосту. Если запрос идёт на api.example.test, сертификат только для admin.example.test не должен считаться подходящим из-за того, что ключ технически рабочий.'), p('Период действия проверяется по часам клиента. Ошибка в системном времени даёт симптом «сертификат ещё не действителен» или «истёк», хотя сервер ничего не менял. Переход на другой контейнер может поменять корневое хранилище и набор промежуточных сертификатов. Поэтому при сравнении сред нужно собирать не только URL, но и hostname, SNI, trust store, время и цепочку.'), table('Минимальная проверка сертификата', ['Проверка', 'Вопрос', 'Отказ', 'Что собрать'], [ ['Срок', 'now между notBefore и notAfter?', 'not-yet-valid / expired', 'UTC-время клиента и поля сертификата'], ['Имя', 'host есть в SAN?', 'hostname mismatch', 'SNI, hostname и SAN'], ['Цепочка', 'есть путь до доверенного корня?', 'unknown issuer', 'leaf, intermediate, trust store'], ['Подпись', 'алгоритм и ключ разрешены?', 'signature/algorithm error', 'TLS policy и negotiated version'], ['Отзыв', 'политика проверяет статус?', 'revoked/unknown', 'OCSP/CRL policy и доступность'], ]), h2('Почему отключение verify ухудшает диагностику'), p('Флаг вроде insecure меняет вопрос с «можно ли доверять peer» на «зашифрован ли канал до кого-то». Запрос начинает проходить, но факт успеха перестаёт говорить о подлинности сервера. Если потом этот флаг попадёт в общий клиент или пример конфигурации, временная отладка станет постоянной дырой.'), p('Надёжнее вывести диагностическую информацию без обхода проверки: имя хоста, SNI, цепочку, срок, код ошибки и идентификатор корня. В тестовой среде можно добавить собственный CA в доверенное хранилище или передать его явно. Такой путь сохраняет настоящую проверку и делает отличие среды видимым.'), figure('/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg', 'Матрица TLS-проверки: сертификат проходит срок действия, имя SAN, цепочку доверия и алгоритмическую политику до начала HTTP-обмена.', 'Диаграмма разделяет данные сертификата и локальную политику. Успешный запрос одного клиента не является доказательством для другого trust store.'), h2('Runnable-пример: срок и SAN как отдельные причины'), p('Функция ниже не строит X.509-цепочку и не заменяет TLS-библиотеку. Она принимает ISO-даты, hostname и список SAN, затем показывает две базовые проверки, которые полезно видеть в тестах и диагностическом выводе. Входы специально простые; ожидаемый результат различает успех, истёкший сертификат и несовпадение имени.'), code(`import { validateCertificateWindow } from './upgrade-2027-10.mjs'; const common = { host: 'api.example.test', sans: ['api.example.test', 'api.internal.test'], notBefore: '2026-01-01T00:00:00Z', notAfter: '2027-01-01T00:00:00Z', }; console.log(validateCertificateWindow({ ...common, now: '2026-06-01T00:00:00Z' })); console.log(validateCertificateWindow({ ...common, host: 'cdn.example.test', now: '2026-06-01T00:00:00Z' }).reason); // { ok: true, reason: 'certificate-window-and-san-match' } // host-not-listed-in-san`), h2('Порядок проверки в среде'), ol([ 'Зафиксируйте точный hostname и порт, который видит TLS-клиент. IP-адрес в логе не заменяет имя для проверки SAN.', 'Проверьте часы контейнера и узла в UTC. Ошибку времени нельзя лечить повторной загрузкой сертификата.', 'Снимите leaf и intermediate без отключения verify. Сравните цепочку с trust store конкретного процесса.', 'Проверьте SAN, SNI и redirect. Сертификат для исходного адреса не обязан подходить для нового host после перенаправления.', 'Разделите ошибку доверия, имени, срока и алгоритма. Для каждой причины оставьте отдельный тестовый fixture.', 'Исправляйте trust store или цепочку на сервере; флаг обхода проверки не используйте как решение.', ]), h2('Цепочка не равна доверию'), p('Наличие intermediate в файле сервера не означает, что клиент доверяет корню. И наоборот, локальный trust store может содержать корень, но сервер не отправить промежуточный сертификат. Успешная проверка строит путь по подписи и ограничениям, а не по совпадению строк в PEM-файле. Это объясняет, почему «в браузере работает» может быть правдой одновременно с ошибкой минимального контейнера.'), p('Сертификат также не сообщает всю эксплуатационную политику. Клиент может проверять отзыв, запрещать старый алгоритм или требовать минимальную версию TLS. Если диагностический отчёт пишет только «certificate valid», он скрывает полезную часть причины. Сохраняйте код ошибки библиотеки и параметры соединения, а секретный ключ и полное содержимое лишний раз не логируйте.'), h2('Ограничения и следующий шаг'), p('Учебная функция не проверяет подпись, CRL, OCSP, wildcard-правила, DNS и реальное TLS-согласование. Она не является security scanner. Её роль — сделать две часто потерянные проверки явными и тестируемыми без сетевой зависимости.'), p('Следующий шаг — воспроизвести ошибку в том же контейнере, где работает сервис, собрать hostname/SNI, цепочку, время и код ошибки, а затем исправить конкретный слой. После исправления оставьте regression test на истёкший срок и неверный SAN, чтобы повторное «временное» отключение доверия стало заметным.'), ], certificateRefs); const traceRefs = [ { key: 'trace', use: 'Задаёт формат traceparent, правила создания и передачи идентификаторов между HTTP-компонентами.', boundary: 'Не определяет backend storage, sampling, формат логов и семантику бизнес-операции.' }, { key: 'http', use: 'Описывает HTTP-поля и обмен, в котором передаётся контекст трассировки.', boundary: 'Не гарантирует, что proxy пропустит или сервис корректно обработает пользовательский заголовок.' }, ]; const field = revision({ slug: 'editorial-2027-10-field-long-form-interview', title: 'Trace Context в HTTP: как не потерять запрос на границе proxy', categories: ['Observability', 'HTTP'], cover: '/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg', excerpt: 'Практический разбор traceparent: формат, правила передачи через proxy и безопасная проверка заголовка на входе сервиса.', readingMinutes: 15, }, [ p('Проблема видна во время сбоя: gateway сообщает timeout, backend пишет 500, а связать записи по одному запросу невозможно. Цена потери контекста — инженер читает десятки похожих логов, ошибочно обвиняет медленный сервис и дольше держит пользователя в неопределённости.'), p('Причина часто не в отсутствии системы трассировки, а в границе передачи. Proxy удаляет неизвестный заголовок, middleware создаёт новый trace вместо продолжения или сервис принимает строку с неверным форматом. Исправление требует разделить идентификатор трассы, идентификатор родительского span, правила доверия и способ записи событий.'), h2('Что именно передаёт traceparent'), p('Заголовок traceparent содержит четыре поля: версию, trace-id, parent-id и flags. Trace-id связывает дерево обработки одного запроса, parent-id показывает непосредственный предыдущий участок, а flags несут небольшие свойства контекста. Формат строгий: lowercase hexadecimal, правильная длина и ненулевые идентификаторы. Если строка не проходит проверку, её нельзя молча преобразовывать.'), p('Сервис на входе должен решить, продолжать ли внешний trace. Валидный контекст может быть принят как родительский, а новый span создаётся уже локальной библиотекой. Невалидный заголовок лучше отбросить и начать новый локальный trace, одновременно записав безопасную причину. Нельзя доверять trace-id для авторизации или помещать в него пользовательские данные.'), table('Граница передачи HTTP-контекста', ['Участок', 'Проверка', 'Ошибка', 'Действие'], [ ['Клиент → gateway', 'заголовок добавлен один раз', 'новый trace на каждый redirect', 'сохранять контекст по правилам клиента'], ['Gateway → service', 'proxy пропускает traceparent', 'поле удалено', 'allow-list заголовка и integration test'], ['Middleware', 'формат и ненулевые id', 'uppercase/короткое поле', 'отбросить и создать локальный trace'], ['Logger', 'trace-id попал в структурное поле', 'текстовый grep не находит', 'единый JSON key и sampling policy'], ['Сервис → очередь', 'HTTP-контекст не теряется при смене транспорта', 'новое дерево без link', 'использовать механизм контекста брокера'], ]), h2('Proxy — часть контракта наблюдаемости'), p('Заголовок, дошедший до приложения локально, ничего не доказывает о внешнем маршруте. Реальный proxy может нормализовать имя, удалить неизвестное поле, ограничить размер или создать свой контекст. Поэтому тестировать нужно пару gateway + service с теми же правилами allow-list, а не только чистую функцию парсинга.'), p('Проверка также должна учитывать границы доверия. Внешний клиент может прислать любой trace-id. Это полезный идентификатор для диагностики, но не секрет и не доказательство личности. Логи должны экранировать значение, а sampling не должен удалять единственный event с ошибкой. Наблюдаемость помогает найти проблему, но не должна становиться каналом для доступа.'), figure('/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg', 'Цепочка HTTP-контекста: клиент передаёт traceparent gateway, gateway сохраняет его при пересылке, а сервис проверяет формат и создаёт локальный span.', 'Схема показывает место проверки на границе сервиса. Заголовок связывает события, но не является авторизацией и не заменяет логику хранения.'), h2('Runnable-пример: отклоняем повреждённый traceparent'), p('Парсер получает ровно одну строку и возвращает разобранные поля только после проверки длины, lowercase hexadecimal и ненулевых идентификаторов. Для плохого входа он возвращает причину, которую можно посчитать в метрике без записи полного пользовательского заголовка. Пример не создаёт span и не отправляет telemetry; он демонстрирует проверяемую границу формата.'), code(`import { parseTraceparent } from './upgrade-2027-10.mjs'; const valid = parseTraceparent( '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', ); const invalid = parseTraceparent( '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01', ); console.log(valid.ok, valid.traceId.slice(0, 8)); console.log(invalid.ok, invalid.reason); // true 4bf92f35 // false trace-id-invalid`), h2('Порядок поиска потерянного контекста'), ol([ 'Выберите один запрос и выпишите его границы: клиент, gateway, service и downstream. Для каждой границы нужен отдельный trace-id в логах.', 'Проверьте raw-заголовок до proxy и после proxy. Если значение исчезает, не начинайте с backend-библиотеки.', 'Добавьте parser fixture на нулевые id, uppercase, неверную длину и запрещённую версию. Ошибка должна быть безопасной и короткой.', 'Убедитесь, что middleware продолжает валидный trace и создаёт новый локальный parent-id, а не переписывает весь trace.', 'Приведите логи к одному структурному ключу и добавьте ошибку парсинга как счётчик без полного заголовка.', 'Прогоните интеграционный тест через реальный proxy, затем отдельно проверьте sampling и задержку доставки telemetry.', ]), h2('Почему один идентификатор не решает диагностику'), p('Trace-id связывает события, но не объясняет, сколько времени занял каждый участок. Для этого нужны span-границы, timestamps с понятными часами и статус ошибки. Если gateway и сервис пишут один trace-id, но не записывают начало и конец span, инженер всё ещё не знает, где возникла задержка.'), p('Не следует использовать trace-id как ключ бизнес-операции без отдельного поля. Пользователь может повторить операцию, а один trace может завершиться до асинхронной обработки. Для записи и очереди добавляйте безопасный operation-id и связывайте их в журнале по правилам приватности. Так диагностика не подменяет доменную идемпотентность.'), h2('Ограничения и следующий шаг'), p('Парсер не проверяет подпись, доверие к отправителю, sampling backend и форматы контекста Kafka/очереди. Он работает только с header string. Разные библиотеки могут дополнительно проверять version и flags, поэтому интеграционный тест должен закрепить выбранное поведение.'), p('Следующий шаг — добавить один end-to-end тест через proxy: отправить валидный traceparent, проверить один trace-id во всех сервисных логах и отдельно послать повреждённую строку. Вторая проверка должна показать controlled fallback, а не 500 и не принятие внешнего значения как права доступа.'), ], traceRefs); export const revisions = deepFreeze([practice, mechanism, field]); export function runHttpBoundaryFixture() { const cases = [ ['deadline-within', allocateTimeoutBudget({ totalMs: 800, dnsMs: 40, tlsMs: 90, requestMs: 500 }).reason, 'within-deadline'], ['deadline-exceeded', allocateTimeoutBudget({ totalMs: 800, dnsMs: 120, tlsMs: 210, requestMs: 560 }).reason, 'deadline-exceeded'], ['certificate-valid', validateCertificateWindow({ host: 'api.test', sans: ['api.test'], notBefore: '2026-01-01T00:00:00Z', notAfter: '2027-01-01T00:00:00Z', now: '2026-06-01T00:00:00Z' }).ok, true], ['certificate-san-rejects', validateCertificateWindow({ host: 'cdn.test', sans: ['api.test'], notBefore: '2026-01-01T00:00:00Z', notAfter: '2027-01-01T00:00:00Z', now: '2026-06-01T00:00:00Z' }).reason, 'host-not-listed-in-san'], ['traceparent-valid', parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01').ok, true], ['traceparent-rejects-uppercase', parseTraceparent('00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01').reason, 'trace-id-invalid'], ]; const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected })); return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks }); } export function verifyRevisionsAgainstFixture() { const fixture = runHttpBoundaryFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900)); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, 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');