revise October 2023 slo articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 15:16:33 +03:00
parent 2c860caddd
commit 6da24704e1
7 changed files with 670 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 205 из 358 созданных материалов. Остальные 153 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 208 из 358 созданных материалов. Остальные 150 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+54
View File
@@ -0,0 +1,54 @@
# P68 · 2023-10 · SLI/SLO и error budget без декоративных метрик — три прохода саморевью
## Рамка sidecar-партии
- Slug: `editorial-2023-10-practice-sli-slo`, `editorial-2023-10-mechanism-sli-slo`, `editorial-2023-10-field-sli-slo`.
- Голос: М6, октябрь 2023. Системный практик начинает с наблюдаемого сбоя и цены, затем проходит путь «симптом → причина → проверка → действие», называет owner, границу данных и обратимость. Текст не выдаёт учебную модель за собственный production-опыт.
- Созданы ровно пять sidecar-файлов: этот review, один import-safe script и три локальные SVG. `articles.json`, registry, README, очередь, Git и чужие файлы не менялись; пакет не интегрирован.
- Fixture работает только с фиксированными synthetic JS-объектами в памяти. Он не читает проект, файлы, переменные окружения, часы, monitoring, CI, сеть, HTTP, SDK или production-конфигурацию. Его PASS не заявляет реальный SLI, availability, incident, burn rate, traffic, alert, rollout или release authority.
## Проход 1 — факты, источник и техника
- Источники перепроверены 31.07.2026; все существовали до конца октября 2023: [Google SRE Book, Service Level Objectives, copyright 2017](https://sre.google/sre-book/service-level-objectives/), [Google SRE Workbook, Example SLO Document, 19.02.2018](https://sre.google/workbook/slo-document/), [Example Error Budget Policy, 19.02.2018](https://sre.google/workbook/error-budget-policy/) и [Alerting on SLOs, copyright 2018](https://sre.google/workbook/alerting-on-slos/). Первые два дают определения SLI/SLO, scope и пример четырёхнедельного rolling window; policy описывает баланс надёжности и изменения, а не наказание; alerting разбирает отдельную модель burn rate и проблему low traffic.
- В статьях эти материалы ограничены их фактическими выводами. Четырёхнедельное окно названо примером Google, а не универсальной нормой. Формула fixture не названа burn rate. Policy не превращена в автоматический release gate. Google-примеры не используются как доказательство поведения неизвестного сервиса.
- `createSyntheticSloContract()` принимает только полный `synthetic-slo-contract-input-v1` с фиксированными synthetic scope, 28-day window, good/eligible rules, target и owner. Отрицательные ветки отвергают неотмеченный вход, лишнее network-like поле без его вызова, пропущенное обязательное поле, другую область, другое окно, размытый индикатор, target 100, нулевой знаменатель и impossible counts.
- `runSloFixture()` вычисляет только учебные `syntheticGoodCount`, `syntheticEligibleCount`, `syntheticBadCount`, допустимые synthetic bad и synthetic остаток. Он явно возвращает `observed=not-observed`, `realSli=not-claimed`, `realIncident=not-claimed`, `realBurnRate=not-calculated`, `realAvailability=not-claimed`, `releaseAuthority=not-granted`.
- На техническом проходе найден и исправлен дефект отрицательной ветки rollback: она ошибочно пробовала accepted exhausted sample. Теперь ей передаётся отклонённый `wrongScope`, а assertion требует `restored=false`. Это проверяет границу модели, а не создаёт ложный operational rollback.
- `rollbackSyntheticSloContract()` возвращает только snapshot договора и явно сообщает: monitoring/CI не затронуты, сеть не использовалась, production effect не предпринимался. Он не удаляет данные и не управляет release.
## Проход 2 — редактура, полнота и голос
- Три статьи различаются вопросом. Practice собирает SLI-contract от пользовательского пути; mechanism раскрывает арифметику окна, знаменателя и policy; field строит петлю решения с owner и evidence. Они не пересказывают один текст разными заголовками.
- В первых двух абзацах каждого текста названы проблема и цена: декоративный процент ведёт к неверному релизному решению, неполная формула — к ложной остановке или рисковому rollout, а красный график без owner — к скрытым полномочиям и плохой диагностике.
- В каждой ревизии есть доступная таблица, figure с содержательными `alt` и подписью, исполнимый synthetic fixture, явные ограничения, rollback, финальный следующий шаг и упорядоченный маршрут «симптом → причина → проверка → действие».
- `audit:draft` зафиксировал объём основного текста, включая короткий executable пример и без источников: practice — 9 606, mechanism — 9 892, field — 9 770 знаков. Все три текста находятся в заданной цели 8–10 тыс. и обязательном диапазоне 5–15 тыс. знаков.
- Речь краткая и предметная: термины `SLI`, `SLO`, `eligible`, `good`, `window`, `policy`, `owner` и `proxy` привязаны к контракту или решению. Нет обобщений про «современный мир», фантазий о production-инциденте, обещаний «универсальной доступности» или подмены технической проверки ярким заголовком.
## Проход 3 — визуал, безопасность и выпуск
- SVG разделяют понятия: договор SLI, окно с synthetic арифметикой и петля ручного решения. В них нет `script`, `foreignObject`, внешних URL, `data:image`, event-handler-атрибутов или пользовательского ввода.
- Sharp-render всех трёх SVG на ширине 375 px открыт вручную. После первого рендера были перенесены две нижние подписи и сокращён заголовок петли решения, чтобы текст не обрезался справа. Финальные 375 px варианты читаемы: блоки, стрелки, числа, ограничения и ручная граница release decision не перекрываются.
- Выполнены проверки: `node --check web/scripts/upgrade-2023-10.mjs` — PASS; `node web/scripts/upgrade-2023-10.mjs --verify-fixture` — PASS 22/22 после независимого model review; `cd web && npm run audit:draft -- scripts/upgrade-2023-10.mjs` — PASS для трёх slug; import-safe export без `date`/`author` — PASS; `xmllint --noout` — PASS; SVG safety scan — чистый результат; Sharp 375 px — PASS.
- Пакет не коммитился, не пушился, не интегрировался в registry и не запускал production build: эти действия намеренно оставлены за пределами sidecar и пользовательского ограничения ровно пяти файлов.
## Интеграционное ревью главного агента
Отдельно сверены первичные страницы Google SRE: SLO — target/range измеряемого
SLI; error budget связывается с решением о риске; Example SLO Document даёт
четырёхнедельное rolling window как пример; Example Error Budget Policy не
называет остановку изменений наказанием; Alerting on SLOs разбирает burn rate
и особенно риск ложной чувствительности low-traffic систем. В статьях эти
утверждения сохранены как исторический контекст, не как политика проекта.
Model review закрыл форму synthetic-входа. Раньше helper запрещал лишние keys,
но не требовал полный набор собственных полей. Теперь top-level и все nested
records обязаны быть plain records с точным expected shape; добавлена
отрицательная ветка без `sample`. Fixture после исправления проходит `22/22`
assertions и по-прежнему не получает полномочие на release.
На визуальном проходе в SLI-схеме мобильный рендер обрезал последнюю часть
подписи исключения. Подпись разбита на две строки; повторный Sharp-render 375
px читаем. Все SVG прошли XML и safety scan. Повторный draft audit: 9 640 /
9 892 / 9 770 знаков; строгий archive audit — PASS для трёх slug; registry
содержит 199 уникальных ревизий. `npm run build` успешно сгенерировал 374
статические страницы.
+2
View File
@@ -64,6 +64,7 @@ import { revisions as june2023Revisions } from '../scripts/upgrade-2023-06.mjs';
import { revisions as july2023Revisions } from '../scripts/upgrade-2023-07.mjs';
import { revisions as august2023Revisions } from '../scripts/upgrade-2023-08.mjs';
import { revisions as september2023Revisions } from '../scripts/upgrade-2023-09.mjs';
import { revisions as october2023Revisions } from '../scripts/upgrade-2023-10.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -133,4 +134,5 @@ export const editorialRevisions = [
...july2023Revisions,
...august2023Revisions,
...september2023Revisions,
...october2023Revisions,
];
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="760" viewBox="0 0 1200 760" role="img" aria-labelledby="title desc">
<title id="title">Учебная арифметика окна и error budget</title>
<desc id="desc">Слева показано фиксированное synthetic окно 28 дней и synthetic вход. В центре приведён расчёт synthetic eligible, good, bad и допустимых bad. Справа вручную выбирается следующий шаг; это не реальный burn rate или доступность.</desc>
<rect width="1200" height="760" fill="#F5F7FB"/>
<rect x="36" y="32" width="1128" height="84" rx="20" fill="#102A43"/>
<text x="76" y="84" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="38" font-weight="700">Error budget зависит от окна и знаменателя</text>
<text x="76" y="145" fill="#486581" font-family="Arial, sans-serif" font-size="25">Фиктивные числа показывают форму расчёта, а не состояние сервиса</text>
<rect x="48" y="198" width="292" height="350" rx="26" fill="#E5EFFA" stroke="#2F80ED" stroke-width="3"/>
<text x="82" y="254" fill="#173B65" font-family="Arial, sans-serif" font-size="30" font-weight="700">Synthetic window</text>
<text x="82" y="294" fill="#173B65" font-family="Arial, sans-serif" font-size="25">fixed 28 days</text>
<line x1="82" y1="326" x2="305" y2="326" stroke="#8CB8E8" stroke-width="3"/>
<text x="82" y="376" fill="#486581" font-family="Arial, sans-serif" font-size="25">Eligible: 1000</text>
<text x="82" y="422" fill="#486581" font-family="Arial, sans-serif" font-size="25">Good: 994</text>
<text x="82" y="468" fill="#486581" font-family="Arial, sans-serif" font-size="25">Bad: 6</text>
<text x="82" y="510" fill="#486581" font-family="Arial, sans-serif" font-size="21">only in-memory input</text>
<path d="M364 374 H425" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round"/>
<path d="M419 356 L449 374 L419 392" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="462" y="166" width="360" height="416" rx="30" fill="#FFF7E6" stroke="#E7A72D" stroke-width="3"/>
<text x="500" y="226" fill="#7A4D00" font-family="Arial, sans-serif" font-size="32" font-weight="700">Synthetic arithmetic</text>
<rect x="500" y="258" width="284" height="66" rx="14" fill="#FFFFFF"/>
<text x="526" y="300" fill="#5D4724" font-family="Arial, sans-serif" font-size="25">Target = 99%</text>
<rect x="500" y="340" width="284" height="66" rx="14" fill="#FFFFFF"/>
<text x="526" y="382" fill="#5D4724" font-family="Arial, sans-serif" font-size="25">Allowed bad = 10</text>
<rect x="500" y="422" width="284" height="66" rx="14" fill="#E7F6ED" stroke="#2D9D5B" stroke-width="2"/>
<text x="526" y="464" fill="#176B3A" font-family="Arial, sans-serif" font-size="25">Remaining = 4</text>
<text x="500" y="536" fill="#7A4D00" font-family="Arial, sans-serif" font-size="21">observed: not-observed</text>
<path d="M846 374 H885" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round"/>
<path d="M879 356 L909 374 L879 392" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="920" y="198" width="234" height="350" rx="26" fill="#F1EDFF" stroke="#7C5CE0" stroke-width="3"/>
<text x="952" y="254" fill="#49399B" font-family="Arial, sans-serif" font-size="30" font-weight="700">Decision</text>
<text x="952" y="306" fill="#34314C" font-family="Arial, sans-serif" font-size="24">Owner reviews</text>
<text x="952" y="344" fill="#34314C" font-family="Arial, sans-serif" font-size="24">contract +</text>
<text x="952" y="382" fill="#34314C" font-family="Arial, sans-serif" font-size="24">evidence</text>
<line x1="952" y1="416" x2="1120" y2="416" stroke="#B6A7F0" stroke-width="3"/>
<text x="952" y="460" fill="#49399B" font-family="Arial, sans-serif" font-size="22">No auto gate</text>
<text x="952" y="500" fill="#49399B" font-family="Arial, sans-serif" font-size="22">No real burn rate</text>
<rect x="48" y="612" width="1106" height="100" rx="20" fill="#FFFFFF" stroke="#B7C7D8" stroke-width="3"/>
<text x="80" y="652" fill="#243B53" font-family="Arial, sans-serif" font-size="27" font-weight="700">Изменили окно, eligible set или target — получили другую модель.</text>
<text x="80" y="690" fill="#243B53" font-family="Arial, sans-serif" font-size="27" font-weight="700">Это не новая краска графика.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="760" viewBox="0 0 1200 760" role="img" aria-labelledby="title desc">
<title id="title">Петля решения по SLI SLO и error budget</title>
<desc id="desc">Петля ведёт от synthetic сигнала через проверку SLI-контракта и контекст разрешённой среды к решению владельца, обратимому действию и повторной проверке. Линия к релизу обозначена как ручное решение без автоматического gate.</desc>
<rect width="1200" height="760" fill="#F5F7FB"/>
<rect x="36" y="32" width="1128" height="84" rx="20" fill="#102A43"/>
<text x="76" y="84" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="38" font-weight="700">Бюджет — петля решения, не автоматический gate</text>
<text x="76" y="145" fill="#486581" font-family="Arial, sans-serif" font-size="25">Сигнал → договор → контекст → owner → обратимое действие → повторная проверка</text>
<rect x="58" y="236" width="214" height="150" rx="24" fill="#E5EFFA" stroke="#2F80ED" stroke-width="3"/>
<text x="91" y="290" fill="#173B65" font-family="Arial, sans-serif" font-size="29" font-weight="700">1. Signal</text>
<text x="91" y="330" fill="#173B65" font-family="Arial, sans-serif" font-size="22">synthetic only</text>
<rect x="334" y="188" width="244" height="182" rx="24" fill="#FFF7E6" stroke="#E7A72D" stroke-width="3"/>
<text x="366" y="244" fill="#7A4D00" font-family="Arial, sans-serif" font-size="29" font-weight="700">2. Contract</text>
<text x="366" y="284" fill="#7A4D00" font-family="Arial, sans-serif" font-size="22">scope · window</text>
<text x="366" y="318" fill="#7A4D00" font-family="Arial, sans-serif" font-size="22">good · eligible</text>
<rect x="650" y="188" width="244" height="182" rx="24" fill="#E7F6ED" stroke="#2D9D5B" stroke-width="3"/>
<text x="682" y="244" fill="#176B3A" font-family="Arial, sans-serif" font-size="29" font-weight="700">3. Context</text>
<text x="682" y="284" fill="#176B3A" font-family="Arial, sans-serif" font-size="22">allowed evidence</text>
<text x="682" y="318" fill="#176B3A" font-family="Arial, sans-serif" font-size="22">one hypothesis</text>
<rect x="928" y="236" width="214" height="150" rx="24" fill="#F1EDFF" stroke="#7C5CE0" stroke-width="3"/>
<text x="960" y="290" fill="#49399B" font-family="Arial, sans-serif" font-size="29" font-weight="700">4. Owner</text>
<text x="960" y="330" fill="#49399B" font-family="Arial, sans-serif" font-size="22">manual decision</text>
<path d="M276 310 H314" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round"/>
<path d="M308 292 L338 310 L308 328" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M582 280 H630" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round"/>
<path d="M624 262 L654 280 L624 298" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M898 310 H908" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round"/>
<path d="M902 292 L932 310 L902 328" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="640" y="472" width="286" height="142" rx="24" fill="#FFFFFF" stroke="#B7C7D8" stroke-width="3"/>
<text x="674" y="528" fill="#243B53" font-family="Arial, sans-serif" font-size="29" font-weight="700">5. Action</text>
<text x="674" y="566" fill="#486581" font-family="Arial, sans-serif" font-size="22">narrow, fix, defer,</text>
<text x="674" y="596" fill="#486581" font-family="Arial, sans-serif" font-size="22">or continue with control</text>
<rect x="258" y="472" width="290" height="142" rx="24" fill="#FFFFFF" stroke="#B7C7D8" stroke-width="3"/>
<text x="292" y="528" fill="#243B53" font-family="Arial, sans-serif" font-size="29" font-weight="700">6. Recheck</text>
<text x="292" y="566" fill="#486581" font-family="Arial, sans-serif" font-size="22">same contract</text>
<text x="292" y="596" fill="#486581" font-family="Arial, sans-serif" font-size="22">same criterion</text>
<path d="M1034 410 C1034 474 982 520 946 534" fill="none" stroke="#7C5CE0" stroke-width="7" stroke-linecap="round"/>
<path d="M938 513 L924 544 L957 542" fill="none" stroke="#7C5CE0" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M632 543 H574" fill="none" stroke="#2F80ED" stroke-width="7" stroke-linecap="round"/>
<path d="M580 525 L550 543 L580 561" fill="none" stroke="#2F80ED" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M250 544 C154 522 122 440 136 396" fill="none" stroke="#2F80ED" stroke-width="7" stroke-linecap="round"/>
<path d="M119 402 L140 376 L149 408" fill="none" stroke="#2F80ED" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="380" y="656" width="440" height="52" rx="16" fill="#FFF7E6" stroke="#E7A72D" stroke-width="2"/>
<text x="411" y="690" fill="#7A4D00" font-family="Arial, sans-serif" font-size="24" font-weight="700">No automatic release gate; no real incident claim</text>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="760" viewBox="0 0 1200 760" role="img" aria-labelledby="title desc">
<title id="title">Учебный договор SLI для одного пути пользователя</title>
<desc id="desc">Слева указан synthetic путь checkout. В центре eligible события содержат good события, а исключение вынесено отдельно. Справа перечислены synthetic окно, цель и владелец. Внизу указано, что это договор, а не дашборд.</desc>
<rect width="1200" height="760" fill="#F5F7FB"/>
<rect x="36" y="32" width="1128" height="84" rx="20" fill="#102A43"/>
<text x="76" y="84" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="38" font-weight="700">SLI начинается с договора, а не с процента</text>
<text x="76" y="145" fill="#486581" font-family="Arial, sans-serif" font-size="25">Учебный synthetic контракт: путь, граница событий, окно, цель и владелец</text>
<rect x="50" y="204" width="296" height="258" rx="24" fill="#E5EFFA" stroke="#2F80ED" stroke-width="3"/>
<text x="82" y="260" fill="#173B65" font-family="Arial, sans-serif" font-size="30" font-weight="700">Путь пользователя</text>
<text x="82" y="312" fill="#173B65" font-family="Arial, sans-serif" font-size="27">synthetic-checkout-</text>
<text x="82" y="350" fill="#173B65" font-family="Arial, sans-serif" font-size="27">submit</text>
<line x1="82" y1="382" x2="312" y2="382" stroke="#8CB8E8" stroke-width="3"/>
<text x="82" y="422" fill="#486581" font-family="Arial, sans-serif" font-size="24">Один ограниченный вопрос</text>
<path d="M370 334 H430" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round"/>
<path d="M424 316 L454 334 L424 352" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="464" y="184" width="338" height="358" rx="28" fill="#FFF7E6" stroke="#E7A72D" stroke-width="3"/>
<text x="500" y="242" fill="#7A4D00" font-family="Arial, sans-serif" font-size="31" font-weight="700">Eligible events</text>
<text x="500" y="282" fill="#7A4D00" font-family="Arial, sans-serif" font-size="23">synthetic request finished</text>
<rect x="500" y="316" width="266" height="100" rx="18" fill="#E7F6ED" stroke="#2D9D5B" stroke-width="3"/>
<text x="530" y="360" fill="#176B3A" font-family="Arial, sans-serif" font-size="30" font-weight="700">Good events</text>
<text x="530" y="396" fill="#176B3A" font-family="Arial, sans-serif" font-size="22">non-5xx response</text>
<rect x="500" y="426" width="266" height="88" rx="14" fill="#FFFFFF" stroke="#6C7A89" stroke-width="3" stroke-dasharray="10 8"/>
<text x="526" y="462" fill="#44505C" font-family="Arial, sans-serif" font-size="21">Explicit exclusion</text>
<text x="526" y="492" fill="#44505C" font-family="Arial, sans-serif" font-size="19">client cancel before request</text>
<path d="M826 334 H866" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round"/>
<path d="M860 316 L890 334 L860 352" fill="none" stroke="#2F80ED" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="900" y="184" width="254" height="358" rx="28" fill="#F1EDFF" stroke="#7C5CE0" stroke-width="3"/>
<text x="932" y="242" fill="#49399B" font-family="Arial, sans-serif" font-size="31" font-weight="700">Контракт</text>
<text x="932" y="304" fill="#49399B" font-family="Arial, sans-serif" font-size="24">Window</text>
<text x="932" y="338" fill="#34314C" font-family="Arial, sans-serif" font-size="23">synthetic 28 days</text>
<text x="932" y="394" fill="#49399B" font-family="Arial, sans-serif" font-size="24">Target</text>
<text x="932" y="428" fill="#34314C" font-family="Arial, sans-serif" font-size="23">synthetic 99%</text>
<text x="932" y="484" fill="#49399B" font-family="Arial, sans-serif" font-size="24">Owner</text>
<text x="932" y="518" fill="#34314C" font-family="Arial, sans-serif" font-size="23">synthetic owner</text>
<rect x="50" y="590" width="1104" height="116" rx="22" fill="#FFFFFF" stroke="#B7C7D8" stroke-width="3"/>
<text x="82" y="642" fill="#243B53" font-family="Arial, sans-serif" font-size="30" font-weight="700">Сначала определяем границы. Затем проверяем данные.</text>
<text x="82" y="682" fill="#243B53" font-family="Arial, sans-serif" font-size="30" font-weight="700">Это договор, не dashboard.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+470
View File
@@ -0,0 +1,470 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.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="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'Google SRE Book: Service Level Objectives, copyright 2017',
url: 'https://sre.google/sre-book/service-level-objectives/',
note: 'Первичный материал Google SRE: определяет SLI как количественную меру, SLO как целевое значение или диапазон и связывает error budget с решением о риске. Он не задаёт порог, окно, формулу или владельца для конкретного проекта.',
},
{
title: 'Google SRE Workbook: Example SLO Document, 19.02.2018',
url: 'https://sre.google/workbook/slo-document/',
note: 'Официальный пример документа с датой 19.02.2018: в нём есть scope, SLI, SLO и четырёхнедельное rolling window. Это пример формы, а не обязательное окно для любого сервиса.',
},
{
title: 'Google SRE Workbook: Example Error Budget Policy, 19.02.2018',
url: 'https://sre.google/workbook/error-budget-policy/',
note: 'Официальный пример policy: расход бюджета служит правилом приоритизации и не должен быть наказанием. Он не разрешает автоматически блокировать или выпускать чужие изменения.',
},
{
title: 'Google SRE Workbook: Alerting on SLOs, copyright 2018',
url: 'https://sre.google/workbook/alerting-on-slos/',
note: 'Официальная глава о том, что alert должен относиться к действенной угрозе бюджету; отдельно разбираются ограничения low-traffic систем. Она не подтверждает реальный burn rate, alert или incident этого учебного пакета.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const MODEL_LIMIT = 'in-memory-fixed-synthetic-slo-contract-no-project-read-no-monitoring-no-ci-no-network-no-real-sli-no-real-incident-no-real-burn-rate-no-real-availability';
const SYNTHETIC_SERVICE = 'synthetic-checkout-api';
const SYNTHETIC_JOURNEY = 'synthetic-checkout-submit';
const SYNTHETIC_WINDOW = 'synthetic-four-week-window-A';
const SYNTHETIC_OWNER = 'synthetic-checkout-owner';
/**
* Учебная модель создаёт и проверяет только заранее заданные synthetic-объекты
* в памяти Node. Она не читает проект, файлы, переменные окружения, часы,
* monitoring, CI, сеть, HTTP, SDK или production-конфигурацию. Результат
* намеренно не является реальным SLI, incident, burn rate, availability,
* наблюдением, alert, решением о выпуске или доказательством качества сервиса.
*/
function rejectSyntheticSlo(reason) {
return Object.freeze({
kind: 'synthetic-slo-fixture-v1',
syntheticOnly: true,
accepted: false,
reason,
modelLimit: MODEL_LIMIT,
});
}
function isWholeNonNegative(value) {
return Number.isInteger(value) && value >= 0;
}
function hasExactSyntheticKeys(object, expected) {
if (!object || typeof object !== 'object' || Array.isArray(object)) return false;
const prototype = Object.getPrototypeOf(object);
if (prototype !== Object.prototype && prototype !== null) return false;
return Object.keys(object).length === expected.length
&& expected.every((key) => Object.hasOwn(object, key));
}
/**
* Собирает только договор учебного примера. Значения goodCount и eligibleCount
* — не измерения и не данные сервиса: это фиксированные synthetic числа,
* нужные, чтобы проверить арифметику границы и отрицательные ветки fixture.
*/
export function createSyntheticSloContract(input) {
if (!input || input.synthetic !== true || input.kind !== 'synthetic-slo-contract-input-v1') {
return rejectSyntheticSlo('synthetic-input-required');
}
const allowedTopLevel = ['synthetic', 'kind', 'service', 'journey', 'window', 'indicator', 'objective', 'sample'];
if (!hasExactSyntheticKeys(input, allowedTopLevel)) return rejectSyntheticSlo('synthetic-input-shape-rejected');
if (input.service !== SYNTHETIC_SERVICE || input.journey !== SYNTHETIC_JOURNEY) return rejectSyntheticSlo('unexpected-synthetic-scope');
const window = input.window;
if (!hasExactSyntheticKeys(window, ['kind', 'label', 'days'])
|| window.kind !== 'synthetic-fixed-window-v1'
|| window.label !== SYNTHETIC_WINDOW
|| window.days !== 28) {
return rejectSyntheticSlo('synthetic-window-contract-rejected');
}
const indicator = input.indicator;
if (!hasExactSyntheticKeys(indicator, ['kind', 'eligibleEvent', 'goodEvent', 'excludedEvent'])
|| indicator.kind !== 'synthetic-success-ratio-v1'
|| indicator.eligibleEvent !== 'synthetic-checkout-request-finished'
|| indicator.goodEvent !== 'synthetic-checkout-response-non-5xx'
|| indicator.excludedEvent !== 'synthetic-client-cancel-before-request') {
return rejectSyntheticSlo('synthetic-indicator-contract-rejected');
}
const objective = input.objective;
if (!hasExactSyntheticKeys(objective, ['targetPercent', 'owner'])
|| !Number.isFinite(objective.targetPercent)
|| objective.targetPercent <= 0
|| objective.targetPercent >= 100
|| objective.owner !== SYNTHETIC_OWNER) {
return rejectSyntheticSlo('synthetic-objective-contract-rejected');
}
const sample = input.sample;
if (!hasExactSyntheticKeys(sample, ['kind', 'goodCount', 'eligibleCount'])
|| sample.kind !== 'synthetic-in-memory-observation-v1'
|| !isWholeNonNegative(sample.goodCount)
|| !Number.isInteger(sample.eligibleCount)
|| sample.eligibleCount <= 0
|| sample.goodCount > sample.eligibleCount) {
return rejectSyntheticSlo('synthetic-sample-contract-rejected');
}
const syntheticBadCount = sample.eligibleCount - sample.goodCount;
const syntheticAllowedBadCount = sample.eligibleCount * ((100 - objective.targetPercent) / 100);
const syntheticCompliancePercent = (sample.goodCount / sample.eligibleCount) * 100;
const syntheticBudgetRemaining = Math.max(0, syntheticAllowedBadCount - syntheticBadCount);
const syntheticBudgetSpentShare = syntheticAllowedBadCount === 0
? 1
: Math.min(1, syntheticBadCount / syntheticAllowedBadCount);
const withinSyntheticObjective = syntheticCompliancePercent >= objective.targetPercent;
const snapshot = Object.freeze({
service: input.service,
journey: input.journey,
windowLabel: window.label,
targetPercent: objective.targetPercent,
owner: objective.owner,
});
return Object.freeze({
kind: 'synthetic-slo-fixture-v1',
syntheticOnly: true,
accepted: true,
reason: 'synthetic-slo-contract-assembled',
modelLimit: MODEL_LIMIT,
contract: Object.freeze({
service: input.service,
journey: input.journey,
window: Object.freeze({ label: window.label, days: window.days, mode: 'synthetic-fixed-only' }),
indicator: Object.freeze({ ...indicator }),
objective: Object.freeze({ ...objective }),
}),
calculation: Object.freeze({
syntheticGoodCount: sample.goodCount,
syntheticEligibleCount: sample.eligibleCount,
syntheticBadCount,
syntheticCompliancePercent,
syntheticAllowedBadCount,
syntheticBudgetRemaining,
syntheticBudgetSpentShare,
withinSyntheticObjective,
observed: 'not-observed',
}),
decision: Object.freeze({
proposal: withinSyntheticObjective ? 'synthetic-review-before-change' : 'synthetic-reliability-work-first',
owner: objective.owner,
realSli: 'not-claimed',
realIncident: 'not-claimed',
realBurnRate: 'not-calculated',
realAvailability: 'not-claimed',
releaseAuthority: 'not-granted',
}),
rollback: Object.freeze({ action: 'restore-synthetic-contract-draft', snapshot }),
});
}
export function rollbackSyntheticSloContract(contract) {
if (!contract || contract.accepted !== true || !contract.rollback?.snapshot) {
return Object.freeze({ restored: false, syntheticOnly: true, reason: 'no-accepted-synthetic-contract' });
}
return Object.freeze({
restored: true,
syntheticOnly: true,
reason: 'synthetic-contract-draft-restored',
snapshot: contract.rollback.snapshot,
observation: 'not-created-or-deleted',
monitoring: 'not-touched',
ci: 'not-touched',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
const validSyntheticInput = Object.freeze({
synthetic: true,
kind: 'synthetic-slo-contract-input-v1',
service: SYNTHETIC_SERVICE,
journey: SYNTHETIC_JOURNEY,
window: Object.freeze({ kind: 'synthetic-fixed-window-v1', label: SYNTHETIC_WINDOW, days: 28 }),
indicator: Object.freeze({
kind: 'synthetic-success-ratio-v1',
eligibleEvent: 'synthetic-checkout-request-finished',
goodEvent: 'synthetic-checkout-response-non-5xx',
excludedEvent: 'synthetic-client-cancel-before-request',
}),
objective: Object.freeze({ targetPercent: 99, owner: SYNTHETIC_OWNER }),
sample: Object.freeze({ kind: 'synthetic-in-memory-observation-v1', goodCount: 994, eligibleCount: 1000 }),
});
export function runSloFixture() {
const valid = createSyntheticSloContract(validSyntheticInput);
const nonSynthetic = createSyntheticSloContract({ ...validSyntheticInput, synthetic: false });
const unknownTopLevel = createSyntheticSloContract({ ...validSyntheticInput, monitoringUrl: 'https://example.invalid' });
const missingTopLevel = createSyntheticSloContract({
synthetic: true,
kind: 'synthetic-slo-contract-input-v1',
service: SYNTHETIC_SERVICE,
journey: SYNTHETIC_JOURNEY,
window: validSyntheticInput.window,
indicator: validSyntheticInput.indicator,
objective: validSyntheticInput.objective,
});
const wrongScope = createSyntheticSloContract({ ...validSyntheticInput, service: 'synthetic-other-api' });
const wrongWindow = createSyntheticSloContract({ ...validSyntheticInput, window: { ...validSyntheticInput.window, days: 30 } });
const ambiguousIndicator = createSyntheticSloContract({ ...validSyntheticInput, indicator: { ...validSyntheticInput.indicator, eligibleEvent: 'synthetic-all-events' } });
const impossibleTarget = createSyntheticSloContract({ ...validSyntheticInput, objective: { ...validSyntheticInput.objective, targetPercent: 100 } });
const missingDenominator = createSyntheticSloContract({ ...validSyntheticInput, sample: { ...validSyntheticInput.sample, eligibleCount: 0 } });
const moreGoodThanEligible = createSyntheticSloContract({ ...validSyntheticInput, sample: { ...validSyntheticInput.sample, goodCount: 1001 } });
const exhausted = createSyntheticSloContract({ ...validSyntheticInput, sample: { ...validSyntheticInput.sample, goodCount: 986 } });
const restored = rollbackSyntheticSloContract(valid);
const rejectedRollback = rollbackSyntheticSloContract(wrongScope);
return Object.freeze({
assertions: Object.freeze({
acceptsFixedSyntheticContract: valid.accepted === true && valid.reason === 'synthetic-slo-contract-assembled',
keepsExplicitScopeAndOwner: valid.contract.service === SYNTHETIC_SERVICE && valid.contract.journey === SYNTHETIC_JOURNEY && valid.contract.objective.owner === SYNTHETIC_OWNER,
keepsFixedSyntheticWindow: valid.contract.window.label === SYNTHETIC_WINDOW && valid.contract.window.days === 28 && valid.contract.window.mode === 'synthetic-fixed-only',
keepsExplicitGoodAndEligibleDefinitions: valid.contract.indicator.eligibleEvent === 'synthetic-checkout-request-finished' && valid.contract.indicator.goodEvent === 'synthetic-checkout-response-non-5xx',
calculatesOnlySyntheticArithmetic: valid.calculation.syntheticGoodCount === 994 && valid.calculation.syntheticEligibleCount === 1000 && valid.calculation.syntheticBadCount === 6,
exposesSyntheticBudgetBoundary: valid.calculation.syntheticAllowedBadCount === 10 && valid.calculation.syntheticBudgetRemaining === 4 && valid.calculation.syntheticBudgetSpentShare === 0.6,
doesNotClaimObservation: valid.calculation.observed === 'not-observed' && valid.decision.realSli === 'not-claimed' && valid.decision.realAvailability === 'not-claimed',
doesNotCalculateRealBurnRate: valid.decision.realBurnRate === 'not-calculated' && !Object.hasOwn(valid.calculation, 'burnRate'),
doesNotGrantReleaseAuthority: valid.decision.releaseAuthority === 'not-granted' && valid.decision.proposal === 'synthetic-review-before-change',
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-input-required',
rejectsUnexpectedNetworkLikeField: unknownTopLevel.accepted === false && unknownTopLevel.reason === 'synthetic-input-shape-rejected',
rejectsIncompleteInputShape: missingTopLevel.accepted === false && missingTopLevel.reason === 'synthetic-input-shape-rejected',
rejectsDifferentScope: wrongScope.accepted === false && wrongScope.reason === 'unexpected-synthetic-scope',
rejectsDifferentWindow: wrongWindow.accepted === false && wrongWindow.reason === 'synthetic-window-contract-rejected',
rejectsAmbiguousIndicator: ambiguousIndicator.accepted === false && ambiguousIndicator.reason === 'synthetic-indicator-contract-rejected',
rejectsImpossibleTarget: impossibleTarget.accepted === false && impossibleTarget.reason === 'synthetic-objective-contract-rejected',
rejectsMissingDenominator: missingDenominator.accepted === false && missingDenominator.reason === 'synthetic-sample-contract-rejected',
rejectsImpossibleCounts: moreGoodThanEligible.accepted === false && moreGoodThanEligible.reason === 'synthetic-sample-contract-rejected',
marksSyntheticBudgetExhaustionWithoutIncidentClaim: exhausted.accepted === true && exhausted.calculation.withinSyntheticObjective === false && exhausted.decision.realIncident === 'not-claimed' && exhausted.decision.proposal === 'synthetic-reliability-work-first',
rollbackRestoresOnlyDraft: restored.restored === true && restored.snapshot.windowLabel === SYNTHETIC_WINDOW && restored.observation === 'not-created-or-deleted',
rollbackDoesNotTouchSystems: restored.monitoring === 'not-touched' && restored.ci === 'not-touched' && restored.network === 'not-used' && restored.productionEffect === 'not-attempted',
rejectedScenarioCannotRollback: rejectedRollback.restored === false && rejectedRollback.reason === 'no-accepted-synthetic-contract',
}),
samples: Object.freeze({ valid, nonSynthetic, unknownTopLevel, missingTopLevel, wrongScope, wrongWindow, ambiguousIndicator, impossibleTarget, missingDenominator, moreGoodThanEligible, exhausted, restored, rejectedRollback }),
});
}
const syntheticExample = `import { runSloFixture } from './upgrade-2023-10.mjs';
const report = runSloFixture();
if (!Object.values(report.assertions).every(Boolean)) throw new Error('fixture failed');
console.log(report.samples.valid.decision.releaseAuthority); // not-granted
console.log(report.samples.valid.calculation.observed); // not-observed
// Полный synthetic вход и отрицательные ветки находятся в этом же модуле.
// Нет чтения проекта, monitoring, CI, сети, real SLI, incident, burn rate или availability.`;
const fixtureCommand = syntheticExample + '\n\nnode web/scripts/upgrade-2023-10.mjs --verify-fixture\n\n# PASS подтверждает только согласованность synthetic договора и его отрицательных веток.';
const practice = revision({
slug: 'editorial-2023-10-practice-sli-slo',
title: 'SLI без декоративной метрики: минимальный договор пути пользователя',
categories: ['Надёжность', 'Наблюдаемость'],
cover: '/assets/editorial/2023/slo-error-budget-2023-sli-contract.svg',
excerpt: 'Как начать SLI/SLO не с процента на дашборде, а с договора: какой путь пользователя считаем, какие события входят в знаменатель, что означает успех, кто владеет решением и чего такой договор пока не доказывает.',
readingMinutes: 12,
}, [
p('Проблема начинается не с отсутствия графика. График обычно уже есть: процесс отвечает, балансировщик отдаёт коды, на панели горит хороший процент. Но пользовательский путь «отправить заказ» может останавливаться после принятого HTTP-запроса, а этот процент продолжает выглядеть спокойно. Команда видит число, но не знает, чью работу оно представляет. Цена ошибки — релизное решение принимают по декоративной метрике: исправляют не тот участок, спорят о «доступности» и не могут показать, какое условие действительно важно пользователю.'),
p('Быстрый ремонт обычно звучит так: возьмём все ответы без 5xx, назовём их успехом и поставим цель 99,9%. Это не договор, пока не названы путь, знаменатель, исключения, окно и владелец. Одно и то же значение будет честным для API-приёма заказа и бесполезным для завершённого checkout. Цена второй ошибки — не выдуманный инцидент и не обещанный downtime. Это потеря причины: в следующий раз команда снова будет выяснять, какие события вообще считает и почему один участник исключил отмену клиента, а другой — нет.'),
h2('Сначала вопрос пользователя, затем SLI'),
p('SLI — service level indicator, количественная мера выбранного свойства сервиса. SLO — цель этой меры в оговорённых условиях. Эти определения были опубликованы в Google SRE Book в 2017 году. Для практики важнее порядок: не спрашивать «какие метрики уже экспортируются», а закончить фразу от имени пользователя. Например: «после подтверждения формы checkout получает осмысленный ответ о приёме запроса». Это ещё не готовый SLI, но уже ограничивает маршрут, а не весь набор сервисных счётчиков.'),
p('Дальше разбираем путь на события, которые можно проверить. В знаменатель входят только завершённые попытки выбранного маршрута. Числитель — подмножество этих попыток, для которых заранее назван хороший результат. Событие, которое не дошло до точки измерения, нельзя бесшумно считать и успехом, и ошибкой: его правило должно быть записано отдельно. В учебном контракте ниже отмена до отправки запроса исключена. Это не рекомендация для каждого продукта, а пример того, как исключение перестаёт быть скрытым допущением.'),
table('Минимальный договор для одного учебного пути', ['Часть договора', 'Точное значение в учебной модели', 'Зачем это нужно', 'Чего из этого нельзя вывести'], [
['Путь', 'synthetic-checkout-submit', 'не смешивать checkout с любым HTTP-трафиком', 'что такой маршрут есть в production'],
['Знаменатель', 'synthetic-checkout-request-finished', 'видеть все завершённые попытки выбранной границы', 'реальное число запросов или пользователей'],
['Хороший исход', 'synthetic-checkout-response-non-5xx', 'отделить правило успеха от кода графика', 'фактическую доступность или корректность заказа'],
['Исключение', 'synthetic-client-cancel-before-request', 'сделать границу спора явной', 'что отмены можно не учитывать в чужом сервисе'],
['Окно и владелец', 'synthetic-four-week-window-A; synthetic-checkout-owner', 'назвать срок сравнения и того, кто принимает решение', 'право автоматически выпускать или блокировать изменение'],
]),
figure('/assets/editorial/2023/slo-error-budget-2023-sli-contract.svg', 'Учебный SLI-контракт: путь synthetic checkout ограничивает множество eligible событий; good events являются его подмножеством, а отдельное исключение названо явно. Рядом стоят окно, целевое значение и synthetic owner.', 'Схема показывает структуру договора до запроса к мониторингу. Это не дашборд, не реальный SLI и не измерение доступности, инцидента или burn rate.'),
h2('Почему uptime процесса часто декоративен'),
p('Процесс может быть жив, а пользовательская операция — нет. Обратная ситуация тоже возможна: один backend ответил ошибкой, но продукт показал пользователю допустимый fallback. Поэтому «uptime» без объекта измерения нельзя автоматически переименовать в SLI. Он может быть полезен для диагностики конкретного компонента, но вопрос «успел ли пользователь сделать нужное действие?» остаётся отдельным. В Google SRE Book прямо различаются пользовательски важная мера и доступный proxy; proxy надо называть proxy, а не выдавать за прямой пользовательский результат.'),
h2('Окно и цель принадлежат договору'),
p('Окно измерения не является оформлением графика. В Example SLO Document Google SRE Workbook от 19 февраля 2018 года показано четырёхнедельное rolling window; это хороший пример того, что окно записывают вместе с SLI. В нашем fixture есть другое, намеренно фиксированное учебное окно 28 дней. Не переносите его в проект автоматически: для редких операций, сезонного трафика, пакетной обработки или юридического SLA могут понадобиться другая граница и отдельное решение владельцев.'),
p('Цель также не берут из текущего красивого значения. Сначала продукт, разработка и эксплуатация называют ожидаемую ценность пути, стоимость деградации и границу измерения. Затем выбирают достижимый target и правило пересмотра. Цель 100% fixture отвергает не потому, что любая система не может иметь жёсткого требования, а потому, что этот маленький пример учит явно отличать цель от бесконечного обещания. Реальный выбор всё равно требует контекста и согласования.'),
h2('Исполнимый synthetic fixture проверяет форму, а не систему'),
p('Fixture хранит входные объекты прямо в модуле и принимает только полный явно названный набор synthetic полей. Он собирает один договор: путь, окно, определение good/eligible, target, owner и две synthetic счётные величины. Затем он отвергает неотмеченный вход, лишнее или пропущенное поле, чужую область, другое окно, размытый индикатор, невозможную цель и отсутствующий знаменатель. Так можно проверить, что review не потерял поле договора ещё до настройки реального monitoring.'),
code(fixtureCommand),
p('PASS здесь означает только: учебный объект непротиворечив и отрицательные ветки сработали. Он не читает репозиторий, не подключается к monitoring, не запускает CI, не вызывает сеть и не знает, существуют ли реальные события. `syntheticGoodCount=994` и `syntheticEligibleCount=1000` — числа для арифметики fixture, не измерение traffic, availability, SLI, incident или burn rate. Поле `releaseAuthority=not-granted` специально не позволяет прочитать вывод как разрешение на изменение.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> На панели есть процент, но при вопросе «какой пользовательский путь он покрывает?» команда называет разные ответы.',
'<strong>Причина.</strong> Источник событий, знаменатель, good outcome, исключения, окно и owner появились в разных местах: запросе, dashboard и устной договорённости.',
'<strong>Проверка.</strong> На одном пути составьте таблицу из пяти строк. Для одного good, одного bad и одного спорного события укажите: входит ли оно в знаменатель, почему и где это можно проверить.',
'<strong>Проверка границы.</strong> Отдельно назовите proxy. Если measure стоит на backend, а ценность происходит в клиентском сценарии, запишите расхождение и не называйте proxy прямой доступностью пользователя.',
'<strong>Действие.</strong> Сохраните короткий SLI-contract рядом с owner, window, target и методом пересмотра. Сначала стабилизируйте определения, затем строите query и alert.',
'<strong>Следующий контроль.</strong> В разрешённой среде получите evidence для трёх примеров и проверьте, что одинаковая формула используется в отчёте и в релизном обсуждении.',
]),
h2('Rollback возвращает текст договора, не данные'),
p('Если формула оказалась неверной, первым обратимым действием будет вернуть предыдущую версию договора и пометить новую как неутверждённую. Это не значит «удалить метрики»: до такой команды надо знать backend, retention, права, потребителей alert и факт уже выполненного rollout. Учебный `rollbackSyntheticSloContract()` возвращает только snapshot пяти полей и прямо сообщает `monitoring=not-touched`, `ci=not-touched`, `network=not-used`. Он полезен тем, что не маскирует отсутствие операционного плана.'),
p('В настоящем изменении rollback должен содержать версию правила, владельца, условие возврата и способ проверить, что новый расчёт больше не используется. Если этого нет, не добавляйте к дашборду ещё один процент. Сначала сузьте изменение до одного пути и одного места измерения. Для автора уровня M6 это не бюрократия: это способ не выдать красивую визуализацию за доказательство пользовательского результата.'),
h2('Ограничения и следующий шаг'),
p('В статье нет production-данных, настоящего SLI, availability, incident, burn rate, alert, dashboard, SLO compliance, traffic или реальной стоимости простоя. Нет чтения проекта, файлов, часов, monitoring, CI, сети, HTTP, SDK и конфигурации. Внешние источники объясняют исторические термины и форму документов, но не назначают формулу, target или policy конкретной команде. Даже корректный SLI-contract не показывает причину ухудшения: для этого понадобится отдельный диагностический сигнал.'),
p('Следующий шаг — выбрать один действительно ценный путь и провести короткий review с его владельцем. Результатом должен стать не скриншот, а запись: scope, eligible event, good event, исключения, окно, источник, target, owner, способ пересмотра и известный proxy-gap. Только после этого имеет смысл обсуждать dashboard и error budget. Если любой из пунктов неизвестен, честный статус — «договор не готов», а не «метрика зелёная». '),
h2('Историческая граница октября 2023'),
p('К концу октября 2023 уже существовали использованные здесь первичные материалы Google: SRE Book с copyright 2017 и SRE Workbook с документами 2018 года. Они обосновывают термины SLI, SLO, окно и policy, но не делают эту учебную схему историческим production-кейсом автора. В тексте нет более позднего инструмента и нет утверждения, что любой backend уже реализует эти правила.'),
]);
const mechanism = revision({
slug: 'editorial-2023-10-mechanism-sli-slo',
title: 'Error budget без магии: границы окна, знаменателя и решения',
categories: ['Надёжность', 'Наблюдаемость'],
cover: '/assets/editorial/2023/slo-error-budget-2023-budget-window.svg',
excerpt: 'Разбор механизма error budget: почему процент нельзя читать без числа eligible событий, окна, target и policy; как отделить учебную арифметику от реального burn rate, инцидента и решения о релизе.',
readingMinutes: 12,
}, [
p('Проблема появляется, когда в релизном обсуждении говорят: «мы потратили бюджет», но никто не может показать, из какого окна, знаменателя и цели получилась фраза. Два отчёта могут дать разные проценты из одних и тех же событий, если один начал считать после смены версии, а второй оставил в знаменателе отмены до запроса. Цена — не математическая придирка. Команда способна заморозить полезное изменение по шумному сигналу или, наоборот, продолжить рискованный rollout, потому что неудобные события тихо исчезли из формулы.'),
p('Вторая ловушка — копировать target как число с девятками и считать, что появился error budget. Бюджет — это допустимая доля неуспеха в конкретной модели, а не универсальный счётчик проблем. Без scope и policy он становится красной полосой, которой каждый объясняет своё решение задним числом. Цена — у команды нет общей точки остановки: инженер приносит график, продукт говорит о сроке, а владелец сервиса не может назвать, какое действие разрешено именно при этом состоянии.'),
h2('Механизм состоит из пяти связанных частей'),
p('У простого success-ratio договора есть eligible count, good count, target, window и правило реакции. Сначала считают долю good среди eligible. Затем target определяет допустимую долю bad в том же окне. Разница между допустимым и фактическим числом bad — остаток учебного бюджета. Если поменять хотя бы одну часть, получится другая величина. Поэтому нельзя взять число ошибок за день и назвать его расходом четырёхнедельного бюджета без явного правила агрегации.'),
p('В коде этой партии target равен 99, synthetic eligible count — 1000, synthetic good count — 994. Из этого следуют шесть synthetic bad и десять допустимых synthetic bad, то есть остаток четыре условные единицы. Это именно проверяемая арифметика модели, а не расчёт для услуги: в коде нет источника событий, часов, реального окна, monitoring или backend. Слово `synthetic` повторяется намеренно, чтобы понятная формула не превратилась в несуществующий отчёт о доступности.'),
table('Что меняет каждый элемент error-budget модели', ['Элемент', 'Вопрос', 'Если он не назван', 'Что проверять до решения'], [
['Scope', 'какой путь пользователя оцениваем?', 'в одну цифру попадают разные операции', 'маршрут, версию и границу владельца'],
['Eligible', 'какие попытки имеют право попасть в знаменатель?', 'часть ошибок или отмен исчезает неявно', 'источник, фильтр и отрицательные примеры'],
['Good', 'какой исход считается допустимым?', 'HTTP-код или технический ответ подменяет смысл результата', 'связь статуса с пользовательским действием'],
['Window', 'за какой период сравниваем?', 'один всплеск и долгий тренд смешиваются', 'rolling/fixed правило, reset и низкий трафик'],
['Policy', 'кто и что делает при состоянии бюджета?', 'цвет графика выдают за автоматический gate', 'owner, evidence, исключения и обратимое действие'],
]),
figure('/assets/editorial/2023/slo-error-budget-2023-budget-window.svg', 'Учебное окно из 28 synthetic дней: eligible и good события образуют долю, target задаёт допустимое число synthetic bad, а остаток бюджета передаётся на ручной decision step. Визуально отделены окно, арифметика и policy.', 'Диаграмма показывает только фиктивную формулу. Она не читает временной ряд, не считает реальный burn rate, не фиксирует incident и не сообщает доступность сервиса.'),
h2('Окно — это граница сравнения, а не подпись оси'),
p('Окно отвечает на вопрос «какие события имеют право повлиять на это решение сейчас». В Example SLO Document Google показано rolling window в четыре недели; этот документ датирован 19 февраля 2018 года. Это не доказывает, что четыре недели подходят всем. Для малотрафикового пути один failure может резко изменить процент, а для потоковой задачи важнее задержка завершения. Google SRE Workbook отдельно предупреждает, что подходы alerting для высокотрафиковых сервисов дают ложный сигнал на low traffic. Значит, размер окна и способ действия надо выбрать вместе с характером события, а не после того, как график уже настроен.'),
p('Нужно также решить, что происходит на границе окна. Fixed window проще объяснить: все события относятся к явно названному периоду, затем начинается следующий. Rolling window быстрее показывает недавнее ухудшение, но требует точного описания того, как момент измерения сдвигает набор событий. Нельзя менять режим в середине отчёта без новой версии договора: сравнение с прошлым значением потеряет смысл. В fixture выбран fixed 28-day object только потому, что его отрицательную ветку легко проверить; он ничего не говорит о реальном календаре.'),
h2('Знаменатель не должен прятаться в query'),
p('Есть соблазн исключить неудобные ответы, чтобы сделать ratio спокойнее. Исключение может быть законным, например событие не дошло до измеряемой операции, но его надо объяснить на языке пользователя и фиксировать в контракте. «Не успели отправить запрос» и «операция завершилась ошибкой на сервере» — разные состояния. Их нельзя склеить под общим названием «шум». Хороший SLI не обязан учитывать всё; он обязан честно говорить, что именно не учитывает и какой другой сигнал требуется для этой слепой зоны.'),
h2('Арифметика не заменяет policy'),
p('Google SRE Book 2017 связывает error budget с решением о риске, а Example Error Budget Policy 2018 прямо описывает policy как способ защитить пользователя, а не наказать команду. Из этого следует практический вывод: после расчёта должна появиться не автоматическая команда, а заранее согласованная развилка. Например, владелец может назначить review, сузить rollout или запланировать reliability work. Какое действие верно, зависит от продукта, класса изменения, обратимости, security-исключений и качества evidence. Ни один процент сам по себе не знает этих условий.'),
p('В учебной модели `proposal` имеет только два значения: `synthetic-review-before-change` и `synthetic-reliability-work-first`. Рядом стоит `releaseAuthority=not-granted`. Это намеренно ограничивает fixture: он показывает, что состояние арифметики может привести к вопросу владельцу, но не даёт право останавливать CI, менять feature flag или выполнять rollout. Такой запрет полезен в коде примера, потому что в реальной системе именно место принятия решения и права на действие чаще всего оказываются неявными.'),
h2('Исполнимый fixture ловит плохую границу раньше графика'),
p('Ниже один и тот же fixture. Он проверяет не PromQL, не правила alerting и не production telemetry, а форму входа. Хорошая ветка содержит ровно один synthetic scope, фиксированное 28-day окно, explicit good/eligible event, target меньше 100 и целые счётные значения. Плохие ветки пытаются подменить окно 30 днями, размыть eligible event, записать невозможную цель, передать нулевой знаменатель или сделать good count больше eligible. Эти ошибки должны быть заметны ещё в review, а не после публикации красивого отчёта.'),
code(fixtureCommand),
p('При запуске код не использует дату, файл, environment, monitoring API, CI, HTTP или сеть. У него нет clock и нет таблицы реальных точек. Даже строка `syntheticBudgetSpentShare=0.6` не является burn rate: она лишь результат деления шести fixed synthetic bad на десять synthetic допустимых bad внутри одного объекта. PASS не подтверждает, что система выдерживает цель, что произошёл incident или что изменение безопасно выпускать.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В обсуждении звучит «бюджет потрачен», но для числа нельзя назвать scope, окно, eligible count и target.',
'<strong>Причина.</strong> Процент, период и policy живут в разных запросах, документах или головах участников; одинаковое слово обозначает разные формулы.',
'<strong>Проверка модели.</strong> Выпишите пять частей из таблицы. На synthetic примере пересчитайте good, eligible, bad и допустимое число bad; отдельно отметьте события вне scope.',
'<strong>Проверка данных.</strong> В разрешённой среде подтвердите, что источник предоставляет именно те поля и границы, которые обещает договор. Не подменяйте это прогоном fixture.',
'<strong>Действие.</strong> Версионируйте договор и policy вместе: изменение фильтра, окна или target — это новое правило сравнения, а не косметика дашборда.',
'<strong>Следующий контроль.</strong> Перед спорным релизом попросите owner показать входные определения и evidence, затем выбрать обратимый шаг. Если evidence нет, статус — «решение отложено», а не «бюджет зелёный».',
]),
h2('Изменение формулы требует своего rollback'),
h2('Ограничения и следующий шаг'),
p('Этот текст не вычисляет настоящий error budget, real SLI, availability, burn rate, incident, traffic, alert или SLO compliance. Fixture не читает проект, не использует monitoring/CI/сеть/HTTP/SDK и не открывает production-данные. Он не устанавливает цель 99 для чьей-либо услуги, не утверждает, что 28 дней универсальны, и не создаёт release gate. Источники доступны до конца октября 2023, но их примеры не заменяют договор команды и не дают готовую формулу для неизвестного продукта.'),
p('Следующий шаг — взять одну существующую метрику и провести обратный путь: от цвета панели к точному user journey, eligible set, good outcome, window и policy. Если хотя бы один пункт невозможно восстановить, метрика пока diagnostic, а не основа для error budget. После этого можно сравнить fixed и rolling варианты на известном наборе данных в разрешённой среде и зафиксировать, какой вопрос каждый вариант отвечает. Выбирать можно не самый красивый процент, а самый проверяемый договор.'),
h2('Историческая граница октября 2023'),
p('Все источники в статье существовали к концу октября 2023: Google SRE Book имеет copyright 2017, а SRE Workbook и его Example SLO Document, Error Budget Policy и Alerting on SLOs — copyright или даты 2018 года. Поэтому материал не приписывает автору позднюю платформу и не ссылается на более новые методы как на обязательный стандарт. Уровень M6 здесь проявляется в границе модели, версии правила и обратимом решении, а не в лозунге о «культуре SRE». '),
]);
const field = revision({
slug: 'editorial-2023-10-field-sli-slo',
title: 'SLI/SLO в релизном разговоре: бюджет как петля решения, не как красный график',
categories: ['Надёжность', 'Наблюдаемость'],
cover: '/assets/editorial/2023/slo-error-budget-2023-decision-loop.svg',
excerpt: 'Как превратить SLI/SLO и error budget в воспроизводимый путь решения: от симптома к scope, данным, причинам и обратимому действию — без автоматического gate, выдуманного incident или декоративного burn rate.',
readingMinutes: 12,
}, [
p('Проблема релизного дня редко звучит технически чисто. На панели красный блок, кто-то говорит «бюджет почти закончился», другая команда просит не задерживать изменение, а on-call не может объяснить, какой пользовательский путь пострадал. Если принять решение только по цвету, можно остановить безопасное исправление или выпустить изменение в момент, когда полезнее сначала разобраться с надёжностью. Цена — не только риск для пользователя. Команда привыкает считать SLO отчётностью, потому что число не связывается с действием и ответственностью.'),
p('Обратная ошибка — превратить budget в автоматический запрет. Формула сама не знает, является ли изменение обратимым, закрывает ли оно security-уязвимость, относится ли к тому же пути и подтверждены ли входные данные. Без policy владелец узнаёт о решении уже после того, как CI или чей-то скрипт сделал необратимое действие. Цена — скрытые полномочия и плохая диагностика: красный график объясняет «что-то не так», но не показывает причину, границу или следующий безопасный шаг.'),
h2('Error budget нужен для вопроса, а не для победы в споре'),
p('Google SRE Book в главе 2017 года описывает error budget как способ обсудить допустимый риск между разработкой и надёжностью. В Example Error Budget Policy Google SRE Workbook от 19 февраля 2018 года отдельно сказано, что policy не является наказанием за missed SLO. Практический смысл для небольшой команды: заранее договориться, какое evidence нужно перед решением и какие обратимые действия допустимы. Тогда цифра не заканчивает разговор; она запускает один и тот же маршрут для релиза, reliability-задачи и разбора расхождения.'),
p('Это особенно важно, когда SLI — proxy. Низкая доля good событий может относиться к одному backend-ответу, но не объяснять весь пользовательский путь. Высокая доля может скрывать ошибку после точки измерения. Поэтому петля решения содержит два разных перехода: сначала проверить, что сигнал соответствует договору, затем искать причину в разрешённых данных. Нельзя перепрыгнуть из одного synthetic процента к выводу «был инцидент» или «нужно заблокировать production». Эти утверждения требуют других фактов и других владельцев.'),
table('Запись для решения вместо одного цвета', ['Шаг', 'Что должен принести owner', 'Допустимое действие', 'Чего не делает шаг'], [
['Сигнал', 'версия SLI-contract, scope, окно, target и time of observation', 'открыть разбор', 'не объявляет incident автоматически'],
['Проверка формулы', 'eligible/good definitions, exclusions и источник', 'остановить неверный расчёт', 'не доказывает причину деградации'],
['Контекст', 'диагностические данные разрешённой среды и known changes', 'сформировать гипотезу', 'не подменяет продуктовый impact'],
['Решение', 'owner, policy, обратимость и evidence', 'сузить rollout, исправить, отложить или продолжить с контролем', 'не выдаёт CI-право учебному fixture'],
['Повторная проверка', 'та же формула и критерий после действия', 'подтвердить или пересмотреть гипотезу', 'не обещает отсутствие следующих сбоев'],
]),
figure('/assets/editorial/2023/slo-error-budget-2023-decision-loop.svg', 'Петля решения: synthetic сигнал сверяется с SLI-контрактом, затем в разрешённой среде ищется контекст; owner выбирает обратимое действие и повторную проверку. У стрелки к релизу стоит ручное решение, а не автоматический gate.', 'Схема не является журналом инцидента, CI pipeline или мониторингом. Она не получает реальные SLI, availability, burn rate или данные о выпуске.'),
h2('Один сигнал не объясняет причину'),
p('SLI отвечает на заранее узкий вопрос: выполняется ли выбранная мера в выбранной границе. Он не обязан хранить корневую причину. Когда сигнал отклоняется, следующей задачей становится диагностика: изменение версии, зависимость, класс ответов, очередь, лимит, данные или сценарий клиента. Набор диагностических сигналов зависит от архитектуры и доступа. Нельзя заранее объявить, что trace, log или dashboard всегда дадут ответ. Но можно заранее назначить, кто проверяет scope и кто подтверждает, что данные относятся к одному и тому же периоду.'),
p('В Google SRE Workbook 2018 о SLO alerting подчёркнута разница между alerting metric и данными, которые помогают расследовать причину. Этот вывод не позволяет сказать «любая красная точка требует page». Он учит не смешивать роль меры и роль объяснения. Для одних путей нужен срочный response, для других — ticket, review или временное ограничение rollout. Сначала должна быть policy с владельцем и уровнями действий, а затем выбранные thresholds. Без этого alert просто переносит спор из чата в notification channel.'),
h2('Policy должна быть короткой и исполнимой людьми'),
p('Минимальная policy помещается в несколько строк: какие SLO относятся к релизу, кто владелец решения, какие данные обязательны, что считается обратимым действием, какие исключения требуют отдельного одобрения и когда договор пересматривают. Важно указать не только «при exhausted budget остановить изменения», но и что именно означает exhausted по версии формулы, какая проверка защищает от ложного сигнала и кто может разрешить security fix. Иначе правило будет жить только в памяти того, кто однажды настроил дашборд.'),
h2('Synthetic fixture оставляет полномочие у владельца'),
p('Здесь fixture специально не играет роль release gate. Он возвращает `proposal`, но всегда сохраняет `releaseAuthority=not-granted`. В хорошей synthetic ветке остаётся четыре условные единицы бюджета, поэтому proposal — review before change. В другой ветке условный budget exhausted, но это всё равно не incident и не команда остановить реальную выкладку. Fixture не видит code diff, security context, CI result, feature flag, owner approval или production traffic; выдавать ему полномочие было бы ошибкой модели.'),
code(fixtureCommand),
p('Запуск создаёт фиксированные JS-объекты в памяти и завершается. Он не читает проект, не вызывает monitoring, CI, сеть, HTTP, SDK или часы. У него нет реальных SLI, availability, incident, burn rate, event stream либо dashboard. Даже отрицательная ветка `synthetic-reliability-work-first` описывает только ожидаемую форму учебной развилки. PASS проверяет, что она не перепутана с разрешением на release, а не то, что какой-либо сервис достиг цели.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Релизная команда видит красный budget-показатель, но не может связать его с конкретным путём, окном и версией SLI-contract.',
'<strong>Причина.</strong> Сигнал, формула, диагностические данные и policy развивались отдельно; график получил вес решения, но не получил owner и границы полномочий.',
'<strong>Проверка формулы.</strong> Сверьте scope, good/eligible events, exclusions, окно, target и источник. Если версия правила неизвестна, остановите интерпретацию, а не релиз по умолчанию.',
'<strong>Проверка контекста.</strong> В разрешённой среде соберите минимальные данные для одной гипотезы и отделите их от synthetic fixture. Не называйте correlation или событие доказанными, пока источник не подтверждён.',
'<strong>Действие.</strong> Owner выбирает обратимый шаг согласно policy: сузить exposure, исправить причину, отложить non-urgent change или продолжить с явным контролем. Выбор фиксируется вместе с evidence.',
'<strong>Повтор.</strong> После действия примените ту же версию формулы и проверьте известный критерий. При расхождении пересматривайте гипотезу, а не подгоняйте denominator.',
]),
h2('План обратного действия должен предшествовать спору'),
p('Слова «если что, откатим» не являются policy. До изменения нужно назвать, что именно меняется: код, конфигурация, правило обработки или ограничение exposure. Затем — какую версию вернуть, кто это сделает, что будет считаться подтверждением и какие потребители затронуты. Если действие нельзя обратить, policy должна сказать это прямо и потребовать иной уровень review. Error budget полезен тем, что превращает такое обсуждение в ожидаемую часть работы, а не в реакцию на громкий график.'),
p('Учебный rollback нарочно скучный: он возвращает пять полей synthetic договора и говорит, что monitoring, CI и сеть не затронуты. Он не может остановить deploy или удалить записи, потому что ничего такого не создавал. В реальной системе это ограничение не повод делать вид, что rollback существует. Это повод вынести операционный план в отдельный runbook с теми системами, где у команды действительно есть права и наблюдение.'),
h2('Ограничения и следующий шаг'),
p('Пакет не показывает production incident, доступность, SLI, burn rate, error budget, алерт, CI run, релиз, пользователя или стоимость. Он не читает проект и не обращается к monitoring, CI, сети, HTTP, SDK, clock или dashboard. Все имена, события, количества и состояния имеют synthetic marker и существуют только в памяти Node. Google-источники задают исторические определения и примеры policy, но не предоставляют автоматический gate и не дают этому fixture права оценивать реальный риск.'),
p('Следующий шаг для команды — сделать одну реальную decision record до следующего спорного изменения. В ней должны быть SLI-contract version, owner, scope, источник evidence, окно, target, policy branch, обратимое действие и повторная проверка. Если не хватает хотя бы одной строки, не скрывайте пробел новой визуализацией. Сначала назначьте владельца и сузьте вопрос. Тогда SLO перестанет быть декорацией и станет способом честно выбрать следующий инженерный шаг.'),
h2('Историческая граница октября 2023'),
p('К октябрю 2023 уже были доступны Google SRE Book 2017 и Google SRE Workbook 2018 с примерами SLO document, error budget policy и SLO alerting. Статья опирается на эти документы, не добавляя поздние платформенные практики и не имитируя чужую production-историю. Автор уровня M6 здесь связывает измерение с owner, evidence и обратимостью, но не выдаёт учебную модель за статистику своей системы.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runSloFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
return;
}
const count = Object.keys(report.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');