diff --git a/editorial/agent-rewrites/089.json b/editorial/agent-rewrites/089.json index 47d7b36..fc91dd5 100644 --- a/editorial/agent-rewrites/089.json +++ b/editorial/agent-rewrites/089.json @@ -1,7 +1,8 @@ { "index": 89, "slug": "editorial-2025-07-mechanism-product-metrics", - "title": "Рост метрики не равен улучшению продукта: проверяем denominator и guardrail", - "excerpt": "Практический способ проверить продуктовую метрику до решения: зафиксировать событие, cohort, attribution, окно и denominator, затем сопоставить локальный сигнал с guardrail и остановить вывод при разрыве данных.", - "contentHtml": "
На дашборде treatment показывает conversion 52%, а control — 48%. Команда готовит выпуск. Через день выясняется, что в treatment считали уникальных открывших экран, а в control — все строки события. Ещё часть подтверждений пришла без связи с вариантом. Числа выглядят аккуратно, но сравнивают разные множества.
\nЦена ошибки — не только неверный график. Команда может раскатить изменение, которого пользователь не заметил, потерять доверие к аналитике и потратить следующий спринт на поиск причины. Если рядом выросли отказы, задержка или отмены, локальный рост conversion скрывает ущерб.
\nТезис простой: метрика становится основанием для решения только вместе с контрактом измерения. Контракт называет субъектов, событие, cohort, attribution, период, numerator, denominator и guardrail. Любое нарушение контракта должно остановить product decision. Пустой или неполный результат не следует трактовать как нулевой эффект.
\nЗапись 52 / 100 ничего не говорит без описания ста субъектов и пятидесяти двух действий. В продуктовой метрике нужно сначала определить population, затем выбрать единицу счёта. Если один пользователь повторил событие три раза, число строк и число пользователей отвечают на разные вопросы.
Учебный пример ниже считает conversion по уникальным субъектам. Numerator — субъекты с checkout_confirmed. Denominator — субъекты с checkout_opened. Обе группы ограничены одним cohort и одним днём. Guardrail считает render_failed среди открывших. Это фиксированные значения для иллюстрации. Они не описывают production и не доказывают эффект.
const events = [\n { event: 'checkout_opened', subject: 'u-1', cohort: 'control', period: '2025-07-14' },\n { event: 'checkout_confirmed', subject: 'u-1', cohort: 'control', period: '2025-07-14' },\n { event: 'checkout_opened', subject: 'u-2', cohort: 'control', period: '2025-07-14' },\n { event: 'render_failed', subject: 'u-2', cohort: 'control', period: '2025-07-14' },\n { event: 'checkout_opened', subject: 'u-3', cohort: 'treatment', period: '2025-07-14' },\n { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment', period: '2025-07-14' },\n { event: 'checkout_opened', subject: 'u-4', cohort: 'treatment', period: '2025-07-14' },\n];\n\nconst unique = (name, cohort) => new Set(\n events.filter((x) => x.event === name && x.cohort === cohort)\n .map((x) => x.subject),\n).size;\n\nconst conversion = unique('checkout_confirmed', 'treatment')\n / unique('checkout_opened', 'treatment');\nconst guardrail = unique('render_failed', 'control')\n / unique('checkout_opened', 'control');\n\n// Учебный результат: treatment conversion = 0.5,\n// control guardrail = 0.5. Это не production-вывод.\nВ этом наборе treatment conversion равна 1 из 2, а control guardrail — 1 из 2. Эти дроби нужны, чтобы показать форму вычисления, а не чтобы объявить treatment лучше. В реальной системе дополнительно проверяют распределение вариантов, задержку доставки, повторные события, идентификаторы, окно наблюдения и статистическую неопределённость.
\nattribution связывает действие с вариантом. Например, подтверждение можно отнести к treatment, если у события есть тот же subject и request или сохранённый exposure key. Если связи нет, система не должна угадывать. Она возвращает hold и причину missing-attribution.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Conversion выросла сразу после изменения tracking | Пропали открытия или изменился способ deduplication | Сравнить число субъектов, строк и долю доставки каждого события до и после изменения | Остановить интерпретацию; проверить instrumentation и denominator |
| Treatment и control имеют разные размеры | Нарушилось распределение вариантов или одна группа потеряла события | Сверить ожидаемое и наблюдаемое соотношение, exposure и data-quality metric | Не объявлять победителя; найти источник mismatch |
| Подтверждение не содержит cohort или request | Нет правила attribution | Проверить event schema и цепочку от exposure до outcome | Вернуть hold; не приписывать outcome варианту |
| Local metric растёт, но растут ошибки рендера | Выигрыш куплен ухудшением соседнего шага | Посчитать guardrail по той же population и тому же окну | Сверить порог с владельцем риска и остановить выпуск при нарушении |
| В одном отчёте смешаны два дня | Query собрал разные окна | Проверить period на каждой записи и границы окна | Пересобрать выборку; не усреднять разрыв молча |
Положительный путь означает не «метрика хорошая». Он означает, что измерение прошло базовые проверки и может попасть к владельцу решения. Минимальный результат содержит definition, population, период, attribution, guardrail и список ограничений.
\nfunction inspect(report) {\n const reasons = [];\n\n if (!report.attribution) reasons.push('missing-attribution');\n if (report.denominator !== 'unique-opened-subjects') {\n reasons.push('wrong-denominator');\n }\n if (report.periods.length !== 1) reasons.push('mixed-period');\n if (report.guardrailRate > report.guardrailLimit) {\n reasons.push('guardrail-breached');\n }\n\n return reasons.length === 0\n ? { status: 'eligible-for-human-review', reasons: [] }\n : { status: 'hold', reasons };\n}\n\n// Код иллюстрирует stop conditions.\n// Он не читает telemetry и не принимает решение о выпуске.\nВ отрицательном пути нет попытки «починить» данные средним значением или подстановкой default cohort. Если период смешан, выборку пересобирают. Если denominator изменился, заново описывают метрику. Если нет attribution, чинят схему события или правила связи. Если guardrail превышен, владелец риска решает, допустимо ли продолжать. Функция не должна скрывать эти причины в null или в зелёном статусе.
Такое разделение защищает от двух подмен. Первая — движение локальной метрики превращают в причинное объяснение. Вторая — техническую проверку превращают в автоматический ship. Инспектор может сказать «условия расчёта выполнены» или «расчёт остановлен». Он не может доказать, что изменение вызвало результат, если дизайн и данные этого не показывают.
\nSuccess metric отвечает на вопрос о желаемом результате. Local metric помогает понять ближайший шаг. Guardrail ограничивает цену улучшения. Например, форма может увеличить число подтверждений, но одновременно повысить ошибки рендера или отмены. Если guardrail появляется только после обсуждения успеха, команда уже выбрала удобную рамку.
\nGuardrail должен иметь population, окно, единицу счёта и владельца порога. «Ошибок стало больше» недостаточно. Нужны доля, база и правило: например, render_failed unique subjects / checkout_opened unique subjects в том же cohort и периоде. Порог задают до интерпретации результата. Его не следует подбирать после того, как local metric уже выросла.
Отдельная data-quality metric проверяет, можно ли доверять самой выборке. Она не является guardrail пользовательского опыта. Sample-ratio mismatch, потеря exposure или резкий провал доставки событий могут остановить анализ раньше, чем команда посмотрит conversion. Это отрицательный путь измерения, а не доказательство плохого продукта.
\nКонтракт метрики не заменяет дизайн эксперимента. Он не доказывает случайное распределение, достаточную мощность, отсутствие сезонности или причинный эффект. Небольшой fixed набор в примере не моделирует реальный трафик. Имена u-1 и u-2 не являются советом хранить открытые идентификаторы пользователя.
Событие с корректным именем всё равно может потеряться в клиенте, задержаться в очереди или попасть в другую систему времени. Поэтому проверка схемы должна сопровождаться проверкой доставки и задержки. Для финансовых, медицинских и других чувствительных сценариев нужны отдельные правила приватности, retention и доступа.
\nПроверяемый критерий готовности такой: независимый инженер по записи может восстановить population, numerator, denominator, cohort, период, attribution и guardrail. На валидном наборе система возвращает eligible-for-human-review, а на каждом специально испорченном наборе — hold с причиной. Ни один путь не публикует результат и не запускает rollout автоматически. Если критерий не выполняется, сначала ремонтируют измерение.
На дашборде treatment показывает conversion 52%, а control — 48%. Команда готовит выпуск. На разборе выясняется, что в treatment считали уникальных открывших экран, а в control — все строки события. Часть подтверждений пришла без связи с вариантом. Проценты выглядят убедительно, но сравнивают разные множества.
\nЦена ошибки — не только неверный график. Команда может раскатить изменение, которого пользователь не заметил, потерять доверие к аналитике и потратить следующий спринт на поиск причины. Если одновременно выросли ошибки рендера, задержка или отмены, локальный рост conversion скрывает ущерб.
\nМетрика становится основанием для инженерного решения только вместе с контрактом измерения. В нём явно записаны субъект, событие успеха, population, cohort, attribution, окно, numerator, denominator и guardrail. При нарушении контракта результат получает статус hold: неполные данные нельзя выдавать за нулевой эффект или за победу варианта.
Conversion — это не свойство экрана или кнопки, а дробь над выбранной population. До запроса к хранилищу ответьте на пять вопросов: кого считаем, какое событие открывает воронку, какое событие считается успехом, к какому варианту относим субъекта и в каком окне ждём результат.
\nВ этой статье учебный контракт такой: subject — обезличенный идентификатор пользователя, population — субъекты с checkout_opened, numerator — субъекты с checkout_confirmed, единица счёта — один субъект. Оба события должны относиться к одному cohort и дню. Поэтому формула выглядит так:
conversion = unique(subject where event = checkout_confirmed)\n / unique(subject where event = checkout_opened)\nЕсли один субъект нажал кнопку трижды, три строки могут быть полезны для диагностики повторов, но не должны превращать одного человека в трёх участников знаменателя. Если вопрос другой — например, «сколько подтверждений на тысячу попыток» — это допустимая другая метрика. Её нельзя молча сравнивать с пользовательской conversion.
\nПервый источник разрыва — смена единицы счёта. Запрос по строкам события может показать рост после того, как клиент начал отправлять повторный checkout_confirmed. Запрос по уникальным субъектам этот повтор уберёт. Оба запроса технически корректны, но отвечают на разные вопросы.
Второй источник — несовместимые population. Если знаменатель treatment строится по открывшим checkout, а знаменатель control — по всем посетителям, разница отражает состав групп, а не поведение продукта. В отчёте рядом с каждой долей должны быть абсолютные значения: numerator, denominator, число уникальных субъектов и число сырых строк.
Третий источник — неверная attribution, то есть привязка outcome к exposure и варианту. Подтверждение без subject, exposure_id или времени нельзя надёжно приписать treatment. При отсутствии связи система должна возвращать причину missing-attribution, а не выбирать вариант по последнему известному значению.
Событие само по себе тоже имеет контракт. В официальной спецификации OpenTelemetry событие — это именованное происшествие с временем возникновения и структурированными атрибутами; динамические идентификаторы не должны попадать в имя события. Для продуктовой аналитики это означает практическое правило: имя вроде checkout_confirmed остаётся стабильным, а subject, exposure_id и cohort хранятся отдельными полями. Спецификация не определяет вашу conversion, поэтому остальные поля нужно согласовать в проекте.
Сохраните следующий фрагмент как metrics-example.mjs и запустите командой node metrics-example.mjs. Набор намеренно мал: в нём видны дедупликация субъектов и отдельный guardrail. Числа учебные и не описывают production-трафик.
const events = [\n { event: 'checkout_opened', subject: 'u-1', cohort: 'control' },\n { event: 'checkout_confirmed', subject: 'u-1', cohort: 'control' },\n { event: 'checkout_opened', subject: 'u-2', cohort: 'control' },\n { event: 'render_failed', subject: 'u-2', cohort: 'control' },\n { event: 'checkout_opened', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_opened', subject: 'u-4', cohort: 'treatment' },\n];\n\nfunction uniqueSubjects(eventName, cohort) {\n return new Set(\n events\n .filter((item) => item.event === eventName && item.cohort === cohort)\n .map((item) => item.subject),\n );\n}\n\nfunction ratio(numerator, denominator) {\n if (denominator === 0) return { status: 'hold', reason: 'empty-denominator' };\n return { status: 'ok', value: numerator / denominator };\n}\n\nfor (const cohort of ['control', 'treatment']) {\n const opened = uniqueSubjects('checkout_opened', cohort);\n const confirmed = uniqueSubjects('checkout_confirmed', cohort);\n const failed = uniqueSubjects('render_failed', cohort);\n const failedAfterOpen = [...failed].filter((subject) => opened.has(subject)).length;\n\n console.log(cohort, {\n conversion: ratio(\n [...confirmed].filter((subject) => opened.has(subject)).length,\n opened.size,\n ),\n renderFailure: ratio(failedAfterOpen, opened.size),\n openedSubjects: opened.size,\n confirmedSubjects: confirmed.size,\n });\n}\nОжидаемый результат: в каждой группе conversion равна 1 / 2 = 0.5. В control guardrail рендера тоже равен 1 / 2, а в treatment — 0. Повторное подтверждение u-3 не меняет conversion, потому что множество удаляет дубликат. Обратите внимание: этот код не проверяет, что вариант был назначен случайно, и не доказывает причинный эффект.
Перед расчётом в рабочем отчёте полезно хранить не только число, но и описание запроса:
\n{\n \"name\": \"checkout_confirmation_rate\",\n \"unit\": \"unique_subject\",\n \"population\": \"checkout_opened\",\n \"outcome\": \"checkout_confirmed\",\n \"cohortKey\": \"checkout_v2\",\n \"period\": \"2025-07-14T00:00:00Z/2025-07-15T00:00:00Z\",\n \"attribution\": \"same subject and exposure_id\",\n \"guardrails\": [\"render_failure_rate\", \"cancel_rate\"]\n}\nТакой объект не является универсальным стандартом. Это минимальный проектный шаблон, который делает запрос проверяемым через ревью, повторный запуск и сравнение версий схемы.
\nAttribution нужно определить до просмотра результата. Для короткого checkout можно требовать тот же subject, связанный exposure_id и outcome после exposure. Для отложенной покупки понадобится другое окно и, возможно, серверное событие. Нельзя переносить правило из одного продукта в другой только потому, что названия событий совпадают.
События должны различать время, когда действие произошло, и время, когда его приняла аналитическая система. Задержка доставки может сделать вчерашнее окно неполным. Практический отчёт поэтому содержит event_time, received_at и дату среза. Пока данные ещё догружаются, статус отчёта — pending, а не «конверсия равна нулю».
Проверяйте и границы окна: включается ли начало, исключается ли конец, что делать с часовыми поясами, когда субъект открыл экран до полуночи, а подтвердил после неё. Одна и та же граница должна применяться treatment и control. Смешанное окно — причина пересобрать выборку.
\nSuccess metric отвечает на вопрос о желаемом результате. Local metric показывает ближайший шаг. Guardrail ограничивает цену улучшения: ошибки рендера, отмены, задержку или обращение в поддержку. Guardrail — не украшение отчёта, а условие, при котором рост success metric перестаёт быть приемлемым.
\n| Симптом | Что проверить | Безопасное действие |
|---|---|---|
| Conversion выросла сразу после изменения tracking | Число субъектов, сырые строки, deduplication и долю доставки каждого события до и после изменения | Остановить интерпретацию и восстановить прежнее определение denominator |
| Treatment и control имеют неожиданное соотношение | Exposure, распределение вариантов, пропуски и задержку событий | Не объявлять победителя; проверить sample-ratio и pipeline |
| Outcome не содержит cohort или exposure_id | Схему события и цепочку от exposure до результата | Вернуть hold: missing-attribution |
| Local metric растёт вместе с ошибками | Guardrail на той же population и в том же окне | Сравнить с заранее заданным порогом и привлечь владельца риска |
| События пришли после закрытия окна | Разницу между event_time и received_at | Пометить отчёт pending или пересобрать окно |
Порог guardrail задают до чтения результата и связывают с владельцем риска. Формулировка «ошибок стало больше» не годится: нужны числитель, знаменатель, окно и действие при нарушении. Если допустимый порог неизвестен, отчёт может показать наблюдение, но не должен сам объявлять выпуск безопасным.
\nПоложительный путь означает, что расчёт прошёл проверки и может попасть к человеку, принимающему решение. Он не означает, что изменение уже доказанно улучшает продукт. Отрицательный путь возвращает конкретную причину и сохраняет выборку для исправления.
\nfunction inspect(report) {\n const reasons = [];\n\n if (!report.attribution) reasons.push('missing-attribution');\n if (report.denominator !== 'unique-opened-subjects') {\n reasons.push('wrong-denominator');\n }\n if (report.periods.length !== 1) reasons.push('mixed-period');\n if (report.dataQuality !== 'ok') reasons.push('data-quality');\n if (report.guardrailRate > report.guardrailLimit) {\n reasons.push('guardrail-breached');\n }\n\n return reasons.length === 0\n ? { status: 'eligible-for-human-review', reasons: [] }\n : { status: 'hold', reasons };\n}\n\n// Инспектор проверяет условия расчёта.\n// Он не читает telemetry и не запускает rollout.\nВ отрицательном пути нет подстановки default cohort, усреднения пропущенных событий или выбора удобного denominator. Если период смешан, выборку пересобирают. Если attribution отсутствует, чинят схему или правило связи. Если guardrail нарушен, владелец риска принимает отдельное решение. Функция не должна прятать эти причины в null или зелёный статус.
Controlled rollout помогает разделить эксперимент и доставку: в работе Microsoft Research описаны одновременное сравнение вариантов, поэтапное расширение аудитории, работа с exposed populations, длительностью и pass criteria. Это поддерживает порядок проверки, но не делает учебные числа доказательством и не заменяет статистический дизайн конкретного эксперимента.
\nhold с конкретными причинами.Контракт метрики не доказывает случайное распределение, достаточную мощность, отсутствие сезонности или причинный эффект. Он отвечает на более узкий вопрос: одинаково ли определены данные, на которых построено сравнение. Для причинного вывода нужны подходящий дизайн эксперимента, длительность и статистический анализ.
\nМаленький набор в примере не моделирует реальный трафик. В production дополнительно проверяют идемпотентность отправки, потерю событий в клиенте, задержку очереди, часовые пояса, приватность, retention и доступ к идентификаторам. В финансовых, медицинских и других чувствительных сценариях эти требования нельзя заменить одним полем subject.
Критерий готовности воспроизводим: независимый инженер по записи может восстановить population, numerator, denominator, cohort, период, attribution, качество данных и guardrail. На валидном наборе инспектор возвращает eligible-for-human-review, а на каждом специально испорченном наборе — hold с причиной. Ни один путь не публикует результат и не запускает rollout автоматически.
После ускорения checkout на графике выросла conversion. Команда готовит rollout. Через несколько дней выясняется: повторные открытия перестали попадать в denominator, а часть ошибок рендера исчезла из отчёта вместе с событием. Пользователи не стали чаще подтверждать заказ. Изменился способ счёта.
Цена ошибки — решение по ложному сигналу. Команда выпускает изменение, тратит время на обратное расследование и теряет возможность сравнить варианты в одном окне. Если ошибка затрагивает платежный или регистрационный путь, к этому добавляются незавершённые операции и обращения в поддержку.
Тезис: продуктовая метрика для инженера — это не имя на дашборде, а контракт. Он связывает техническое изменение с наблюдаемым действием, задаёт cohort, период и denominator, а рядом держит guardrail. При разрыве связи расчёт должен остановиться. Число без этих условий не становится доказательством.
Техническое изменение само по себе не является продуктовым результатом. Предзагрузка формы может сократить ожидание. Сокращённое ожидание может изменить долю открывших checkout, которые нажали confirm. Но между этими утверждениями стоят события, идентификаторы и правила включения.
Для каждого измерения назовите пять звеньев:
Например, гипотеза звучит так: «Предзагрузка формы увеличит долю подтверждений среди пользователей, открывших checkout, но не повысит долю render failure». Это проверяемая цепочка. Формулировка «сделаем экран быстрее и поднимем conversion» цепочки не содержит.
Событие отвечает на вопрос «что произошло», а его поля — на вопросы «с кем», «в каком варианте», «когда» и «как связать шаги». Имя вроде checkout_confirmed полезнее произвольного button_click, но одного имени мало. Два одинаковых события могут относиться к разным вариантам и разным попыткам.
Минимальный учебный контракт может выглядеть так:
const event = {\n name: 'product.checkout_confirmed',\n subjectId: 'u-17',\n cohort: 'treatment',\n period: '2025-07-14',\n requestId: 'r-204',\n schemaVersion: 1,\n};\n\n// Учебный объект в памяти. Он не отправляет telemetry\n// и не показывает результат реального продукта.subjectId нужен, чтобы повторная доставка события не увеличила denominator. cohort не следует восстанавливать по текущему флагу: пользователь мог увидеть один вариант, а запросить данные после переключения флага. period не даёт смешать окна. requestId связывает открытие, подтверждение и техническую ошибку одной попытки.
OpenTelemetry разделяет traces, metrics и logs как разные сигналы наблюдаемости. Это полезная граница: latency можно увидеть в span, число ошибок — в metric, а контекст конкретной попытки — в log или event. Но сама телеметрия не создаёт product contract. Владелец решения должен заранее определить, какие сигналы отвечают на его вопрос.
Учебная локальная метрика может быть записана так:
conversion(cohort, period) =\n unique subjects with checkout_confirmed\n /\n unique subjects with checkout_opened\n\nrenderFailureRate =\n unique subjects with render_failed\n /\n unique subjects with checkout_openedОбе дроби используют одну базу opened, один cohort и один период. Это не универсальное определение conversion. Реальный продукт может считать заказ, оплату или завершённую сессию иначе. Важно другое: правило нельзя менять между вариантами, а его состав нужно хранить рядом с результатом.
Если один пользователь открыл checkout три раза, denominator по subjects равен одному, а не трём. Если повторная попытка имеет другой смысл для продукта, это решение нужно зафиксировать до подсчёта. Нельзя выбрать удобный вариант после просмотра результата.
Attribution связывает outcome с показанным вариантом. Если подтверждение пришло без requestId, расчёт не должен молча принять его. Оно могло относиться к старому экрану, другой вкладке или повторной попытке. В этом случае правильный статус — остановка с причиной missing-attribution-rule, а не нулевая conversion.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Conversion выросла сразу после изменения | Из denominator исчезли повторные или ошибочные открытия | Сравнить множества unique subjects и правило включения до и после | Остановить интерпретацию и восстановить сопоставимый denominator |
| Confirm есть, но вариант неизвестен | Нет attribution или requestId | Проверить связь opened, confirmed и cohort для каждой попытки | Вернуть hold, добавить ключ связи и тест отрицательного пути |
| Treatment лучше control, но даты различаются | Смешаны cohort или period | Сверить период каждого события и источник cohort | Пересобрать окна и не сравнивать текущие числа |
| Локальная метрика растёт вместе с отказами | Guardrail не включён в решение | Посчитать render failure на той же базе opened | Остановить rollout и разобрать технический путь отказа |
| На графике появились нули | Пайплайн не отличает отсутствие данных от нулевого результата | Проверить статус расчёта и причины отклонения | Показывать stop reason отдельно от числового значения |
| Число меняется после повторного запуска | Дубликаты событий или плавающее окно | Проверить idempotency по subjectId, requestId и period | Зафиксировать дедупликацию и повторить расчёт |
Предположим, есть два cohort и один день наблюдения. В каждом варианте два пользователя открыли checkout. В treatment один пользователь подтвердил действие. В control один пользователь подтвердил действие. У treatment дополнительно зафиксирован один render failure.
В таком маленьком наборе обе conversion равны 0,5. Это не результат эксперимента и не основание для запуска. Он показывает только форму контракта: одинаковые знаменатели, явный cohort и guardrail рядом. Если удалить cohort у одного confirm, расчёт должен остановиться, даже если арифметика всё ещё возможна.
function evaluate(events) {\n const required = events.every((event) =>\n event.subjectId && event.cohort && event.period && event.requestId\n );\n\n if (!required) {\n return { status: 'hold', reason: 'missing-attribution-rule' };\n }\n\n const opened = unique(events, 'checkout_opened', 'subjectId');\n const confirmed = unique(events, 'checkout_confirmed', 'subjectId');\n const failures = unique(events, 'render_failed', 'subjectId');\n\n return {\n status: 'eligible-for-human-review',\n conversion: confirmed.size / opened.size,\n renderFailureRate: failures.size / opened.size,\n };\n}Функция учебная. В ней нет проверки случайного распределения, задержки доставки, часовых поясов, privacy-политики или достаточного размера выборки. Она иллюстрирует отрицательный путь: отсутствие обязательного поля переводит расчёт в hold до деления. В production это правило должно жить в проверяемом pipeline, а не только в тексте.
Такая схема не заменяет экспериментальный дизайн. Она не доказывает randomization, причинность, статистическую значимость или долгосрочный outcome. Она не исправляет потерю событий и не знает, был ли пользователь заблокирован сетью. Она только делает условия сравнения явными и не даёт незаметно продолжить при нарушенном контракте.
Один guardrail не покрывает все риски. Для платежа важны отказ и незавершённая операция. Для регистрации — доступность и повторная отправка. Для медленного интерфейса — время до действия и ошибки клиента. Выбирайте guardrail по цене конкретного ухудшения, а не по удобству существующего дашборда.
Нельзя выдавать рост local metric за рост выручки или удовлетворённости. Нельзя считать отсутствие события нулевым значением без проверки доставки. Нельзя сравнивать cohort, собранные разными версиями схемы, если вы не доказали сопоставимость. Если это невозможно, честный результат — hold и план исправления данных.
Проверка готова, когда другой инженер может по decision record восстановить гипотезу, cohort, период, numerator, denominator, attribution, guardrail и owner. Для каждого числа есть источник событий. Для каждого stop reason есть воспроизводимый вход. Повторный запуск на том же окне даёт тот же результат.
Минимальный набор доказательств — контракт событий, пример успешного расчёта, три отрицательных проверки, сравнение local metric с guardrail и запись ограничения. Только после этого human owner выбирает rollout, hold или rollback. Если связь между изменением и outcome не доказана, система не обязана выдавать красивую цифру. Она обязана показать, где цепочка оборвалась.
Представим изменение checkout: форма должна открываться без дополнительного ожидания. На следующий день conversion выросла с 42% до 47%, и команда готовит rollout. Но при проверке выясняется, что после релиза часть событий checkout_opened перестала отправляться, а повторное открытие теперь считается иначе. Пользователи не обязательно стали чаще подтверждать заказ. Изменился способ подсчёта.
Это не задача про красивый дашборд. Инженеру нужно установить, что именно поменялось, кого сравнивают, какое действие считается результатом и что может отменить локальный выигрыш. Если одного звена нет, число следует пометить как непригодное для решения, а не превращать пропуск в нулевую конверсию.
Тезис: продуктовая метрика — это контракт между изменением и решением. В контракте заранее указаны событие, cohort (сравниваемая группа), период, population (множество пользователей или попыток), числитель, denominator (знаменатель) и guardrail — показатель побочного риска. Такой контракт не доказывает причинность сам по себе, но делает ошибку измерения видимой до релиза.
Слово «conversion» не говорит, какое действие нужно совершить. Один и тот же термин может означать подтверждение формы, создание заказа или успешную оплату. Поэтому начните с решения: rollout, hold (пауза до проверки), rollback или сбор дополнительных данных.
Для рассматриваемого checkout формулировка может быть такой: «Разрешить увеличение доли treatment после того, как доля подтверждений среди пользователей, открывших checkout, не ниже control, а доля ошибок рендера не выросла». Здесь есть вариант изменения, основной outcome и ограничитель риска. Фраза «ускорим экран и поднимем conversion» оставляет все три части неопределёнными.
Разделите технический сигнал и пользовательский результат. Время ответа API описывает один вызов, но не сообщает, дождался ли пользователь экрана и завершил ли действие. OpenTelemetry разделяет traces, metrics и logs: путь запроса, измерение во времени и запись события. Это граница диагностики, а не готовая product metric.
Хорошая гипотеза помещается в одну проверяемую цепочку: изменение → наблюдаемый шаг → outcome → guardrail → решение. Для checkout это выглядит так:
checkout_opened после фактического отображения checkout.checkout_confirmed.render_failed на базе открывших checkout не растёт относительно control.Ключевой вопрос здесь — что является единицей анализа. Если продукт оценивает людей, denominator состоит из уникальных пользователей. Если важна каждая попытка оплаты, единицей становится попытка с отдельным идентификатором. Нельзя считать пользователей в числителе и попытки в знаменателе: такая дробь выглядит точной, но отвечает не на тот вопрос.
Также зафиксируйте окно измерения. Cohort, собранный по текущему значению feature flag, может быть неверным: флаг успели переключить после показа старой версии. Надёжнее сохранить назначенный вариант в событии экспозиции или в неизменяемом контексте попытки. Атрибуция — правило, связывающее outcome с реально увиденным вариантом.
Имя button_click почти ничего не говорит о результате. Для решения нужны имя события, версия схемы, обезличенный идентификатор субъекта, cohort, период и идентификатор попытки. Не отправляйте в telemetry email, номер карты или свободный текст пользователя.
const event = {\n name: 'checkout_confirmed',\n schemaVersion: 1,\n subjectId: 'u-17',\n cohort: 'treatment',\n period: '2025-07-14',\n requestId: 'r-204',\n occurredAt: '2025-07-14T10:21:03Z'\n};\n\n// Это пример записи в памяти. Он не отправляет событие\n// и не является результатом работы реального checkout.subjectId позволяет убрать повторную доставку одного события. requestId связывает открытие, подтверждение и ошибку одной попытки. cohort нужно записывать в момент назначения варианта, а не вычислять задним числом по текущему флагу. period задаёт окно сравнения и помогает не смешать данные разных версий схемы.
Trace ID связывает запросы между сервисами, а product request ID задаёт единицу расчёта; подменять их можно только после явного решения.
Отсутствие обязательного поля — это не «неизвестный пользователь» и не нулевая конверсия. Это отдельный статус качества данных. При нём расчёт должен вернуть hold с причиной, которую можно найти в логах и исправить.
Для локального учебного сравнения зададим одну базу: уникальные пары subjectId + requestId, у которых есть checkout_opened. Тогда для каждого cohort считаем:
conversion(cohort) =\n opened attempts with checkout_confirmed\n /\n unique opened attempts\n\nrenderFailureRate(cohort) =\n opened attempts with render_failed\n /\n unique opened attemptsЭто не универсальное определение conversion. Платёжный продукт может считать только подтверждённый заказ, успешное списание или завершённую сессию. Важна не выбранная формула, а её неизменность между вариантами и наличие источника для каждого множества.
| Поле | Что фиксируем | Что сломается без него | Проверка |
|---|---|---|---|
| Outcome | checkout_confirmed, а не любой клик | Локальный сигнал примут за завершённое действие | Сверить событие с бизнес-операцией |
| Cohort | Вариант, назначенный до действия | Control и treatment смешаются | Сравнить assignment с событием экспозиции |
| Period | Единое окно и часовой пояс | Варианты будут сравниваться в разные дни | Проверить границы окна и версию схемы |
| Denominator | Одна база открывших checkout | Рост дроби появится из-за пропавших открытий | Посчитать уникальные ключи до деления |
| Attribution | Связь outcome с той же попыткой | Старое или чужое подтверждение попадёт в результат | Проверить пару subjectId + requestId |
| Guardrail | Ошибка рендера на той же базе | Локальный выигрыш скроет ухудшение | Сопоставить риск с заранее заданным порогом |
Если один пользователь открыл checkout три раза, выбор между «один пользователь» и «три попытки» должен быть сделан до просмотра результата. Для продуктовой воронки чаще нужна одна единица на пользователя; для надёжности платёжного вызова может быть важна каждая попытка. Оба решения допустимы в разных задачах, но их нельзя молча смешивать.
Ниже — самостоятельный скрипт Node.js без внешних пакетов. Сохраните его в файл metrics-check.mjs и выполните node metrics-check.mjs. В наборе есть повторное событие открытия: оно не увеличивает denominator. Все строки вымышлены и нужны только для проверки алгоритма.
const events = [\n { name: 'checkout_opened', subjectId: 'u-1', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\n { name: 'checkout_opened', subjectId: 'u-1', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\n { name: 'checkout_confirmed', subjectId: 'u-1', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\n { name: 'checkout_opened', subjectId: 'u-2', cohort: 'control', period: '2025-07-14', requestId: 'r-2' },\n { name: 'checkout_opened', subjectId: 'u-3', cohort: 'treatment', period: '2025-07-14', requestId: 'r-3' },\n { name: 'checkout_confirmed', subjectId: 'u-3', cohort: 'treatment', period: '2025-07-14', requestId: 'r-3' },\n { name: 'checkout_opened', subjectId: 'u-4', cohort: 'treatment', period: '2025-07-14', requestId: 'r-4' },\n { name: 'render_failed', subjectId: 'u-4', cohort: 'treatment', period: '2025-07-14', requestId: 'r-4' }\n];\n\nconst required = ['subjectId', 'cohort', 'period', 'requestId'];\nconst key = (event) => event.subjectId + ':' + event.requestId;\n\nfunction evaluate(input) {\n const invalid = input.find((event) =>\n required.some((field) => !event[field])\n );\n if (invalid) return { status: 'hold', reason: 'missing-required-field' };\n\n const periods = new Set(input.map((event) => event.period));\n if (periods.size !== 1) return { status: 'hold', reason: 'mixed-period' };\n\n const cohorts = [...new Set(input.map((event) => event.cohort))];\n const metrics = cohorts.map((cohort) => {\n const inCohort = input.filter((event) => event.cohort === cohort);\n const opened = new Set(inCohort.filter((event) => event.name === 'checkout_opened').map(key));\n const confirmed = new Set(inCohort.filter((event) => event.name === 'checkout_confirmed').map(key));\n const failed = new Set(inCohort.filter((event) => event.name === 'render_failed').map(key));\n const confirmedAfterOpen = [...confirmed].filter((item) => opened.has(item)).length;\n const failedAfterOpen = [...failed].filter((item) => opened.has(item)).length;\n if (opened.size === 0) return { cohort, status: 'hold', reason: 'empty-denominator' };\n return {\n cohort,\n opened: opened.size,\n confirmed: confirmedAfterOpen,\n conversion: confirmedAfterOpen / opened.size,\n renderFailed: failedAfterOpen,\n renderFailureRate: failedAfterOpen / opened.size\n };\n });\n return { status: 'eligible-for-human-review', period: input[0].period, metrics };\n}\n\nconsole.log(JSON.stringify(evaluate(events), null, 2));Ожидаемый результат для основного набора: у control opened: 2 и conversion: 0.5; у treatment те же opened: 2 и conversion: 0.5, но renderFailureRate: 0.5. Это не основание для rollout: guardrail показывает риск, а маленький искусственный набор не даёт статистического вывода.
Теперь добавьте к массиву событие без requestId и снова запустите команду:
events.push({\n name: 'checkout_confirmed',\n subjectId: 'u-5',\n cohort: 'treatment',\n period: '2025-07-14'\n});Результат должен стать {\\\"status\\\":\\\"hold\\\",\\\"reason\\\":\\\"missing-required-field\\\"}. Такой отрицательный путь важнее подстановки нуля: он сообщает, что дробь нельзя интерпретировать, пока не восстановлена атрибуция. В production дополнительно нужны дедупликация на уровне хранилища, обработка запаздывающих событий, политика хранения и контроль доступа к данным.
Когда число изменилось сразу после релиза, сначала ищите разрыв в измерении. Следующая таблица задаёт короткий маршрут расследования.
| Симптом | Рабочая гипотеза | Проверка | Действие |
|---|---|---|---|
| Conversion выросла сразу | Пропали открытия или изменился фильтр включения | Сравнить множества уникальных opened до и после | Поставить hold и восстановить общий denominator |
| Есть confirm, но нет варианта | Потеряна атрибуция при отправке события | Найти assignment и requestId для каждой попытки | Исключить неподтверждённые строки и исправить контракт |
| Treatment лучше control, но окна разные | Смешаны period или часовые пояса | Сверить границы периода и версию схемы | Пересобрать оба cohort в одном окне |
| Outcome растёт вместе с отказами | Guardrail не участвовал в решении | Посчитать render failure на базе opened | Остановить rollout и проверить технический путь |
| На графике появился ноль | Пустые данные выданы за нулевой результат | Проверить статус загрузки и stop reason | Разделить «нет данных» и «значение равно нулю» |
| Повторный запуск даёт другое число | Дубликаты или плавающее окно | Сверить ключ дедупликации и зафиксированный period | Повторить расчёт после исправления входа |
Рядом с числом храните размер cohort, numerator, denominator, долю пропусков, версию схемы и время построения: это помогает отличить изменение поведения от сбоя pipeline.
eligible-for-human-review или hold, но rollout, rollback и интерпретацию бизнес-результата утверждает ответственный человек.Эта схема делает расчёт проверяемым, но не превращает его в доказательство причинности. Она не проверяет случайное распределение, статистическую мощность, длительность эффекта, сезонность, interference между пользователями и корректность самого бизнес-события. Для таких вопросов нужен отдельный дизайн эксперимента и статистическая проверка.
Нельзя считать отсутствие события нулевым результатом: оно может означать сбой клиента, блокировку сети, задержку доставки или изменение схемы. Нельзя сравнивать cohort, собранные разными правилами, даже если итоговые проценты выглядят рядом. Нельзя выдавать рост локальной conversion за рост выручки, удовлетворённости или удержания без связи с соответствующими исходами.
Один guardrail не описывает всю цену изменения. Для оплаты это могут быть отказы, незавершённые операции и обращения в поддержку; для регистрации — повторная отправка и доступность; для медленного интерфейса — время до полезного состояния и ошибки клиента. Выбирайте ограничители по реальному риску, а не по тому, какой график уже есть.
Если персональные данные попадают в событие, прежде чем расширять сбор, нужны правила минимизации, доступа и срока хранения. Пример выше использует вымышленные технические идентификаторы и не является готовым шаблоном политики приватности.
Проверка готова, когда другой инженер может по записи решения восстановить гипотезу, единицу анализа, cohort, период, numerator, denominator, атрибуцию, guardrail и владельца. Для каждого числа известен источник событий. Для каждого hold есть воспроизводимый вход и понятное действие по исправлению.
Минимум — контракт схемы, локальный прогон, положительный пример, отрицательные проверки, сравнение outcome с guardrail и ограничения. Microsoft Research показывает на инфраструктурных A/B-тестах, почему одной серверной latency недостаточно: изменения в сети и backend могут усилить задержку на пользовательском пути, а ошибки телеметрии способны испортить ранние scorecard. Это аргумент в пользу нескольких независимых сигналов, а не готовый порог для любого продукта.
Итоговое правило простое: сначала доказать, что число считается одинаково, затем обсуждать, что оно означает. Если связь между изменением и outcome оборвалась, честный результат — hold с причиной, а не точный процент без смысла.
Заявка отправилась, ошибок нет, но разработчик не знает, что будет дальше. Он ждёт подтверждения, ищет владельца в чате и повторяет запрос. Через сорок минут результат появляется. Формально инструмент сработал. Практически он оставил человека без следующего шага.
\nЦена такой ошибки складывается из нескольких частей. Разработчик теряет время. Support повторно объясняет маршрут. Владелец получает сообщения, которые нельзя связать с конкретным этапом. Команда видит среднее время ответа, но не видит, где именно возникло ожидание. Если сразу менять интерфейс или добавлять автоматические повторы, можно ускорить не тот участок.
\nТезис. Удобство внутреннего инструмента нельзя вывести из одного времени ожидания. Сначала нужно разделить четыре факта: событие в системе, то, что понял человек, сигнал поддержки и действие владельца. Только после сопоставимой проверки можно говорить об изменении пути. Один учебный пример ниже показывает этот принцип; его данные не описывают реальный сервис.
\nЛюбой путь состоит из этапов. Для заявки на доступ это могут быть открытие задачи, отправка запроса, начало ожидания согласования, получение подтверждения и появление результата. События фиксируют порядок и время. Они не объясняют причину задержки.
\nПричина может находиться в очереди согласований, в правах, в другой системе или в тексте интерфейса. В последнем случае человек ждёт не потому, что операция медленная. Он не понимает, кому адресован следующий шаг. Разница важна: таймаут лечит медленную операцию, но не лечит неясного владельца.
\nПоэтому время полезно использовать как адрес проверки. Корзина 30m–1h сообщает, что между двумя событиями есть заметный промежуток. Она не сообщает, сколько людей столкнулись с ним, почему он возник и помогло ли изменение.
Ниже — фиксированная модель без реальных пользователей, заявок, сетевых запросов и production-данных. Роль platform-engineer отправляет запрос на учебный доступ к sandbox. В 09:03 задача открыта. В 09:04 запрос отправлен. В 09:05 начинается ожидание согласования. В 09:41 приходит подтверждение. В 09:45 появляется учебный результат.
В модели есть ещё два поля. UX-наблюдение: «после отправки неясно, кто отвечает за следующий шаг». Сигнал поддержки: routing-unclear. Эти записи не доказывают, что каждый пользователь испытывает то же самое. Они только формулируют две проверяемые гипотезы: задержка связана с очередью или с маршрутом; подсказка с владельцем может уменьшить число неопределённых обращений.
const journey = {\n stages: [\n ['request.submitted', '09:04'],\n ['approval.wait.started', '09:05'],\n ['approval.received', '09:41'],\n ['result.confirmed', '09:45']\n ],\n waitBucket: '30m–1h',\n observation: 'owner-unclear',\n supportSignal: 'routing-unclear',\n claim: 'not-established'\n};\nПоле claim намеренно не говорит «инструмент улучшен» или «инструмент плох». Учебная запись содержит один путь и не содержит группы сравнения. Она не показывает частоту, распределение, стоимость ожидания и причинность. Её роль — не доказать эффект, а не дать перепутать разные виды данных.
| Симптом | Возможная причина | Проверка | Действие |
|---|---|---|---|
| После отправки человек спрашивает «кто отвечает?» | Владелец этапа не виден | Проверить путь до отправки и текст статуса | Показать владельца и следующий шаг |
| Долгий интервал между двумя событиями | Очередь, право или внешний процесс | Сопоставить этап, роль и источник времени | Исправить узкое место или объяснить ожидание |
| Растёт число ручных обходов | Неясный маршрут либо срочная задача | Разделить причины обращений поддержки | Изменять только подтверждённую часть пути |
| После изменения среднее время ниже | Изменились роль, задача или состав данных | Сравнить одинаковые границы и период | Оставить вывод открытым при несопоставимости |
| Один яркий отзыв требует срочного решения | Сигнал приняли за масштаб проблемы | Проверить частоту и альтернативные объяснения | Назначить узкую проверку без общего обещания |
Системное событие отвечает на вопрос «что произошло и когда». Оно может показать, что ожидание началось в 09:05 и закончилось в 09:41. Оно не отвечает на вопрос «почему».
\nНаблюдение отвечает на вопрос «что человек понял или не понял». Формулировка «не вижу владельца» полезна для интерфейса. Но она не показывает число таких случаев и не доказывает, что новая подпись решит проблему.
\nСигнал поддержки показывает тему обращения. Категория routing-unclear помогает найти направление, но не заменяет подсчёт обращений и не доказывает, что маршрут вызвал ожидание. Решение владельца описывает выбранное изменение. Оно ещё не является результатом изменения.
Эта граница защищает от отрицательного пути. Если после добавления владельца среднее время стало меньше, но вместе с этим изменилась задача или источник данных, сравнение нельзя считать честным. Если обращений стало меньше, но пользователи начали бросать заявки, снижение поддержки не равно улучшению. Если нет сопоставимых данных, вывод остаётся not-established.
Сравнение требует одинаковой задачи, роли, порядка этапов и набора свидетельств. Иначе изменение может появиться из-за другой нагрузки, другой очереди или другого способа считать время. Сравнительная граница не создаёт причинность сама по себе. Она только убирает очевидные подмены.
\nНужно заранее назвать ожидаемое свидетельство. Например: в той же роли человек видит владельца до отправки, проходит тот же этап и реже создаёт обращение с категорией routing-unclear. Даже такое свидетельство требует осторожности. Оно не доказывает, что исчезла вся cognitive cost. Оно проверяет одну часть маршрута.
Ограниченное окно проверки тоже не означает статистический результат. Оно задаёт срок, в который владелец возвращается к вопросу. Если источник данных не определён, окно сравнения не спасает ситуацию. Правильное действие — остановить утверждение и уточнить, что именно можно проверить.
\nnot-established и не приписывать эффект.Учебная модель не содержит реального workflow, telemetry, тикетов, пользователей, сетевых ответов и production-результатов. Время 09:03–09:45, роль и категории — искусственные значения. Их нельзя использовать как бенчмарк, KPI или прогноз. Иллюстрации также показывают учебную схему, а не состояние конкретного инструмента.
\nДаже реальные данные имеют пределы. Событие может потерять контекст. Support-сигнал может отражать только тех, кто решил написать. Среднее время скрывает длинный хвост ожидания. Изменение интерфейса может перевести вопрос в другой канал. Поэтому один показатель нельзя объявлять ответом за весь путь.
\nЕсть и отрицательный вариант: команда не может законно или технически получить сопоставимые данные. Тогда не нужно заменять их впечатлением, красивой диаграммой или единичным отзывом. Можно исправить очевидную ошибку текста, уточнить владельца или записать вопрос для будущей проверки. Но эффект такого действия не следует объявлять установленным.
\nРазбор готов, когда для одной задачи можно показать упорядоченные события, источник каждого сигнала, роль, владельца, ограничение сравнения и условие остановки. После изменения есть повторная запись с той же задачей и ролью. Она содержит заранее названное свидетельство. Если хотя бы одного элемента нет, готово только описание проблемы, а не вывод об улучшении.
\nРазработчик клонирует репозиторий, запускает команду и получает сообщение «готово». Через час он всё ещё не сделал первое изменение: неясно, где взять тестовые данные, кто выдаёт доступ и какой результат считать успешным. Такой путь часто называют медленным, хотя в нём смешаны ожидание внешнего решения, ручные действия и отсутствие обратной связи.
\nЦена ошибки — не только потерянные минуты. Человек повторяет команды, пишет в поддержку и создаёт обходной скрипт. Владелец инструмента видит среднее время выполнения, но не знает, на каком шаге пользователь остановился. Если в ответ добавить ещё одну кнопку или увеличить таймаут, можно ускорить уже быстрый участок и оставить настоящий блокер.
\nРабочий тезис. Developer experience (DX, опыт разработчика) нужно проверять как путь конкретной задачи до наблюдаемого результата. Время — один сигнал. К нему нужны упорядоченные события, наблюдение самого разработчика и причина обращения в поддержку. Ниже — учебная модель, которую можно воспроизвести локально. Она не описывает реальный сервис и не выдаёт данные за production-измерение.
\nНачните не с вопроса «удобен ли инструмент», а с результата, который можно увидеть. Для локального запуска это может быть зелёная проверка и первое принятое изменение в тестовой ветке. Для внутреннего API — успешный запрос с ожидаемым ответом. Для шаблона проекта — старт приложения и прохождение smoke-теста.
\nГраница должна включать одного пользователя, одну роль и одну задачу. «Запустить новый сервис» слишком широко: в него попадут доступ к репозиторию, секреты, база данных и CI. Возьмите меньший путь: «получить sandbox-доступ, изменить текст на странице, выполнить проверку». Тогда можно назвать начало, конец и условия остановки.
\n| Поле | Пример | Зачем оно нужно |
|---|---|---|
| Роль | new-contributor | Не смешивать новичка и владельца сервиса |
| Начало | task.started | Зафиксировать, когда человек действительно начал путь |
| Конец | check.passed | Отделить полезный результат от запуска команды |
| Ожидание | env.ready → change.applied | Проверить внешний или ручной блокер |
| Отрицательный исход | blocked: owner-unknown | Не считать незавершённую задачу быстрым обходом |
Эти имена — проектное соглашение статьи, а не обязательный стандарт. В вашем проекте они могут быть другими. Важно, чтобы событие имело источник, время и понятного владельца; иначе одинаковое слово будет означать разные этапы в разных командах.
\nПолное время до результата удобно представить как сумму участков: TTFG = setup + waiting + work + verification, где TTFG — время до первого успешного изменения. Формула помогает выбрать следующий вопрос, но не объясняет причину автоматически.
setup — действия до готового окружения: установка зависимостей, получение доступа, загрузка фикстур. waiting — время, когда следующий шаг зависит от владельца, очереди или внешней системы. work — действия разработчика после готовности среды. verification — проверка результата. Если записать только начало и конец, все четыре участка сольются в одну «медленную» операцию.
Для инструментированной части полезна трассировка. В OpenTelemetry span представляет операцию с началом, концом и атрибутами, а span event — значимую точку времени внутри операции. Это позволяет связать серверное ожидание с одним путём, но не позволяет узнать, что человек делал в терминале до первого запроса. Человеческое наблюдение и системный span дополняют друг друга, а не подменяют.
\nПредставим фиксированную задачу для роли new-contributor: открыть проект, получить sandbox-доступ, изменить заголовок и пройти проверку. В 09:00 задача начата. В 09:06 окружение готово. В 09:24 изменение применено. В 09:29 проверка прошла. Между готовностью и изменением — 18 минут, но из одних временных меток нельзя узнать, были ли это ожидание доступа, чтение инструкции или исправление ошибки.
К задаче добавлены два независимых сигнала: наблюдение «непонятно, кто выдаёт доступ» и категория поддержки owner-unknown. Они формируют гипотезу, а не доказывают её: возможно, владелец не указан в интерфейсе; возможно, доступ уже выдан, но команда запускается с неверным профилем. Следующая проверка должна различить эти объяснения.
const journey = {\n role: 'new-contributor',\n task: 'sandbox-first-change',\n events: [\n ['task.started', '09:00'],\n ['env.ready', '09:06'],\n ['change.applied', '09:24'],\n ['check.passed', '09:29']\n ],\n observation: 'access-owner-unclear',\n supportReason: 'owner-unknown',\n claim: 'hypothesis-only'\n};\n\nconsole.table(journey.events);\nПоле claim намеренно ограничивает вывод. Одна учебная запись не показывает частоту, медиану, хвост распределения и причинность. Она нужна, чтобы проверить схему данных и не объявить случай улучшением. Если в реальном сервисе нельзя безопасно связать события с одной задачей, сначала решите проблему корреляции, а не стройте дашборд из несвязанных чисел.
У сигнала должна быть узкая область применимости. Событие отвечает на вопрос «что произошло и когда». Наблюдение отвечает на вопрос «что понял или не понял человек». Обращение в поддержку показывает тему, которую пользователь посчитал препятствием. Решение владельца фиксирует действие. Ни один из них сам по себе не доказывает улучшение DX.
\n| Сигнал | Что он показывает | Чего он не показывает | Следующая проверка |
|---|---|---|---|
События task.started и check.passed | Длительность пути для связанной записи | Почему человек ждал | Разложить путь на интервалы и источники |
| Span или span event | Время операции и её контекст в системе | Действия вне инструментированной системы | Сопоставить trace с задачей без лишних персональных данных |
| Наблюдение разработчика | Непонятный термин, шаг или владелец | Масштаб проблемы и причинность | Повторить сценарий с несколькими участниками |
| Категория поддержки | Повторяющийся тип обращения | Все случаи, включая молчаливый отказ | Считать обращения вместе с завершением задачи |
| Среднее время до результата | Агрегированное значение выбранной группы | Длинный хвост и смену состава группы | Сравнить медиану, p90 и долю завершивших |
Это особенно важно для среднего. Один случай с ожиданием в два дня может исчезнуть в среднем значении, если девять задач завершились за минуту. Медиана показывает типичный путь, p90 — верхний хвост, а доля завершивших не даёт принять незавершённую задачу за быструю. Выбирайте показатель по вопросу, который задаёте, а не по тому, который уже есть в панели.
\nНиже — локальный расчёт без пакетов, сети и production-доступа. Сохраните JavaScript в файл dx-check.mjs, проверьте синтаксис командой node --check dx-check.mjs, затем запустите node dx-check.mjs. Нужна версия Node.js, поддерживающая ECMAScript modules; числа в примере искусственные.
const events = [\n ['task.started', '09:00'],\n ['env.ready', '09:06'],\n ['change.applied', '09:24'],\n ['check.passed', '09:29']\n];\n\nconst toMinutes = (clock) => {\n const [hours, minutes] = clock.split(':').map(Number);\n return hours * 60 + minutes;\n};\n\nconst duration = (from, to) =>\n toMinutes(events.find(([name]) => name === to)[1]) -\n toMinutes(events.find(([name]) => name === from)[1]);\n\nconst result = {\n timeToFirstGreen: duration('task.started', 'check.passed'),\n setup: duration('task.started', 'env.ready'),\n waitingHypothesis: duration('env.ready', 'change.applied'),\n verification: duration('change.applied', 'check.passed')\n};\n\nconsole.log(result);\n// { timeToFirstGreen: 29, setup: 6, waitingHypothesis: 18, verification: 5 }\nРезультат означает только длительности учебной последовательности. Название waitingHypothesis напоминает, что 18 минут ещё нужно объяснить. Чтобы проверить гипотезу, добавьте источник ожидания: например, событие access.requested от сервиса доступа и отметку выдачи. Не добавляйте в telemetry содержимое секретов, токены, персональные данные или полный текст команд. Для идентификатора достаточно минимального технического ключа с понятным сроком хранения и контролем доступа.
Одно и то же наблюдение может вести к разным решениям. Если владелец этапа неизвестен, исправьте маршрут и текст статуса. Если доступ выдан, но CLI читает не тот профиль, исправьте диагностику и сообщение об ошибке. Если запрос стоит в очереди, меняйте очередь или показывайте честное состояние ожидания. Если окружение ломается из-за отсутствующей зависимости, добавьте проверку prerequisites, а не инструкцию «попробуйте ещё раз».
\n| Причина | Малое изменение | Цена и риск | Критерий успеха |
|---|---|---|---|
| Не виден владелец | Показать owner и следующий шаг до отправки | Нужно поддерживать актуальность маршрута | Меньше обращений owner-unknown при той же доле завершения |
| Нет prerequisites | Команда предварительной проверки с конкретным исправлением | Проверка может замедлить быстрый путь | Ошибка выявляется до длинного запуска |
| Очередь доступа | Статус, время обновления и ссылка на владельца очереди | Нельзя обещать срок, которым управляет другая команда | Ожидание видно, а повторные заявки не растут |
| Нет подтверждения результата | Явная проверка и ссылка на лог | Нужно выбрать стабильный smoke-тест | Разработчик сам отличает успех от частичного запуска |
Не делайте все изменения сразу. Маленькая партия сохраняет причинную связь между изменением и наблюдением. Документация Google Cloud, описывая DevOps-возможности, отдельно связывает поддерживаемость кода, обратную связь, наблюдаемость и работу малыми партиями с улучшением доставки. Это ориентир для выбора практики, но не доказательство эффекта именно в вашей команде.
\nСравнивайте одну и ту же задачу, роль, ветку процесса и определение конца. Зафиксируйте период и версию инструмента. Считайте отдельно завершённые и незавершённые пути. Минимальный набор для учебного эксперимента: количество стартов, доля check.passed, медиана TTFG, p90 TTFG, медиана ожидания и частота причин поддержки.
После добавления подсказки «владелец доступа» среднее время может уменьшиться случайно: в новую выборку попали опытные разработчики, очередь была короче или часть людей перестала создавать заявки. Поэтому корректная формулировка звучит так: «в этой выборке при этих границах показатель изменился». Утверждение «подсказка сократила время» требует более сильного дизайна сравнения — например, стабильных когорт или контролируемого эксперимента.
\nРекомендация GOV.UK применима здесь как методическая граница: performance metrics полезно сочетать с исследованием пользователей, а для целого пути смотреть на завершение задачи и время её выполнения. Она не задаёт универсальный KPI для внутренних инструментов. Ваши показатели должны следовать задаче и цене ошибки: иногда важнее доля успешного запуска, иногда — отсутствие ручного доступа к секретам.
\nУчебные времена 09:00–09:29, роль, события, категории поддержки и ожидаемый вывод выдуманы. Их нельзя использовать как бенчмарк, KPI, прогноз или свидетельство работы конкретного продукта. Локальный скрипт проверяет арифметику четырёх событий, но не проверяет права, сеть, корректность telemetry, работу очереди и поведение реального клиента.
\nТрассировка не видит молчаливый отказ: человек мог бросить задачу до первого запроса. Обращения в поддержку отражают только тех, кто написал. События могут потерять контекст при ретрае или повторном запуске. Агрегаты могут скрыть различия между ролями, операционными системами и уровнями доступа. Поэтому любые сравнения делайте с явной схемой семплирования, сроком хранения и правилами приватности.
\nЕсли нельзя связать начало и конец одной задачи или неясно, кто владеет этапом, честный результат — «данных недостаточно для вывода». Можно исправить очевидную ошибку инструкции, но не приписывать ей измеренный эффект. Для публичного или критичного сервиса дополнительно нужны security review, нагрузочная проверка, план отката и согласование с владельцами данных.
\nРазбор готов, когда для одной задачи можно восстановить путь от старта до результата, отличить системное ожидание от человеческой неопределённости, назвать владельца каждого перехода и показать повторную проверку. Если есть только красивый график времени, это ещё не доказательство улучшения DX.
\nЗаявка во внутреннем инструменте может завершиться успешно, а разработчик всё равно не поймёт, кто отвечает за следующий шаг. Он ищет владельца в чате, повторяет уже введённые данные и держит задачу открытой до непонятного результата. Ошибка редко видна в статусе: система показывает approved, но не показывает, почему путь занял время и что делать при тишине.
Цена такой ошибки — не только минуты. Теряется контекст, растёт поток уточнений, support повторяет одну и ту же инструкцию, а команда может начать переделку по единичному громкому отзыву. Если измерить только время от submit до результата, эти причины смешаются.
Тезис. Удобство внутреннего инструмента нужно проверять на границе одной задачи. Контракт должен отделять факт перехода от того, как его понял человек, от сигнала поддержки, решения владельца и доказательства эффекта. Пока сопоставимого сравнения нет, вывод остаётся not-established.
Задача — это не экран и не весь сервис. Это путь одной объявленной роли от ясного входа к проверяемому результату. Например, роль инженера открывает запрос на доступ к sandbox. Вход можно сформулировать так: «запрос отправлен с указанным окружением». Результат — «доступ подтверждён и его можно проверить». Между ними видны этапы: открытие, отправка, начало ожидания approval, получение approval и подтверждение результата.
У каждого этапа должны быть имя, порядок, время факта и источник записи. Время факта отвечает на вопрос «когда переход произошёл». Время наблюдения отвечает на другой вопрос: «когда источник его зафиксировал». Если эти значения совпадают в учебном примере, это не обещает такой же доставки в реальной системе.
OpenTelemetry разделяет traces, metrics и logs как разные сигналы. В его семантических соглашениях событие несёт timestamp момента, когда оно произошло. Это полезная дисциплина для контракта: событие и измерение нельзя заменять свободным текстом. Но стандарт не выбирает за команду UX-метрику и не доказывает причину задержки.
| Поле | Зачем нужно | Проверка | Что нельзя выводить |
|---|---|---|---|
declaredRole | Описывает, для кого рассматриваем путь. | Роль совпадает с объявленной записью. | Она не равна реальному пользователю или его правам. |
stage и порядок | Показывают, где находится переход. | Список этапов фиксирован и упорядочен. | Порядок не объясняет причину ожидания. |
occurredAt и observedAt | Разделяют время факта и время фиксации. | ISO-время, occurredAt ≤ observedAt, хронология. | Время не измеряет cognitive cost. |
waitBucket | Даёт диапазон без ложной точности. | Корзина разрешена только для нужного этапа. | Корзина не является оценкой DX. |
evidenceKind и source | Не дают наблюдению притвориться событием. | Для каждого вида задана допустимая пара. | Источник сам по себе не делает тезис причинным. |
knownUnknowns | Сохраняют пробелы рядом с решением. | Есть непустой список конкретных неизвестных. | Неизвестное нельзя заменить удобной догадкой. |
Строгий контракт нужен не ради красивого JSON. Он задаёт место отказа. Лишнее поле вроде unboundedScore меняет смысл записи и должно быть отвергнуто так же, как пропущенное обязательное поле. Разреженный массив, неверная версия модели или неизвестный источник должны закрывать проверку. Иначе один слой назовёт запись событием, другой — наблюдением, а третий построит на ней решение.
Instrumented event фиксирует переход: запрос отправлен или approval получен. UX-observation описывает понимание шага: роль не видит ответственного после отправки. Support signal группирует формулировку вопроса, например routing-unclear. Candidate change задаёт ограниченную гипотезу: показать owner до submit. Effect evidence появляется только после повторной проверки по той же границе.
Эти объекты нельзя переставить местами. Событие не говорит, что ожидание плохо. Наблюдение не доказывает, что так происходит у всех. Сигнал поддержки не является счётчиком обращений и не устанавливает причину. Гипотеза не равна результату. Если система не сохранила сопоставимое сравнение, безопасный ответ — остановиться, а не дописать эффект в отчёт.
Ниже приведён только учебный in-memory пример. Он не обращается к внутреннему инструменту, не содержит пользователей, заявок, telemetry или production-результатов. Пусть фиксированная запись описывает один sandbox-запрос. Между отправкой и получением approval стоит корзина 30m–1h. Роль не знает владельца после submit. Это три разных факта: этап, наблюдение и вопрос маршрутизации.
const input = createFixedJourneyInput(); input.claimedEffect.status = 'established'; input.claimedEffect.evidenceRefs = ['one-record-is-not-a-comparison']; const result = evaluateJourney(input); console.log(result.reason); // effect-claim-not-evidenced; console.log(result.effectClaim); // not-establishedПроверка должна отвергнуть такой input. Одна запись показывает, что модель умеет представить путь. Она не показывает частоту, стоимость ожидания, причину ручного обхода или улучшение после изменения. Поле effectState остаётся false. Даже принятый decision означает только «гипотезу можно проверить в ограниченном follow-up», а не «изменение разрешено к выпуску».
Отрицательный путь важнее happy path. Если валидатор принимает голословный established, команда быстро перенесёт вывод на другие роли и задачи. Если сравнение меняет роль, ожидаемый результат, порядок этапов или источник данных, разница может появиться из-за другой границы, а не из-за изменения интерфейса.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Результат успешен, но человек спрашивает «к кому идти». | Owner не виден на этапе маршрутизации. | Сопоставить UX-observation с конкретным stage. | Проверить показ owner до submit; не обещать сокращение approval. |
| В dashboard растёт время до результата. | В одну метрику попали очередь, доставка события и ручная работа. | Разделить stage events, occurredAt и observedAt. | Проверить источник и задержку доставки отдельно. |
| Есть один громкий тикет про неудобство. | Сигнал поддержки приняли за распространённый эффект. | Проверить категорию сигнала и неизвестный denominator. | Назначить вопрос и owner; не строить общий DX-score. |
| После изменения «стало лучше». | Сравнили разные роли или разные задачи. | Сверить comparison boundary и порядок этапов. | Вернуть claim в not-established и повторить сопоставимый проход. |
| Валидатор принимает запись с лишним полем. | Контракт проверяет наличие, но не точный набор ключей. | Запустить exact-key и canonical-JSON проверки. | Отклонять лишние и пропущенные поля до решения. |
Когнитивная цена возникает между видимыми событиями. Человек ищет инструкцию в другом чате, сравнивает похожие формы, сомневается, повторно отправляет запрос или запоминает обходной путь. Можно ждать недолго и всё равно потратить много внимания. Можно ждать долго из-за внешнего окна и не считать это дефектом интерфейса.
Поэтому корзина времени только указывает участок для исследования. Она не объясняет причину и не превращается в score. Нельзя умножить 30m–1h на observation «owner неясен» и получить измерение удобства. Это разные данные, у которых разные владельцы и разные способы проверки.
not-established.Контракт проверяет структуру и границы данных, но не правдивость внешнего мира. Он не заменяет user research, проверку безопасности telemetry, согласие на сбор данных, анализ support-категорий или измерение реальной выборки. Он также не объясняет причинность: одинаковый результат до и после изменения может быть следствием другой нагрузки, инструкции или внешнего процесса.
Учебный код не содержит transport, client, user identifier, retention policy или integration point. Его положительный результат означает только согласованность фиксированных литералов. Его отрицательный результат не доказывает, что реальный инструмент неудобен или что предложенное изменение поможет.
Проверяемый критерий готовности: для одной разрешённой задачи существуют две записи с одинаковыми role, objective, stage order, evidence labels и comparison boundary; каждая запись проходит exact-key и timestamp-проверки; изменение и bounded window задокументированы; а effect claim либо опирается на это сопоставление, либо явно остаётся not-established. Если хотя бы одно условие не выполнено, работа готова только к следующему исследовательскому шагу, но не к заявлению об улучшении.
Внутренний сервис может вернуть approved, а работа человека на этом не закончится. Он не понимает, кто отвечает за следующий шаг, открывает чат поддержки, повторяет уже введённые данные или ждёт ответа, не зная, нужно ли что-то делать. В журнале при этом остаётся успешный результат.
Цена ошибки — потерянное время и неверное решение. Если смотреть только на длительность от отправки до ответа, в одну цифру попадут очередь, задержка доставки события, неясная инструкция и ручной обход процесса. Команда начнёт исправлять интерфейс, хотя причина может быть в правах или внешнем владельце. А один громкий отзыв легко примут за массовую проблему.
Рабочая граница. Удобство внутреннего инструмента проверяется не общим баллом DX, а путём одной повторяемой задачи. Для этого нужно отдельно записать роль, этап, системное событие, наблюдение человека, сигнал поддержки, владельца изменения и условие, при котором вывод останется неподтверждённым.
Задача — это не экран и не весь onboarding. Это ограниченный маршрут с началом и проверяемым результатом. Например: инженер запрашивает доступ к sandbox-окружению. Начало — форма отправлена с указанным окружением. Результат — сервис сообщил решение, а инженер может проверить доступ. Между ними находятся открытие формы, отправка, постановка в очередь, approval и проверка результата.
Такая граница заставляет назвать участника и действие. Для роли «инженер» вопрос может звучать так: «видит ли человек после отправки, что заявка принята, кто владелец следующего шага и как проверить результат?» Это лучше, чем расплывчатое «инструмент неудобен». Один путь можно пройти вручную, по журналу событий и по обращениям поддержки, не смешивая источники.
Системное событие отвечает только на вопрос «что произошло и когда». Наблюдение UX отвечает на вопрос «что человек понял или не понял». Сигнал поддержки показывает тему обращения. Решение владельца формулирует ограниченную гипотезу. Эффект появляется лишь при повторной проверке той же границы. Переставлять эти утверждения местами нельзя: событие не доказывает неудобство, а отзыв не доказывает причину.
Свободная заметка плохо подходит для сравнения. Минимальный контракт должен показать, к какой задаче относится запись, кто проходил путь, где произошло событие и откуда взялось утверждение. Поля ниже — проектный пример для учебного разбора, а не готовая схема телеметрии.
| Поле | Что фиксирует | Проверка | Граница вывода |
|---|---|---|---|
taskType и declaredRole | Одну задачу и роль, для которой рассматривается путь. | Значения выбраны до сравнения и не меняются между проходами. | Это не перепись всех задач и не доказательство, что так действует каждый сотрудник. |
stage и order | Название этапа и его место в маршруте. | Список этапов фиксирован; пропущенный или повторённый этап отклоняется. | Порядок показывает место задержки, но не её причину. |
occurredAt и observedAt | Время события и время, когда источник его зафиксировал. | Оба значения в ISO 8601; наблюдение не раньше факта. | Временная метка не измеряет понимание, усилие или удовлетворённость. |
evidenceKind и source | Тип свидетельства и его происхождение. | Для события, наблюдения и поддержки разрешены разные источники. | Источник делает запись проверяемой, но не причинной. |
waitBucket | Диапазон ожидания на конкретном этапе. | Корзина задана заранее и имеет версию. | Диапазон указывает участок исследования, а не оценку DX. |
knownUnknowns | Что пока нельзя утверждать. | Список непустой и сформулирован конкретно. | Неизвестное нельзя заполнить предположением после просмотра результата. |
comparisonBoundary | Что обязано совпасть до и после изменения. | Минимум роль, задача, порядок этапов и виды свидетельств. | Совпадение границы не доказывает причинность, но обнаруживает несопоставимое сравнение. |
Разделение времени особенно важно. occurredAt — момент, когда переход произошёл в рассматриваемом процессе. observedAt — момент, когда источник его увидел. Между ними может быть задержка сбора или доставки. Если хранить только одно время, команда не поймёт, измеряет она работу процесса или работу системы наблюдения.
Список ключей тоже является частью договора. Лишнее поле может быть не менее опасным, чем пропущенное: его начнут читать как доказательство, хотя остальные потребители о нём не знают. Валидатор должен отказывать на неизвестной версии, другой роли, разреженном списке этапов и на заявлении об эффекте без сравнения.
Ниже — самостоятельная проверка для Node.js 18 или новее. Она работает только с фиксированным объектом в памяти: не обращается к сети, не читает журнал и не содержит идентификаторов пользователей. Поэтому её результат означает лишь, что учебная запись соответствует заявленному контракту.
node - <<'NODE'\nconst journey = {\n taskType: 'sandbox-access',\n declaredRole: 'engineer',\n stages: [\n { stage: 'submit', order: 1, occurredAt: '2025-06-15T09:03:00Z', observedAt: '2025-06-15T09:03:01Z', evidenceKind: 'event', source: 'task-log' },\n { stage: 'approval-wait', order: 2, occurredAt: '2025-06-15T09:03:00Z', observedAt: '2025-06-15T09:03:01Z', evidenceKind: 'event', source: 'task-log', waitBucket: '30m-1h' },\n { stage: 'approval', order: 3, occurredAt: '2025-06-15T09:45:00Z', observedAt: '2025-06-15T09:45:02Z', evidenceKind: 'event', source: 'task-log' }\n ],\n knownUnknowns: ['нет сопоставимого прохода после изменения', 'неизвестен объём обращений поддержки'],\n comparisonBoundary: ['taskType', 'declaredRole', 'stage.order', 'evidenceKind'],\n effectClaim: 'not-established'\n};\nconst required = ['taskType', 'declaredRole', 'stages', 'knownUnknowns', 'comparisonBoundary', 'effectClaim'];\nconst missing = required.filter((key) => !Object.hasOwn(journey, key));\nconst ordered = journey.stages.every((item, i, all) => i === 0 || item.order === all[i - 1].order + 1);\nconst timestamps = journey.stages.every((item) => item.observedAt.localeCompare(item.occurredAt) >= 0);\nif (missing.length || !ordered || !timestamps || journey.effectClaim !== 'not-established') {\n console.error('FAIL', { missing, ordered, timestamps });\n process.exit(1);\n}\nconsole.log('PASS: contract is valid; effect claim remains not-established');\nNODEКоманда должна напечатать PASS: contract is valid; effect claim remains not-established. Если заменить effectClaim на established, проверка в текущем виде остановится. В реальном валидаторе к этому добавятся exact-key проверка каждого этапа, проверка допустимой пары evidenceKind и source, уникальность order, версию схемы и запрет на чувствительные поля.
Отрицательная ветка — обязательная часть примера. Валидатор не должен угадывать, почему данных мало, и не должен превращать один отзыв в эффект. Он возвращает отказ с причиной: нет сопоставимого прохода, нарушен порядок, неизвестен источник или утверждение превышает свидетельство. Такой отказ сохраняет вопрос для следующего исследования и не создаёт ложный KPI.
Среднее время от submit до approval скрывает разные механизмы. Сначала привяжите симптом к этапу и роли. Затем проверьте, что именно зафиксировано: событие, наблюдение, категория поддержки или решение владельца. Только после этого выбирайте действие.
| Симптом | Гипотеза | Проверка | Ограниченное действие |
|---|---|---|---|
| После успешной заявки человек спрашивает, к кому обращаться. | Следующий владелец не виден в текущем статусе. | Сопоставить UX-наблюдение с этапом после submit и проверить интерфейс на той же роли. | Показать владельца или следующий шаг; не обещать сокращения очереди. |
| Время ожидания растёт, но причина неясна. | В одну метрику попали очередь, доставка события и ручная работа. | Разделить этапы, occurredAt, observedAt и источник. | Проверить один участок маршрута; не менять весь workflow. |
| Есть один повторно пересказанный вопрос в поддержке. | Тема маршрутизации не покрыта инструкцией. | Сгруппировать формулировки и проверить, кто действительно сталкивается с задачей. | Уточнить инструкцию и назначить владельца сигнала; не объявлять масштаб. |
| После правки говорят «стало лучше». | Сравнивались разные роли, этапы или условия нагрузки. | Сверить comparisonBoundary до и после изменения. | Вернуть claim в not-established и повторить сопоставимый проход. |
| Валидатор принимает неизвестное поле. | Проверяется наличие обязательных ключей, но не точный набор. | Сравнить отсортированные ключи с версией схемы и проверить отрицательный тест. | Отклонять запись до передачи её в отчёт или решение. |
Матрица нужна не для автоматического выбора интерфейсной правки. Она удерживает порядок рассуждения: сначала наблюдаемый симптом, затем проверяемая гипотеза, затем узкое действие. Если проверка показывает, что владелец виден, а задержка вызвана внешним окном, исправлять текст статуса бессмысленно. Если владелец не виден, но очередь не изменилась, можно улучшить навигацию, не заявляя об ускорении процесса.
waitBucket удобен для первичного поиска: «0–5 минут», «30 минут–1 час», «больше часа». Он не создаёт ложную точность до секунды и позволяет увидеть длинный хвост. Но одна и та же корзина может означать разные вещи. В первом случае человек не знает, принята ли заявка, и пишет в поддержку. Во втором он видит владельца и заранее знает, что approval зависит от внешнего окна. Система наблюдает похожее ожидание, а вопрос к интерфейсу разный.
Поэтому диапазон нельзя умножать на число обращений и называть результат «стоимостью когнитивной нагрузки». Для такой оценки нужны отдельный вопрос, подходящая выборка и разрешённый способ исследования. Даже тогда среднее время остаётся одним из показателей, а не заменой наблюдению за тем, как человек понимает следующий шаг.
Обращение в поддержку — хороший указатель направления, но не готовая причина. В нём могут смешаться старая инструкция, срочность, отсутствие прав, привычка писать конкретному человеку и настоящий дефект маршрутизации. Сначала сохраните формулировку сигнала и его источник. Затем задайте один исследовательский вопрос: «видит ли эта роль владельца и следующий шаг до отправки заявки?»
Решение владельца должно ограничивать изменение: один этап, один ответственный, одна ожидаемая проверка и окно возврата к вопросу. Например, candidate change — показать owner на экране подтверждения. Оно не обещает уменьшить approval wait. Его проверяемое следствие — станет ли понятнее следующий шаг у той же роли и на той же задаче.
Сравнение считается сопоставимым, если заранее сохранены задача, роль, ожидаемый результат, порядок этапов, версии контракта и виды свидетельств. Изменение одного из этих элементов может объяснить разницу само по себе. Даже совпадающая граница не делает эксперимент причинным: на результат могут влиять нагрузка, инструкция, права и внешний процесс. Она лишь не даёт незаметно сравнить две разные задачи.
Эта модель подходит для повторяемых внутренних процессов, где можно безопасно описать один путь и назвать владельца следующей проверки. Она не предназначена для анонимного профилирования людей, оценки конкретного сотрудника или решения вопроса о доступе. Идентификаторы, содержимое заявок и данные поддержки нужно собирать только по правилам своей организации и в минимальном объёме.
Учебный код не подключён к workflow, telemetry, очереди, клиенту или хранилищу. Времена, роль, корзина ожидания и список неизвестных придуманы для воспроизводимости. Они не являются бенчмарком, SLA, прогнозом и не подтверждают, что конкретный внутренний инструмент неудобен. Локальный SVG — схема объяснения, а не снимок dashboard.
Контракт не доказывает причинность и не измеряет cognitive cost сам по себе. Он также не отвечает на вопрос о статистической значимости: для этого понадобятся дизайн сравнения, достаточная выборка, критерии остановки и консультация с владельцами данных. Если законно получить сопоставимые записи нельзя, безопасный результат — уточнить документацию или записать исследовательский вопрос, но оставить claim not-established.
declaredRole, границу входа и выхода, список этапов и владельца каждого перехода.waitBucket, обязательные ключи и список известных неизвестных.comparisonBoundary: та же задача, роль, результат, порядок и набор источников.Готовность — это не фраза «DX улучшился». Для одной задачи должны быть видны последовательность этапов, источники, ограничение сравнения, владелец и стоп-условие. После изменения должна появиться вторая запись с той же границей. Только если она содержит заранее названное свидетельство, можно обновлять вывод; во всех остальных случаях честный статус — not-established.
Внутренний инструмент отвечает 200 OK, но задача не заканчивается. Разработчик отправляет заявку, не видит следующего владельца, открывает чат и повторяет вопрос. В журнале есть успешный запрос. В интерфейсе нет ошибки. Симптом появляется между двумя этапами: система приняла действие, а человек не понял, что делать дальше.
Цена ошибки выше времени одного ожидания. Разработчик переключается между системами и повторяет ввод. Поддержка отвечает на одинаковые вопросы. Владелец инструмента видит хорошие технические статусы и откладывает проблему. Затем команда чинит заметный экран, хотя задержку создаёт очередь согласования или неясное право доступа.
\nТезис. Developer experience нельзя надёжно оценить одним средним временем или общим баллом. Нужно взять одну задачу, провести её от входа до результата и сохранить границы каждого вывода. Событие показывает переход. Наблюдение описывает действие человека. Сигнал поддержки указывает вопрос. Решение выбирает изменение. Эффект подтверждает только сопоставимое повторение.
\nНе начинайте с всего onboarding и не смешивайте разные роли. Возьмите один повторяемый путь. Например, инженер запрашивает доступ к sandbox. Ожидаемый результат — подтверждение или объяснимый отказ. Между ними находятся открытие заявки, отправка, начало ожидания, решение владельца и подтверждение результата.
\nУ задачи должны быть четыре явные границы. Первая — declared role: кто выполняет действие. Вторая — objective: зачем он его выполняет. Третья — expected result: что можно увидеть и проверить. Четвёртая — comparison boundary: какие условия обязаны совпасть при повторной проверке. Без этих полей сравнение легко превращается в сравнение разных задач.
\nВременная метка помогает найти участок пути. Она не объясняет причину. occurredAt может обозначать момент перехода, а observedAt — момент его фиксации. Если запись пришла позже, это свойство наблюдения, а не обязательно задержка процесса. Поэтому время нужно хранить рядом с источником и этапом, а не превращать в самостоятельную оценку удобства.
const journey = {\n role: 'platform-engineer',\n objective: 'получить sandbox access',\n expectedResult: 'confirmation или объяснимый отказ',\n stages: [\n 'task.opened',\n 'request.submitted',\n 'approval.wait.started',\n 'approval.received',\n 'result.confirmed'\n ],\n waitBucket: '30m-1h',\n nextOwner: 'unknown',\n uxObservation: 'после submit неясен следующий шаг',\n supportSignal: 'routing-unclear',\n effectClaim: 'not-established'\n};\nКод — учебный пример в памяти. Он не обращается к сети, не отправляет telemetry и не описывает реальную заявку. Его задача — показать минимальный набор полей и место, где система должна остановиться. В рабочем инструменте отдельно определяют разрешённые данные, права доступа, срок хранения и правила удаления идентификаторов.
\nСобытие отвечает: «Что произошло и когда?» Например, заявка перешла из submitted в approval.wait.started. Оно не отвечает, почему человек открыл чат.
UX-наблюдение отвечает: «Как человек понял или не понял шаг?» Запись «после отправки человек ищет владельца в другом канале» полезнее диагноза «плохой интерфейс». Наблюдение ещё не говорит, что добавление label исправит путь.
\nSupport signal отвечает: «Какой вопрос повторяется и какая команда может его проверить?» Категория routing-unclear помогает сгруппировать обращения. Она не показывает долю всех пользователей и не доказывает причинность.
Решение отвечает: «Какое ограниченное изменение проверяем, на каком этапе и кто отвечает?» Хорошее решение содержит owner, target stage, candidate change, окно проверки и условие остановки.
\nEffect evidence отвечает: «Что изменилось при той же границе?» Для него нужны та же роль, тот же результат, сопоставимый порядок этапов и заранее определённый признак. Одна удачная запись не становится evidence только потому, что её удобно показать.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Заявка успешна, но человек повторяет вопрос | Следующий владелец или шаг не виден | Восстановить путь от submit до следующего действия одной роли | Записать UX-наблюдение и проверить видимость owner |
| Среднее время ожидания растёт | В одну метрику попали разные роли и этапы | Разделить stage, role и wait bucket | Выбрать одну границу задачи и не строить общий DX-score |
| Один отзыв сразу превращается в правку | Наблюдение смешали с решением | Отделить действие, вопрос, гипотезу и неизвестное | Сформулировать candidate change с owner |
| Категорию поддержки называют доказательством эффекта | Нет сопоставимого результата после изменения | Проверить source, роль, период и тот же ожидаемый результат | Оставить claim как not-established |
| После изменения стало «удобнее» | Повторили другой маршрут или изменили состав роли | Сравнить objective, stage order, fields и окно наблюдения | Остановить вывод и повторить задачу по прежней границе |
Таблица не заменяет разговор с человеком и не создаёт статистику из одной записи. Она заставляет назвать следующий проверяемый шаг. Если действие нельзя выполнить или его результат нельзя увидеть, это не действие, а пожелание.
\nКорзина 30m-1h говорит только о диапазоне между двумя событиями. В одном случае человек не понимает, что заявка уже стоит в очереди. Тогда нужно проверить статус, владельца и текст подтверждения. В другом случае владелец понятен, но согласование зависит от внешнего окна. Тогда интерфейс может честно объяснить ограничение, но не сократить сам процесс.
Одинаковый wait bucket поэтому ведёт к разным решениям. Нельзя строить DX-score из времени и выдавать его за причину. Нельзя считать отсутствие обращения доказательством удобства: человек мог не иметь канала поддержки, а событие могло не видеть ручной шаг в соседней системе.
\nИсточник каждого факта должен быть виден рядом с ним. Запись журнала подтверждает событие. Интервью или наблюдение подтверждает вопрос человека. Категория поддержки подтверждает повторяемую формулировку. Ни один источник не заменяет остальные. OpenTelemetry полезен здесь как пример дисциплины временных событий: имя и время помогают восстановить переход, но не отвечают на продуктовый вопрос о понятности шага.
\nПредставим, что после одной записи команда меняет effectClaim на established. В качестве evidence она прикладывает ту же запись. Такой вывод нужно отклонить. Нет второй точки сравнения. Неизвестно, совпала ли роль. Нельзя отделить эффект изменения от внешнего окна согласования.
function decide(claim) {\n const comparable = claim.sameRole &&\n claim.sameObjective &&\n claim.sameStageBoundary &&\n claim.evidenceCount >= 2;\n\n if (claim.status === 'established' && !comparable) {\n return {\n status: 'HOLD',\n reason: 'effect-claim-not-evidenced'\n };\n }\n\n return { status: 'needs-owner-decision' };\n}\nЭто тоже учебный пример. Функция проверяет условие остановки на объекте в памяти. Она не оценивает правдивость внешних данных и ничего не меняет в production. В настоящем валидаторе понадобятся проверки схемы, порядка этапов, формата времени, источника события и разрешений.
\nHOLD не означает, что гипотеза неверна. Он означает, что текущая запись не отвечает на вопрос. Это защищает команду от преждевременного переноса решения на другие роли и задачи. Если показ owner уменьшит число вопросов, повторная проверка это обнаружит. Если причина лежит во внешней очереди, результат будет другим: интерфейс улучшит объяснение, но не сократит ожидание.
occurredAt, observedAt и допустимые wait buckets.not-established и сформулируйте, каких данных не хватает.Эта модель не даёт репрезентативную выборку. Она не измеряет cognitive cost, удовлетворённость, частоту обращений или стоимость поддержки. Она не заменяет user research, проверку доступности, нагрузочное тестирование и анализ прав. Она помогает не смешать вопросы и не выдать техническую запись за ответ на каждый из них.
\nУчебные значения нельзя публиковать как результат внутреннего инструмента. В production-пути могут отличаться часы, задержка доставки, идентичность роли, источник ручной работы и правила хранения данных. Любое сравнение должно сохранять версию контракта и явно отмечать изменения границы.
\nПоказ владельца до отправки может снизить число вопросов о маршрутизации и не изменить очередь согласования. Ускорение одного этапа может увеличить нагрузку на другой. Поэтому изменение должно иметь narrow target и stop condition, а не обещание «сделать DX лучше».
\nРазбор готов к инженерному решению, когда другой человек без устного пересказа может восстановить role, objective, expected result, stage order, source, wait bucket, UX-наблюдение, support signal, unknowns, owner, candidate change и comparison boundary. Он понимает, какой результат подтвердит гипотезу, а какой остановит вывод.
\nМинимальная проверка даёт три наблюдаемых исхода. Корректная задача проходит структурную проверку и остаётся гипотезой до решения владельца. Неполный source, неверный порядок и forged effect claim возвращают HOLD с причиной. Ни один учебный вызов не отправляет данные и не меняет production. Только после этого можно подключать разрешённые источники и повторять тот же путь.
В учебном сценарии в 09:10 инженер отправляет во внутреннем инструменте заявку на доступ к sandbox. Сервер возвращает 200 OK, но к 09:45 разработчик всё ещё не знает, кто следующий владелец. Он открывает чат, повторяет уже введённые данные и получает ответ, который не связан с заявкой. Технический запрос успешен, а задача для человека — нет.
Цена ошибки выше времени одного ожидания. Разработчик переключается между системами и повторяет ввод. Поддержка отвечает на одинаковые вопросы. Владелец инструмента видит хорошие технические статусы и откладывает проблему. Затем команда чинит заметный экран, хотя задержку создаёт очередь согласования или неясное право доступа.
\nТезис. Developer experience нельзя надёжно оценить одним средним временем или общим баллом. Нужно взять одну задачу, провести её от входа до результата и сохранить границы каждого вывода. Событие показывает переход. Наблюдение описывает действие человека. Сигнал поддержки указывает вопрос. Решение выбирает изменение. Эффект подтверждает только сопоставимое повторение.
\nНе начинайте с всего onboarding и не смешивайте разные роли. Возьмите один повторяемый путь. Например, инженер запрашивает доступ к sandbox. Ожидаемый результат — подтверждение или объяснимый отказ. Между ними находятся открытие заявки, отправка, начало ожидания, решение владельца и подтверждение результата.
\nУ задачи должны быть четыре явные границы. Первая — declared role: кто выполняет действие. Вторая — objective: зачем он его выполняет. Третья — expected result: что можно увидеть и проверить. Четвёртая — comparison boundary: какие условия обязаны совпасть при повторной проверке. Без этих полей сравнение легко превращается в сравнение разных задач.
\nВременная метка помогает найти участок пути. Она не объясняет причину. occurredAt может обозначать момент перехода, а observedAt — момент его фиксации. Если запись пришла позже, это свойство наблюдения, а не обязательно задержка процесса. Поэтому время нужно хранить рядом с источником и этапом, а не превращать в самостоятельную оценку удобства.
const journey = {\n role: 'platform-engineer',\n objective: 'получить sandbox access',\n expectedResult: 'confirmation или объяснимый отказ',\n stages: [\n 'task.opened',\n 'request.submitted',\n 'approval.wait.started',\n 'approval.received',\n 'result.confirmed'\n ],\n waitBucket: '30m-1h',\n nextOwner: 'unknown',\n uxObservation: 'после submit неясен следующий шаг',\n supportSignal: 'routing-unclear',\n effectClaim: 'not-established'\n};\nКод — учебный пример в памяти. Он не обращается к сети, не отправляет telemetry и не описывает реальную заявку. Его задача — показать минимальный набор полей и место, где система должна остановиться. В рабочем инструменте отдельно определяют разрешённые данные, права доступа, срок хранения и правила удаления идентификаторов.
\nВ учебной сцене важны две временные точки. В 09:10 произошла отправка заявки, а в 09:45 человек открыл чат и повторил вопрос. Это не доказательство того, что интерфейс вызвал задержку: между точками могли быть очередь, ручное согласование и задержка доставки события. Время обозначает участок для расследования, а не готовую причину.
\nСобытие отвечает: «Что произошло и когда?» Например, заявка перешла из submitted в approval.wait.started. Оно не отвечает, почему человек открыл чат.
UX-наблюдение отвечает: «Как человек понял или не понял шаг?» Запись «после отправки человек ищет владельца в другом канале» полезнее диагноза «плохой интерфейс». Наблюдение ещё не говорит, что добавление label исправит путь.
\nSupport signal отвечает: «Какой вопрос повторяется и какая команда может его проверить?» Категория routing-unclear помогает сгруппировать обращения. Она не показывает долю всех пользователей и не доказывает причинность.
Решение отвечает: «Какое ограниченное изменение проверяем, на каком этапе и кто отвечает?» Хорошее решение содержит owner, target stage, candidate change, окно проверки и условие остановки.
\nEffect evidence отвечает: «Что изменилось при той же границе?» Для него нужны та же роль, тот же результат, сопоставимый порядок этапов и заранее определённый признак. Одна удачная запись не становится evidence только потому, что её удобно показать.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Заявка успешна, но человек повторяет вопрос | Следующий владелец или шаг не виден | Восстановить путь от submit до следующего действия одной роли | Записать UX-наблюдение и проверить видимость owner |
| Среднее время ожидания растёт | В одну метрику попали разные роли и этапы | Разделить stage, role и wait bucket | Выбрать одну границу задачи и не строить общий DX-score |
| Один отзыв сразу превращается в правку | Наблюдение смешали с решением | Отделить действие, вопрос, гипотезу и неизвестное | Сформулировать candidate change с owner |
| Категорию поддержки называют доказательством эффекта | Нет сопоставимого результата после изменения | Проверить source, роль, период и тот же ожидаемый результат | Оставить claim как not-established |
| После изменения стало «удобнее» | Повторили другой маршрут или изменили состав роли | Сравнить objective, stage order, fields и окно наблюдения | Остановить вывод и повторить задачу по прежней границе |
Таблица не заменяет разговор с человеком и не создаёт статистику из одной записи. Она заставляет назвать следующий проверяемый шаг. Если действие нельзя выполнить или его результат нельзя увидеть, это не действие, а пожелание.
\nКорзина 30m-1h говорит только о диапазоне между двумя событиями. В одном случае человек не понимает, что заявка уже стоит в очереди. Тогда нужно проверить статус, владельца и текст подтверждения. В другом случае владелец понятен, но согласование зависит от внешнего окна. Тогда интерфейс может честно объяснить ограничение, но не сократить сам процесс.
Одинаковый wait bucket поэтому ведёт к разным решениям. Нельзя строить DX-score из времени и выдавать его за причину. Нельзя считать отсутствие обращения доказательством удобства: человек мог не иметь канала поддержки, а событие могло не видеть ручной шаг в соседней системе.
\nИсточник каждого факта должен быть виден рядом с ним. Запись журнала подтверждает событие. Интервью или наблюдение подтверждает вопрос человека. Категория поддержки подтверждает повторяемую формулировку. Ни один источник не заменяет остальные. OpenTelemetry полезен здесь как пример дисциплины временных событий: имя и время помогают восстановить переход, но не отвечают на продуктовый вопрос о понятности шага.
\nДо подключения к внутреннему сервису можно проверить структуру на фикстуре. Сохраните следующий фрагмент как journey.json. Даты и строки придуманы для примера; это не telemetry и не результат измерения.
{\n \"role\": \"platform-engineer\",\n \"objective\": \"получить доступ к sandbox\",\n \"expectedResult\": \"confirmation или объяснимый отказ\",\n \"stages\": [\n {\n \"name\": \"request.submitted\",\n \"occurredAt\": \"2025-06-07T09:10:00Z\",\n \"observedAt\": \"2025-06-07T09:10:02Z\"\n },\n {\n \"name\": \"approval.wait.started\",\n \"occurredAt\": \"2025-06-07T09:10:01Z\",\n \"observedAt\": \"2025-06-07T09:10:02Z\"\n }\n ],\n \"effectClaim\": {\n \"status\": \"not-established\",\n \"evidenceCount\": 1\n }\n}\nКоманда ниже проверяет обязательные поля, порядок времени и уникальность этапов. Она должна завершиться строкой LOCAL CHECK PASS. Если удалить expectedResult, изменить порядок дат или поставить status в established при одном evidence, команда завершится ошибкой.
node --input-type=module - <<'NODE'\nimport fs from 'node:fs';\n\nconst journey = JSON.parse(fs.readFileSync('journey.json', 'utf8'));\nconst required = ['role', 'objective', 'expectedResult', 'stages', 'effectClaim'];\nconst missing = required.filter((key) => !(key in journey));\nif (missing.length) throw new Error('missing: ' + missing.join(', '));\nif (!Array.isArray(journey.stages) || journey.stages.length < 2) {\n throw new Error('at least two stages are required');\n}\n\nconst names = new Set();\nlet previousOccurred = -Infinity;\nfor (const stage of journey.stages) {\n if (!stage.name || names.has(stage.name)) throw new Error('duplicate stage');\n names.add(stage.name);\n const occurred = Date.parse(stage.occurredAt);\n const observed = Date.parse(stage.observedAt);\n if (!Number.isFinite(occurred) || !Number.isFinite(observed)) {\n throw new Error('invalid timestamp');\n }\n if (occurred > observed || occurred < previousOccurred) {\n throw new Error('timestamps are not comparable');\n }\n previousOccurred = occurred;\n}\nif (journey.effectClaim.status === 'established' &&\n journey.effectClaim.evidenceCount < 2) {\n throw new Error('effect claim needs comparable evidence');\n}\nconsole.log('LOCAL CHECK PASS');\nNODE\nЭто минимальный структурный guard, а не исследование DX. Он не проверяет, правдивы ли даты, кто действительно выполнил действие, что происходило в очереди и насколько часто встречался симптом. Он лишь оставляет место остановки до интеграции.
\nПредставим, что после одной записи команда меняет effectClaim на established. В качестве evidence она прикладывает ту же запись. Такой вывод нужно отклонить. Нет второй точки сравнения. Неизвестно, совпала ли роль. Нельзя отделить эффект изменения от внешнего окна согласования.
function decide(claim) {\n const comparable = claim.sameRole &&\n claim.sameObjective &&\n claim.sameStageBoundary &&\n claim.evidenceCount >= 2;\n\n if (claim.status === 'established' && !comparable) {\n return {\n status: 'HOLD',\n reason: 'effect-claim-not-evidenced'\n };\n }\n\n return { status: 'needs-owner-decision' };\n}\nЭто тоже учебный пример. Функция проверяет условие остановки на объекте в памяти. Она не оценивает правдивость внешних данных и ничего не меняет в production. В настоящем валидаторе понадобятся проверки схемы, порядка этапов, формата времени, источника события и разрешений.
\nHOLD не означает, что гипотеза неверна. Он означает, что текущая запись не отвечает на вопрос. Это защищает команду от преждевременного переноса решения на другие роли и задачи. Если показ owner уменьшит число вопросов, повторная проверка это обнаружит. Если причина лежит во внешней очереди, результат будет другим: интерфейс улучшит объяснение, но не сократит ожидание.
occurredAt, observedAt и допустимые wait buckets.not-established и сформулируйте, каких данных не хватает.Эта модель не даёт репрезентативную выборку. Она не измеряет cognitive cost, удовлетворённость, частоту обращений или стоимость поддержки. Она не заменяет user research, проверку доступности, нагрузочное тестирование и анализ прав. Она помогает не смешать вопросы и не выдать техническую запись за ответ на каждый из них.
\nУчебные значения нельзя публиковать как результат внутреннего инструмента. В production-пути могут отличаться часы, задержка доставки, идентичность роли, источник ручной работы и правила хранения данных. Любое сравнение должно сохранять версию контракта и явно отмечать изменения границы.
\nПоказ владельца до отправки может снизить число вопросов о маршрутизации и не изменить очередь согласования. Ускорение одного этапа может увеличить нагрузку на другой. Поэтому изменение должно иметь narrow target и stop condition, а не обещание «сделать DX лучше».
\nРазбор готов к инженерному решению, когда другой человек без устного пересказа может восстановить role, objective, expected result, stage order, source, wait bucket, UX-наблюдение, support signal, unknowns, owner, candidate change и comparison boundary. Он понимает, какой результат подтвердит гипотезу, а какой остановит вывод.
\nМинимальная проверка даёт три наблюдаемых исхода. Корректная задача проходит структурную проверку и остаётся гипотезой до решения владельца. Неполный source, неверный порядок и forged effect claim возвращают HOLD с причиной. Ни один учебный вызов не отправляет данные и не меняет production. Только после этого можно подключать разрешённые источники и повторять тот же путь.
Скрипт выбирает записи по фильтру и обновляет их за секунды. Затем владелец видит лишние изменения: шаблон совпал с архивными объектами, список targets устарел, а часть полей уже исправил другой процесс. Ошибка не заканчивается неудачным exit code. Она оставляет частичный change, теряет исходные значения и заставляет команду запускать ещё одну операцию для исправления первой. Цена ошибки — простой, ручная сверка и риск испортить данные при поспешном rollback.
\nБезопасная batch-автоматизация строится как цепочка независимых границ: preview, ограниченный scope, проверка полномочий, approval, execute, audit trail и независимая verification. Ни один этап не должен выдавать результат следующего этапа. Preview не равен разрешению. Успешное завершение процесса не доказывает состояние данных. Rollback не должен автоматически наследовать полномочия исходной операции.
\nДо запуска опишите operation card — короткую карточку изменения. Она отвечает на вопрос: что именно процесс собирается изменить и как владелец узнает, что изменение завершилось правильно. В карточке нужны стабильный operation id, selector, полный список targets, исключения, digest списка, ожидаемое состояние до и после, версия логики и критерий проверки.
\nСписок targets должен быть плотным: без пропущенных элементов, неявного «всё найденное» и повторного поиска между preview и execute. Digest не заменяет список для чтения. Он связывает карточку, согласование и запуск. Если selector, список и digest невозможно показать вместе, reviewer не видит границу операции.
\n| Поле | Пример учебного значения | Проверка | Остановиться, если |
|---|---|---|---|
| operationId | normalize-labels-2025-05-01 | одно значение проходит через preview, approval и audit | идентификатор переиспользован или отсутствует |
| targets | doc-a, doc-b | список плотный, видны selector и exclusions | список пуст, разрежен или не соответствует фильтру |
| targetDigest | sha256:7b… | digest вычислен по каноническому списку | digest относится к другому набору |
| authority | label-editor, максимум 2 записи | лимит покрывает точный scope | операция шире разрешённого лимита |
| expectation | label=normalized после запуска | есть источник, который это прочитает | успехом считается только exit code |
Preview показывает proposed change без внешней записи. Это полезная граница для чтения, но не гарантия будущего результата. Между расчётом и запуском другой процесс может изменить target. Запись может исчезнуть. Политика может истечь. Поэтому preview получает время расчёта, версию входных данных и максимальный срок действия.
\nКороткий пример ниже учебный. Он не обращается к файлам, сети или реальным targets. Его задача — показать отрицательный путь: authority разрешает одну запись, а preview содержит две. В таком случае код не уменьшает список молча и не запускает разрешённую часть.
\nconst preview = {\n operationId: 'normalize-labels-2025-05-01',\n targets: ['doc-a', 'doc-b'],\n targetDigest: 'sha256:7b-demo',\n expiresAt: '2025-05-01T12:00:00Z'\n};\n\nconst authority = {\n selector: 'document-label',\n maxTargets: 1\n};\n\nfunction approve(preview, authority, now) {\n if (new Date(preview.expiresAt) <= now) {\n return { approved: false, reason: 'preview-expired' };\n }\n if (preview.targets.length > authority.maxTargets) {\n return { approved: false, reason: 'scope-exceeds-authority-limit' };\n }\n return { approved: true };\n}\n\nconsole.log(approve(preview, authority, new Date('2025-05-01T11:00:00Z')));\n// Учебный результат: approved=false, scope-exceeds-authority-limit.\nПроверка должна происходить до approval и тем более до write. Нельзя превращать ограничение в предупреждение. Предупреждение оставляет решение в голове оператора и делает два одинаковых запуска разными по поведению. Явный stop code даёт владельцу причину, которую можно проверить и обработать.
\nApproval должен относиться к конкретной карточке, а не к названию задачи. Минимальная связка содержит operation id, preview digest, target digest, authority id, решение, identity reviewer и срок действия. Executor сверяет значения буквально. Если reviewer согласовал список doc-a и doc-b, а перед запуском список стал doc-a и doc-c, старое согласование недействительно.
\nЭто правило закрывает распространённый отрицательный путь. Сервис обновил inventory, получил новый список и продолжил старым approval. В логах есть «approved», но approval относился к другому scope. Правильное действие — остановиться, построить новый preview и запросить новое решение. Автоматически угадывать намерение reviewer нельзя.
\nExecute сообщает, что процесс попытался применить change. Audit trail сохраняет связанный контекст: operation id, digest входа, версию исполнителя, время, результат по каждому target и correlation id. Audit не доказывает, что данные приняли новое состояние. Для этого нужен отдельный reader, который обращается к authoritative source после execute.
\nVerification сравнивает наблюдаемое состояние с явным expectation. У неё должно быть не два, а три результата: matched, mismatched и unknown. Недоступный reader даёт unknown. Таймаут не превращает unknown в успех. Если один target совпал, а второй нет, операция остаётся остановленной с указанием subset и владельца восстановления.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В preview слишком много записей | Selector шире ожидаемого | Сравнить selector, exclusions и лимит authority | Остановиться и сузить scope; не обрезать список автоматически |
| Approval есть, но digest не совпал | Inventory изменился после review | Сверить operation id и два target digest | Построить новый preview и получить новое approval |
| Executor завершился успешно, данных нет | Exit code приняли за наблюдаемое состояние | Прочитать authoritative source отдельным reader | Отметить unknown и назначить владельца verification |
| Изменена только часть targets | Partial execution или внешний конфликт | Сохранить точный subset и результаты по объектам | Остановиться; не запускать широкий inverse |
| Preview старше допустимого окна | Stale input или истёкшая политика | Сравнить retrieval time с max age | Пересчитать preview перед новым approval |
После mismatched verification естественно вызвать обратную команду в блоке finally. Это опасно. Часть объектов могла измениться вручную. Старое значение могло стать неверным. Новый selector может выбрать больше записей. Поэтому rollback сначала создаёт план восстановления: наблюдаемый subset, известные и неизвестные состояния, владельца recovery и данные для следующего решения.
\nДальше rollback проходит тот же маршрут: новый scope, новый authority check, новый preview, новое approval и новая verification. Исходное согласование не даёт права на обратную запись. Если команда пока не может перечислить targets для rollback, результатом должен быть stop и расследование, а не команда «вернуть всё назад».
\nЭтот маршрут не делает опасную операцию безопасной сам по себе. Он не доказывает идемпотентность кода, неизменяемость журнала, корректность identity provider, отсутствие гонок и возможность восстановления данных. Внешний сервис может вернуть неполный список, а reader — устаревшее состояние. Эти свойства нужно проверять в архитектуре конкретной системы.
\nУчебный код использует фиксированные строки и память процесса. Он не является реализацией permission system, durable audit storage или реального batch runner. В production нужно определить источник targets, каноническое вычисление digest, правила истечения preview, владельца остановки и допустимость partial execution. Если хотя бы один из этих пунктов неизвестен, scope автоматизации следует уменьшить.
\nОперация готова к ограниченному запуску, если команда может воспроизвести три случая на тестовых данных: корректный bounded scope проходит весь маршрут; scope больше authority останавливается до approval; изменившийся target digest останавливает запуск после повторной проверки. Для каждого случая видны operation id, причина остановки или matched verification, запись audit и отсутствие запрещённой записи. Критерий не обещает результат для production. Он показывает, что границы процесса работают.
\nСкрипт выбирает заявки по фильтру и закрывает их за секунды. Затем владелец формы замечает пропавшее поле: фильтр совпал с архивными записями, список targets устарел, а часть объектов уже исправил другой процесс. Ошибка не заканчивается ненулевым exit code. Она оставляет частичный change, теряет исходные значения и провоцирует второй массовый запуск для исправления первого.
\nРассмотрим этот сюжет как учебный кейс, а не как отчёт о конкретной production-системе. Главный вопрос такой: какие границы должен пройти batch-runner, прежде чем ему разрешат запись? Ответ — не «добавить dry-run», а разделить preview, scope, полномочия, approval, execute, журнал и независимую проверку. Каждый этап должен доказывать только свою часть результата.
\nДо запуска зафиксируйте карточку операции. Она превращает расплывчатое «закрыть старые заявки» в проверяемый набор условий: operationId, версия правила, selector, exclusions, точный список targets, ожидаемое изменение, допустимый лимит, срок действия preview и способ чтения результата.
Selector отвечает на вопрос «как искать», а targets — «что именно разрешено менять». На execute нельзя заново выполнить широкий selector и надеяться получить тот же набор. Между двумя чтениями другой процесс может добавить запись, изменить статус или удалить объект. Поэтому preview должен содержать список и digest этого списка, а исполнитель — сверить их непосредственно перед записью.
\n| Поле | Пример | Что проверяем | Стоп-условие |
|---|---|---|---|
| operationId | close-requests-2025-05-25-01 | один идентификатор проходит через все события | идентификатор повторно использован или пуст |
| selector | status=open, age>30d | правило поиска и его версия известны reviewer | в запросе есть неявное «все» |
| targets | req-17, req-21 | каждый ID перечислен и принадлежит ожидаемому типу | список пуст, содержит дубли или неожиданный тип |
| targetDigest | sha256:… | digest вычислен по зафиксированному представлению списка | digest нельзя пересчитать из показанных данных |
| authority | requests.close, максимум 2 | право и количественный лимит покрывают точный scope | scope шире полномочия |
| expectation | status=closed у каждого target | есть authoritative reader для проверки | успехом считается только ответ команды |
Digest — это связка между данными и решением, а не секретный пропуск. Для простого списка уникальных ASCII-ID достаточно заранее описать канонизацию, например сортировку и разделитель. Для вложенного JSON нельзя полагаться на случайный порядок свойств: RFC 8785 описывает детерминированное JSON-представление для повторяемого hashing. В любом варианте правила канонизации должны быть частью контракта и одинаково реализованы producer и executor.
\nPreview должен выполнять чтение и расчёт, но не внешнюю запись. Он показывает, какие изменения процесс собирается выполнить на момент чтения. Это полезное доказательство намерения, однако оно не обещает, что target останется прежним к моменту execute. Terraform прямо разделяет построение плана и применение, а также предупреждает, что более ранний speculative plan может устареть после изменений в целевой системе. Kubernetes аналогично называет --dry-run=client предварительным объектом, который не отправляется в кластер.
Для reusable-скрипта сохраните не только красивый diff, но и машинно читаемую карточку: время получения, версию схемы, selector, targets, digest, identity создателя и expiresAt. Секреты и полные чувствительные значения в preview не включайте. В Terraform сохранённый plan-файл может содержать чувствительные данные, поэтому аналогичный артефакт нужно считать защищаемым.
node --input-type=module <<'NODE'\nconst preview = {\n operationId: 'close-requests-2025-05-25-01',\n selector: 'status=open,age>30d',\n targets: ['req-17', 'req-21'],\n targetDigest: 'sha256:demo-list',\n expiresAt: '2025-05-25T12:00:00Z'\n};\n\nconst authority = {\n action: 'requests.close',\n maxTargets: 2\n};\n\nfunction checkPreview(candidate, permission, now) {\n if (candidate.expiresAt && new Date(candidate.expiresAt) <= now) {\n return { allowed: false, reason: 'preview-expired' };\n }\n if (candidate.targets.length === 0) {\n return { allowed: false, reason: 'empty-scope' };\n }\n if (candidate.targets.length > permission.maxTargets) {\n return { allowed: false, reason: 'scope-exceeds-authority-limit' };\n }\n if (permission.action !== 'requests.close') {\n return { allowed: false, reason: 'wrong-authority' };\n }\n return { allowed: true, operationId: candidate.operationId };\n}\n\nconsole.log(checkPreview(\n preview,\n authority,\n new Date('2025-05-25T11:00:00Z'),\n));\n// { allowed: true, operationId: 'close-requests-2025-05-25-01' }\nNODE\nЭтот фрагмент воспроизводим в Unix-подобной оболочке и намеренно не обращается к сети или данным. Замените фиктивные targets только после того, как определите источник списка. Если лимит уменьшить до 1, функция вернёт scope-exceeds-authority-limit и не должна передавать операцию дальше. Отрицательный путь важнее зелёного: ограничение обязано быть отказом, а не предупреждением.
Согласование по названию задачи слишком слабое. Reviewer должен видеть operation card, список targets, ожидаемые поля до и после, лимит и срок действия. В approval сохраните как минимум operationId, targetDigest, authorityId, identity reviewer, время решения и срок истечения.
Непосредственно перед execute исполнитель заново проверяет identity, срок approval, действие, лимит и digest. Если вместо req-17, req-21 появились req-17, req-29, старое решение не переносится. Сервис должен остановиться, создать новый preview и запросить новое approval. Нельзя молча взять пересечение списков: это меняет предмет решения и скрывает ошибку инвентаризации.
Платформенный gate не заменяет эту связку, но показывает полезный паттерн. GitHub Environments позволяют требовать protection rules до запуска job и до доступа к secrets; среди правил есть required reviewers, запрет self-review и запрет обхода. Это граница workflow, а не доказательство того, что ваш selector выбрал правильные объекты. Содержимое approval и предмет изменения всё равно должны быть связаны в вашей системе.
\nExecute отвечает на узкий вопрос: принял ли исполнитель запрос на изменение и что произошло с каждым target. Сохраните результат по объектам, а не только итоговое число. Для каждого элемента полезны beforeVersion, действие, ответ внешнего API, время, retry count и итоговый статус.
Журнал — это evidence о ходе операции, но не текущее состояние данных. Он должен связывать operation id, target id, digest, identity, версию кода и correlation id. Не записывайте туда секреты и не называйте лог неизменяемым, если хранилище допускает редактирование. Kubernetes описывает аудит как хронологические записи о действиях пользователей, приложений и control plane, а policy определяет, что именно попадёт в backend. Эта модель полезна, но её полноту и срок хранения нужно проверить для конкретной платформы.
\n| Наблюдение | Что оно доказывает | Чего не доказывает | Следующий шаг |
|---|---|---|---|
| Preview создан | В момент чтения найден такой scope | Scope не изменился | Сохранить digest и срок действия |
| Approval получен | Reviewer принял конкретную карточку | Другой scope разрешён | Сверить digest перед execute |
| Команда завершилась с кодом 0 | Процесс не сообщил об ошибке | Данные соответствуют expectation | Запустить независимый reader |
| Audit event записан | Событие попало в доступный журнал | Запись во внешнем источнике успешна | Проверить authoritative source |
| Один target matched | Только этот target наблюдается в ожидаемом состоянии | Остальные targets исправны | Вернуть subset и общий статус partial |
После execute отдельный reader читает authoritative source — тот источник, которому вы доверяете для статуса заявки, поля формы или версии объекта. Reader сравнивает фактическое состояние с expectation из карточки. Минимальный результат — matched, mismatched или unknown.
Unknown нужен для таймаута, недоступного API, задержки репликации и ответа, в котором отсутствует нужное поле. Превращать его в успех опасно: batch-runner тогда закрывает операцию без доказательства. При partial execution выдайте точный subset: какие targets matched, какие mismatched, какие неизвестны. Общий статус в таком случае остаётся остановленным, даже если большинство элементов совпало.
Проверка должна быть независимой хотя бы по одному важному измерению. Если execute и verification используют один кэш, одну ошибочную трансформацию или тот же промежуточный ответ, они могут согласованно подтвердить неверное состояние. Независимость не обязана означать другую команду или другой кластер, но источник и критерий чтения должны быть явно названы.
\nПосле mismatch хочется вызвать обратную команду в finally. Для массовых данных такой inverse может увеличить ущерб: объект уже изменили вручную, старое значение устарело, а selector стал шире. Поэтому сначала создайте recovery plan с subset, наблюдаемыми версиями, неизвестными значениями, владельцем восстановления и допустимым лимитом.
Затем recovery проходит тот же маршрут: новый scope, новый authority check, новый preview, новое approval, запись результата и новая verification. Старое согласование не даёт права на обратную запись. Если предыдущая операция удаляла данные или перезаписывала поле без сохранения версии, честный результат может быть «автоматически восстановить нельзя». Это повод остановиться и привлечь владельца данных, а не запускать «вернуть всё назад».
\nНа тестовых данных воспроизведите три сценария. Нормальный scope должен пройти до matched. Scope больше authority должен остановиться до approval или execute. Замена одного target после approval должна остановиться на повторной проверке digest. Для каждого сценария сохраните operation id, причину остановки или результаты verification и подтвердите отсутствие запрещённой записи. Такой тест доказывает работу границ, но не гарантирует безопасность production без проверки гонок, прав и хранилища.
\nЭта схема не делает любую batch-операцию безопасной автоматически. Она не доказывает идемпотентность, транзакционность, полноту inventory, свежесть реплики, корректность identity provider, неизменяемость журнала или возможность восстановить старые значения. Если API допускает partial success, правило остановки и повторного запуска нужно определить заранее.
\nУчебный JavaScript использует память процесса, фиксированную дату и строковый digest. Он не permission system, не durable audit storage и не клиент реального API. В production нужно определить формат канонизации, защиту preview, максимальный возраст данных, version check или optimistic concurrency, политику retry, владельца unknown и способ ручного recovery. Неизвестное условие уменьшает допустимый scope; оно не является основанием расширить полномочия.
\n--dry-run=client как preview объекта без отправки в кластер и рекомендует стабильный машинный вывод для скриптов.