diff --git a/editorial/agent-rewrites/213.json b/editorial/agent-rewrites/213.json index dd3258e..8c6d7a4 100644 --- a/editorial/agent-rewrites/213.json +++ b/editorial/agent-rewrites/213.json @@ -3,5 +3,5 @@ "slug": "editorial-2022-02-practice-performance-budget", "title": "Бюджет производительности: как не спрятать регрессию за общим PASS", "excerpt": "Как описать один пользовательский путь, разделить его бюджет на именованные части и остановить изменение, которое превышает допуск, даже если общий результат ещё выглядит зелёным.", - "contentHtml": "
На странице оформления следующий шаг появляется позже, чем ожидал пользователь. В отчёте при этом стоит зелёный PASS: общий размер страницы и суммарное время не вышли за предел. Команда добавляет небольшой виджет, ещё один стиль и дополнительный обработчик. Каждый коммит проходит проверку. Через несколько релизов один участок пути забирает весь запас, а итоговая цифра продолжает скрывать это смещение.
\nЦена ошибки — не абстрактная «медленная страница». Пользователь дольше ждёт перехода, чаще повторяет действие или закрывает вкладку. Инженер тратит время на поиск причины среди уже принятых изменений. Если бюджет проверяет только сумму, он не отвечает на главный вопрос: какой компонент потратил запас и что именно нужно остановить?
\nТезис статьи простой: бюджет производительности должен описывать конкретный пользовательский путь, условия измерения и несколько именованных ограничений. Общая сумма полезна как дополнительный предохранитель. Она не должна отменять провал отдельной части.
\nБюджет — это набор заранее названных пределов. Он может ограничивать размер JavaScript, число запросов, время навигации или другую метрику, которая связана с выбранным сценарием. Число имеет смысл только вместе с путём и условиями. Лимит для каталога нельзя без проверки перенести на оформление заказа: у страниц разные данные, изображения и порядок действий.
\nСначала назовите вход. В учебном примере это маршрут /training/checkout/review и сценарий anonymous-cart-with-one-item. Это не production-маршрут и не результат настоящего запуска. Длинное имя нужно намеренно: другой товар, авторизация, локаль или feature flag могут создать другой набор ресурсов.
Затем зафиксируйте условия. Запишите версию сборки, тип сети, состояние кеша, устройство или профиль CPU, способ запуска и источник чисел. Если часть условий неизвестна, не подставляйте правдоподобное значение. Пометка «не измерялось» лучше, чем вывод о браузере по одному числу из учебного объекта.
\n| Часть контракта | Пример значения | Что проверяет | Чего не доказывает |
|---|---|---|---|
| Route и scenario | /training/checkout/review, одна корзина | Сравнивает один и тот же вход | Качество всех страниц |
| Условия | Сборка, сеть и cache записаны | Показывает сопоставимость замеров | Поведение другой среды |
| Компоненты | document, style, script, render | Находит участок, который забрал запас | Автоматически не объясняет причину |
| Допуск | Limit и tolerance у каждого имени | Даёт правило остановки | Универсальную норму для любого продукта |
| Total | Вторичный предел суммы | Замечает общий рост | Разрешение на провал компонента |
Представьте четыре части с пределами 26, 17, 30 и 22 условных единицы. Их допустимая сумма равна 95. Baseline содержит 24, 15, 26 и 20. Candidate меняет только script: 34 вместо 26. Сумма candidate равна 93. По total он проходит. По правилу для script он превышает предел 30 и должен получить FAIL.
\nТакой пример показывает перенос затрат. Остальные части не стали дешевле из-за того, что скрипт стал тяжелее. Если проверять только сумму, рост в одном месте можно компенсировать случайным уменьшением в другом. Для пользователя это не всегда равноценная замена: лишний JavaScript может задержать обработчик, а уменьшение изображения не вернёт время, потерянное на главном потоке.
\nНиже код намеренно работает с числами, а не с браузером. Единица ticks придумана для примера. Она показывает инвариант проверки: провал компонента нельзя замаскировать зелёной суммой.
const budget = {\n route: '/training/checkout/review',\n scenario: 'anonymous-cart-with-one-item',\n components: {\n documentTicks: { limit: 26, tolerance: 1 },\n styleTicks: { limit: 17, tolerance: 1 },\n scriptTicks: { limit: 30, tolerance: 2 },\n renderTicks: { limit: 22, tolerance: 1 },\n },\n totalTicks: { limit: 95, tolerance: 3 },\n};\n\nconst baseline = {\n documentTicks: 24,\n styleTicks: 15,\n scriptTicks: 26,\n renderTicks: 20,\n};\n\nconst candidate = {\n ...baseline,\n scriptTicks: 34,\n};\n\nfunction checkBudget(snapshot, contract) {\n const components = Object.entries(contract.components).map(\n ([name, rule]) => ({\n name,\n value: snapshot[name],\n allowed: rule.limit + rule.tolerance,\n pass: snapshot[name] <= rule.limit + rule.tolerance,\n }),\n );\n\n const total = Object.values(snapshot).reduce((sum, value) => sum + value, 0);\n const totalAllowed = contract.totalTicks.limit + contract.totalTicks.tolerance;\n\n return {\n components,\n total,\n totalPass: total <= totalAllowed,\n pass: components.every((item) => item.pass) && total <= totalAllowed,\n };\n}\n\nconsole.log(checkBudget(candidate, budget));\nВ этой модели scriptTicks равен 34, а его allowed равен 32. Total равен 93, а его allowed равен 98. Полный результат обязан быть FAIL. Код не измеряет FCP, LCP, INP, сетевую задержку или работу CPU. Он не вызывает Lighthouse и не заменяет browser trace. В рабочем проекте поля нужно связать с конкретным источником измерения, иначе они остаются внутренней условной шкалой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Total проходит, component падает | Рост скрыт внутри суммы | Сравнить каждую именованную часть с её allowed | Остановить изменение и найти владельца части |
| Два запуска дают разные выводы | Не совпали сеть, cache, CPU или данные | Сопоставить conditions и route в обоих snapshots | Разделить сценарии или повторить измерение в фиксированных условиях |
| Число выросло, но причина неизвестна | Бюджет хранит результат без состава ресурсов | Проверить размер chunk, список запросов и источник метрики | Добавить детализацию, а не повышать лимит вслепую |
| Провал появляется только у одной локали | Сценарии смешаны в одном job | Запустить одинаковый route для каждой локали отдельно | Сделать локаль частью scenario или выделить отдельный budget |
| Старый snapshot проходит после смены сборщика | Baseline получен в другой версии среды | Сравнить версию сборки и формат измерения | Создать новый baseline и сохранить причину замены |
| Команда повышает лимит после каждого FAIL | Лимит используют как способ убрать сигнал | Проверить изменение ресурса и обоснование нового допуска | Сначала устранить рост или зафиксировать осознанное исключение |
Размер ресурсов и число запросов удобно проверять в сборке. Они дают ранний сигнал до открытия страницы в браузере. Такой сигнал отвечает на вопрос о составе артефакта, но не говорит, как быстро пользователь увидит или сможет использовать экран.
\nВременные метрики нужно получать из инструмента, который действительно их измеряет. API Navigation Timing даёт события навигации. PerformanceResourceTiming помогает увидеть интервалы загрузки отдельных ресурсов. Для интеракций нужны отдельные данные о событиях и отрисовке. Не называйте сумму размеров «временем до интерактивности» и не называйте синтетический snapshot полевой метрикой.
Сопоставимость важнее количества цифр. Один замер на быстром ноутбуке не устанавливает предел для всех телефонов. Среднее значение может скрыть хвост распределения. Если бюджет защищает конкретный путь, проверяйте тот же путь, тот же набор данных и тот же класс условий. Если это невозможно, добавьте в результат причину несопоставимости и остановите автоматическое сравнение.
\nБюджет не исправляет медленную страницу сам. Он только превращает выбранное ожидание в сигнал. Плохой маршрут, неверный baseline или неописанные условия дадут точный ответ на неверный вопрос.
\nЛимиты зависят от продукта. Экран с фотографиями, форма оплаты и текстовая статья имеют разный состав ресурсов. Одного числа для всех страниц обычно недостаточно. Разделяйте budgets по типу пути, но не создавайте отдельный лимит для каждого случайного варианта: так сигнал распадётся и станет необслуживаемым.
\nЛабораторные данные и данные реальных пользователей дополняют друг друга. Лабораторный запуск помогает сравнивать изменения в одинаковой среде. Полевые данные показывают разброс устройств, сетей и поведения. Учебный код из статьи не содержит ни тех, ни других данных. Он проверяет только логику контракта.
\nИногда рост оправдан функцией. Тогда измените бюджет вместе с описанием причины, владельцем и способом повторной проверки. Не повышайте лимит только для того, чтобы вернуть зелёный статус.
\nКонтракт готов, если другой инженер может открыть запись budget и ответить на четыре вопроса: какой путь проверяется, в каких условиях, какая часть превысила допуск и какое действие следует выполнить. Автоматическая проверка должна завершаться FAIL, когда один компонент превышает свой allowed, даже если total остаётся внутри общего предела.
\nМинимальный приёмочный набор выглядит так: baseline и candidate имеют одинаковые route, scenario и conditions; каждое значение неотрицательно и принадлежит известному полю; результат печатает component verdict и total verdict; положительный пример проходит; отрицательный пример из учебного кода падает на scriptTicks. Это проверяет механизм, но не заявляет production-эффект.
Эти источники описывают инструменты и понятие бюджета. Они не подтверждают числа из учебного примера и не дают готовый лимит для конкретного продукта.
" + "contentHtml": "Проблема начинается с маленького изменения: на странице оформления следующий шаг появляется позже, чем ожидает пользователь. В отчёте при этом стоит зелёный PASS: общий размер страницы и суммарное время не вышли за предел. Команда добавляет виджет, стиль и обработчик. Каждый коммит проходит проверку, но через несколько релизов один участок пути забирает весь запас.
\nЦена ошибки — не абстрактная «медленная страница». Пользователь дольше ждёт перехода, повторяет действие или закрывает вкладку. Инженер ищет причину среди уже принятых изменений. Если budget проверяет только сумму, он не отвечает на главный вопрос: какой компонент потратил запас и что нужно остановить?
\nВывод статьи простой: бюджет производительности должен описывать конкретный пользовательский путь, условия измерения и несколько именованных ограничений. Общая сумма полезна как дополнительный сигнал. Она не должна отменять провал отдельной части.
\nPerformance budget — это набор заранее названных лимитов для метрик, связанных с производительностью. В него можно включить размер страницы или скриптов, число HTTP-запросов и время загрузки в заданном сетевом сценарии. Это не универсальная норма: лимит имеет смысл только вместе с путём, данными и условиями измерения.
\nВ учебном примере входом служат маршрут /training/checkout/review и сценарий anonymous-cart-with-one-item. Это условный маршрут, а не результат запуска настоящего сайта. Другой товар, авторизация, локаль или feature flag могут изменить набор ресурсов, поэтому их нельзя молча смешивать с тем же budget.
Рядом с числами запишите версию сборки, сеть, состояние cache, устройство или профиль CPU, способ запуска и источник метрики. Если условие не измерялось, его нельзя восстанавливать по одному итоговому числу.
\n| Часть контракта | Пример значения | Что проверяет | Чего не доказывает |
|---|---|---|---|
| Route и scenario | /training/checkout/review, одна корзина | Сравнивает один и тот же вход | Качество всех страниц |
| Conditions | Сборка, сеть, cache и устройство записаны | Показывает сопоставимость замеров | Поведение другой среды |
| Components | document, style, script, render | Находит участок, который забрал запас | Автоматически не объясняет причину |
| Limit и tolerance | Допуск у каждого имени | Даёт правило остановки | Универсальную норму продукта |
| Total | Вторичный предел суммы | Замечает общий рост | Разрешение на провал компонента |
Возьмём четыре части с лимитами 26, 17, 30 и 22 условные единицы. Их сумма равна 95. В baseline значения — 24, 15, 26 и 20. Candidate меняет только script: 26 становится 34. Итог candidate равен 93, поэтому total проходит. Но для script allowed равен 32: limit 30 плюс tolerance 2. Именованный компонент должен получить FAIL.
Это перенос затрат. Остальные части не стали дешевле из-за тяжёлого скрипта. Если проверять только сумму, рост в одном месте можно случайно компенсировать уменьшением в другом. Для пользователя это не всегда равноценная замена: лишний JavaScript может задержать обработчик, а уменьшение картинки не уберёт работу на основном потоке.
\nДо сравнения candidate проверьте не только значения, но и сам вход. Код работает с frozen plain objects в памяти. Поля documentTicks, styleTicks, scriptTicks и renderTicks — условные единицы, а не названия браузерных метрик.
const contract = Object.freeze({\n route: '/training/checkout/review',\n scenario: 'anonymous-cart-with-one-item',\n conditions: Object.freeze({\n network: 'slow-3g',\n cache: 'cold',\n device: 'mobile',\n }),\n components: Object.freeze({\n documentTicks: Object.freeze({ limit: 26, tolerance: 1 }),\n styleTicks: Object.freeze({ limit: 17, tolerance: 1 }),\n scriptTicks: Object.freeze({ limit: 30, tolerance: 2 }),\n renderTicks: Object.freeze({ limit: 22, tolerance: 1 }),\n }),\n totalTicks: Object.freeze({ limit: 95, tolerance: 3 }),\n});\n\nconst baseline = {\n route: contract.route,\n scenario: contract.scenario,\n conditions: { ...contract.conditions },\n values: {\n documentTicks: 24,\n styleTicks: 15,\n scriptTicks: 26,\n renderTicks: 20,\n },\n};\n\nconst candidate = {\n ...baseline,\n values: { ...baseline.values, scriptTicks: 34 },\n};\n\nfunction hasExactKeys(value, expectedKeys) {\n if (!value || typeof value !== 'object') return false;\n const actualKeys = Object.keys(value);\n return actualKeys.length === expectedKeys.length\n && expectedKeys.every((key) => actualKeys.includes(key));\n}\n\nfunction sameRecord(left, right) {\n const keys = Object.keys(right);\n return hasExactKeys(left, keys)\n && keys.every((key) => left[key] === right[key]);\n}\n\nfunction checkBudget(snapshot, expected) {\n const names = Object.keys(expected.components);\n if (snapshot.route !== expected.route\n || snapshot.scenario !== expected.scenario\n || !sameRecord(snapshot.conditions, expected.conditions)) {\n return { status: 'INVALID', reason: 'measurement-contract-mismatch' };\n }\n\n if (!snapshot.values || !hasExactKeys(snapshot.values, names)\n || !names.every((name) =>\n Number.isFinite(snapshot.values[name]) && snapshot.values[name] >= 0\n )) {\n return { status: 'INVALID', reason: 'invalid-component-values' };\n }\n\n const components = names.map((name) => {\n const rule = expected.components[name];\n const allowed = rule.limit + rule.tolerance;\n const value = snapshot.values[name];\n return { name, value, allowed, pass: value <= allowed };\n });\n const total = names.reduce((sum, name) => sum + snapshot.values[name], 0);\n const totalAllowed = expected.totalTicks.limit\n + expected.totalTicks.tolerance;\n const totalPass = total <= totalAllowed;\n\n return {\n status: components.every((item) => item.pass) && totalPass\n ? 'PASS'\n : 'FAIL',\n components,\n total,\n totalAllowed,\n totalPass,\n };\n}\n\nconsole.log(checkBudget(candidate, contract));\n// status: 'FAIL', scriptTicks: 34 > 32, totalPass: true\nФункция вернёт FAIL по scriptTicks: 34 больше allowed 32. Сумма равна 93 и меньше totalAllowed 98, но totalPass: true не меняет общего verdict. Если route, scenario, conditions или состав полей расходятся, результатом будет INVALID, а не ложная регрессия.
Проверка не измеряет браузер. Она проверяет инвариант контракта: известные неотрицательные числа сравниваются по отдельности и затем суммируются. В рабочем проекте нужно отдельно получить эти числа, сохранить источник и связать каждое поле с владельцем измерения.
\n| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| Total проходит, component падает | Рост скрыт внутри суммы | Сравнить каждую часть с её allowed | Остановить изменение и найти владельца части |
| Два запуска дают разные выводы | Не совпали сеть, cache, CPU или данные | Сопоставить conditions и route | Повторить в фиксированных условиях или разделить сценарии |
| Число выросло, причина неизвестна | Сохранён результат без состава | Проверить размер chunk, запросы и источник | Добавить детализацию, а не повышать лимит вслепую |
| Провал виден только у одной локали | Сценарии смешаны в одном job | Запустить тот же route отдельно | Включить локаль в scenario или выделить budget |
| Старый snapshot проходит после смены сборщика | Baseline получен в другой среде | Сравнить версию и формат данных | Создать baseline с причиной замены |
| После каждого FAIL повышают лимит | Лимит используют для удаления сигнала | Проверить ресурс и обоснование | Устранить рост или зафиксировать исключение |
Размер ресурсов и число запросов удобно проверять на этапе сборки. Они рано показывают состав артефакта и помогают остановить тяжёлую зависимость. Но одинаковый вес не гарантирует одинаковое восприятие: критический ресурс может прийти позже, а порядок загрузки меняет полезный результат.
\nДля времени навигации браузер предоставляет Navigation Timing. Через PerformanceNavigationTiming можно получить запись текущего документа и разобрать интервалы ответа сервера, DOMContentLoaded и load. Эти значения описывают навигацию; они не превращают условные ticks из примера в реальные миллисекунды.
Для отдельных ресурсов используется PerformanceResourceTiming. Он даёт временную шкалу загрузки и сведения о размере ресурса. Для cross-origin ресурсов подробные поля могут быть недоступны без Timing-Allow-Origin. Отсутствие данных нельзя принять за нулевую задержку.
Не называйте сумму размеров «временем до интерактивности» и не называйте один синтетический snapshot полевой метрикой. В budget укажите класс источника, окно наблюдения, сегмент устройств и способ агрегации.
\nBudget не исправляет медленную страницу. Он превращает выбранное ожидание в сигнал. Плохой route, неверный baseline или неописанные conditions дадут точный ответ на неверный вопрос.
\nЛимиты зависят от продукта. Экран с фотографиями, форма оплаты и текстовая статья имеют разный состав ресурсов. Одного числа для всех страниц недостаточно. Разделяйте budgets по типу пути, но не создавайте отдельный лимит для каждого случайного варианта: сигнал распадётся и станет необслуживаемым.
\nЛабораторные данные помогают сравнивать изменения в одинаковой среде. Полевые данные показывают разброс устройств, сетей и поведения посетителей. Учебный код не содержит ни тех, ни других данных. Он проверяет только логику контракта.
\nИногда рост оправдан функцией. Тогда измените budget вместе с причиной, владельцем и способом повторной проверки. Не повышайте лимит только для зелёного статуса.
\nКонтракт готов, если другой инженер может ответить на четыре вопроса: какой путь проверяется, в каких условиях, какая часть превысила допуск и какое действие следует выполнить. Автоматическая проверка должна завершаться FAIL, когда один компонент превышает свой allowed, даже если total остаётся внутри общего предела.
\nМинимальный набор таков: baseline и candidate имеют одинаковые route, scenario и conditions; состав полей совпадает; каждое значение неотрицательно и конечно; результат печатает component verdict и total verdict; положительный пример проходит; отрицательный пример падает на scriptTicks. Это проверяет механизм, но не заявляет эффект на реальном трафике.
Timing-Allow-Origin.ticks в browser measurement.