function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } const p = (text) => '

' + text + '

'; const h2 = (text) => '

' + text + '

'; const code = (text) => '
' + escapeHtml(text) + '
'; const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; function plainText(content) { return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } function bodyText(content) { return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); } function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } function revision(meta, parts) { const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + sourceList(); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } const sources = deepFreeze([ { title: 'OpenTelemetry Semantic Conventions v1.31.0, events (immutable commit c01aa89, 11 March 2025)', url: 'https://raw.githubusercontent.com/open-telemetry/semantic-conventions/c01aa89d9a13042e56536c60975139c50e764796/docs/general/events.md', version: 'tag v1.31.0; immutable commit c01aa89d9a13042e56536c60975139c50e764796, 2025-03-11', claim: 'Событие имеет уникальное имя; структура события и применимые attributes должны быть документированы, а dynamic values не должны становиться частью имени.', boundary: 'Это соглашение о семантике telemetry. Оно не определяет продуктовую метрику, causal effect, выбор denominator или критерий ship.', }, { title: 'Microsoft Research: Safe Velocity with Controlled Rollouts (ICSE-SEIP 2019)', url: 'https://www.microsoft.com/en-us/research/uploads/prod/2020/06/Safe-Velocity-ICSE-SEI.pdf', version: 'ICSE-SEIP 2019 author version, published 2019; HTTPS checked 2026-07-31', claim: 'Работа различает local feature, success и guardrail metrics; guardrail нужен, чтобы выпуск не ухудшал важные показатели, а local metric помогает объяснить движение более общей метрики.', boundary: 'Это описание controlled rollouts Microsoft. Оно не доказывает, что любое изменение local metric вызвало пользовательский результат в другом продукте.', }, { title: 'Microsoft Research: A Dirty Dozen Metric Interpretation Pitfalls (KDD 2017)', url: 'https://www.microsoft.com/en-us/research/uploads/prod/2020/08/2017-08-KDDMetricInterpretationPitfalls.pdf', version: 'KDD 2017 author version, published 2017; HTTPS checked 2026-07-31', claim: 'Работа показывает, что разные наборы наблюдений в treatment и control делают metric delta недостоверной; рекомендует разбирать numerator и denominator и отслеживать telemetry loss.', boundary: 'Пример относится к controlled experiments. Он не заменяет дизайн эксперимента, расчёт мощности, privacy policy или проверку конкретного pipeline.', }, ]); function sourceList() { return ''; } const SYNTHETIC_SCOPE = 'p89-product-metrics-2025-07-fixed-in-memory-v1'; const SYNTHETIC_BOUNDARY = 'Все события, варианты, результаты и решения ниже — fixed synthetic JS literals. Модуль не читает файлы, сеть, clock, telemetry, Git, CI, production или user data и не выполняет side effect.'; const FIXED_EVENTS = deepFreeze([ { event: 'product.checkout_opened', subject: 'u-a', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-1' }, { event: 'product.checkout_confirmed', subject: 'u-a', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-1' }, { event: 'product.checkout_opened', subject: 'u-b', cohort: 'treatment', period: '2025-07-14', surface: 'web', requestId: 'r-2' }, { event: 'product.checkout_confirmed', subject: 'u-b', cohort: 'treatment', period: '2025-07-14', surface: 'web', requestId: 'r-2' }, { event: 'product.checkout_opened', subject: 'u-c', cohort: 'treatment', period: '2025-07-14', surface: 'web', requestId: 'r-3' }, { event: 'product.checkout_opened', subject: 'u-d', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-4' }, { event: 'product.render_failed', subject: 'u-d', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-4', errorType: 'synthetic-timeout' }, ]); const FIXED_CASES = deepFreeze({ 'fixed-valid-v1': { cohortPeriod: '2025-07-14', attribution: 'same-subject-and-request', denominator: 'unique-subjects-with-checkout_opened', guardrail: 'render_failed-per-opened', events: FIXED_EVENTS }, 'fixed-missing-attribution-v1': { cohortPeriod: '2025-07-14', attribution: '', denominator: 'unique-subjects-with-checkout_opened', guardrail: 'render_failed-per-opened', events: FIXED_EVENTS }, 'fixed-mixed-period-v1': { cohortPeriod: '2025-07-14', attribution: 'same-subject-and-request', denominator: 'unique-subjects-with-checkout_opened', guardrail: 'render_failed-per-opened', events: [...FIXED_EVENTS, { event: 'product.checkout_confirmed', subject: 'u-e', cohort: 'treatment', period: '2025-07-15', surface: 'web', requestId: 'r-5' }] }, 'fixed-wrong-denominator-v1': { cohortPeriod: '2025-07-14', attribution: 'same-subject-and-request', denominator: 'all-events', guardrail: 'render_failed-per-opened', events: FIXED_EVENTS }, }); function uniqueSubjects(events, cohort, event) { return new Set(events.filter((item) => item.cohort === cohort && item.event === event).map((item) => item.subject)).size; } function evaluateFixedProductMetric(input) { if (!input || !Object.values(FIXED_CASES).includes(input)) return deepFreeze({ accepted: false, reason: 'reject-unrecognised-fixed-input', productionEffect: 'not-attempted' }); const reasons = []; if (!input.attribution) reasons.push('missing-attribution-rule'); if (input.denominator !== 'unique-subjects-with-checkout_opened') reasons.push('wrong-denominator'); if (input.events.some((item) => item.period !== input.cohortPeriod)) reasons.push('mixed-cohort-or-period'); const byCohort = ['control', 'treatment'].map((cohort) => { const opened = uniqueSubjects(input.events, cohort, 'product.checkout_opened'); const confirmed = uniqueSubjects(input.events, cohort, 'product.checkout_confirmed'); const failed = uniqueSubjects(input.events, cohort, 'product.render_failed'); return deepFreeze({ cohort, opened, confirmed, conversion: opened ? confirmed / opened : null, failed, guardrailRate: opened ? failed / opened : null }); }); const decision = reasons.length ? 'hold-for-measurement-repair' : 'eligible-for-human-decision'; return deepFreeze({ scope: SYNTHETIC_SCOPE, accepted: reasons.length === 0, reasons, definition: deepFreeze({ attribution: input.attribution || 'missing', denominator: input.denominator, guardrail: input.guardrail, cohortPeriod: input.cohortPeriod }), byCohort, decision, productionEffect: 'not-attempted' }); } export function createFixedProductMetricInput(caseId) { return FIXED_CASES[caseId] || null; } export function inspectFixedProductMetric(input) { return evaluateFixedProductMetric(input); } export function prepareFixedProductDecision(report) { return deepFreeze({ kind: 'fixed-synthetic-decision-record-v1', accepted: report?.accepted === true, status: report?.accepted ? 'needs-human-owner-decision' : 'stop-before-product-decision', evidence: report?.definition || null, reasons: report?.reasons || ['missing-report'], productionEffect: 'not-attempted' }); } export function runProductMetricFixture() { const valid = evaluateFixedProductMetric(FIXED_CASES['fixed-valid-v1']); const noAttribution = evaluateFixedProductMetric(FIXED_CASES['fixed-missing-attribution-v1']); const mixed = evaluateFixedProductMetric(FIXED_CASES['fixed-mixed-period-v1']); const denominator = evaluateFixedProductMetric(FIXED_CASES['fixed-wrong-denominator-v1']); return deepFreeze({ assertions: deepFreeze({ validAccepted: valid.accepted, validDecisionIsHuman: prepareFixedProductDecision(valid).status === 'needs-human-owner-decision', validConversion: valid.byCohort[1].conversion === 0.5, validGuardrail: valid.byCohort[0].guardrailRate === 0.5, attributionStops: noAttribution.reasons.includes('missing-attribution-rule'), mixedStops: mixed.reasons.includes('mixed-cohort-or-period'), denominatorStops: denominator.reasons.includes('wrong-denominator'), noProduction: valid.productionEffect === 'not-attempted', frozenEvents: Object.isFrozen(FIXED_EVENTS), unknownStops: !evaluateFixedProductMetric({}).accepted, fixedInputFrozen: Object.isFrozen(FIXED_CASES['fixed-valid-v1']) }) }); } const common = [ h2('Граница модели и терминов'), p('В статье «событие» — записанный факт с именем, субъектом, cohort, периодом и контекстом. Cohort — группа, которой показан один вариант. Attribution — правило, по которому действие относят к этому варианту. Guardrail — отдельная метрика, которая не даёт купить рост локального показателя деградацией рядом. Это не словарь для дашборда: каждое поле должно отвечать на вопрос, почему изменение разрешили или остановили.'), p('OpenTelemetry в закреплённой версии требует уникальное имя события и документированную структуру. Для продуктовых событий это полезная дисциплина: product.checkout_confirmed не содержит динамический id, а subject, requestId, cohort и period лежат отдельно. Но соглашение об имени не доказывает связь с решением пользователя. Эту связь сначала формулирует владелец продукта и проверяет инженерный контракт.'), h2('Один fixed synthetic пример'), code("import { createFixedProductMetricInput, inspectFixedProductMetric, prepareFixedProductDecision } from './upgrade-2025-07.mjs';\n\nconst report = inspectFixedProductMetric(\n createFixedProductMetricInput('fixed-valid-v1'),\n);\nconst decision = prepareFixedProductDecision(report);\n\nconsole.log({ accepted: report.accepted, cohorts: report.byCohort, status: decision.status });\n// Fixed literals only: no HTTP, file, telemetry, clock or production action."), p('В наборе четыре открытия, два подтверждения и одно synthetic render failure. Модель считает unique subjects, а не число строк: один повторный event не должен незаметно увеличить denominator. У treatment один из двух открывших подтвердил действие; у control подтверждает один из двух. Это не результат реального продукта и не статистический вывод: небольшой fixed набор нужен только для проверки того, что contract сохраняет cohort, period, denominator и guardrail рядом.'), table('Контракт события и роль поля', ['Поле', 'Пример', 'Проверка', 'Нельзя заключать'], [['event', 'product.checkout_opened', 'имя описывает тип факта', 'что пользователь доволен'], ['subject', 'u-b', 'deduplicate denominator', 'что это реальная личность'], ['cohort', 'treatment', 'сравнить один вариант с control', 'что варианты случайно распределены'], ['period', '2025-07-14', 'не смешать окна', 'что эффект сохранится завтра'], ['requestId', 'r-2', 'связать шаги одной попытки', 'что причина уже доказана'], ['errorType', 'synthetic-timeout', 'посчитать guardrail', 'что это production incident']]), h2('Общий маршрут до решения'), ol(['Назвать решение. Не «смотрим conversion», а «оставляем вариант только если подтверждение не растёт ценой failure».', 'Записать причинную цепочку. Техническое изменение → видимый шаг → действие пользователя → outcome и отдельный guardrail.', 'Зафиксировать cohort, период и denominator. Эти поля пишут до подсчёта, иначе результат подстраивают под удобный ответ.', 'Проверить negative path. Нехватка attribution, иной period или иной denominator должны остановить decision, а не превратиться в null на графике.', 'Сохранить decision record. Указать owner, evidence, ограничения и следующий запуск; human owner, а не функция, принимает product decision.']), p(SYNTHETIC_BOUNDARY), ]; const practice = revision({ slug: 'editorial-2025-07-practice-product-metrics', title: 'Метрики продукта для инженера: построить цепочку, а не поднять conversion', categories: ['Метрики', 'Инженерия'], cover: '/assets/editorial/2025/product-metrics-2025-causal-funnel.svg', excerpt: 'Практический маршрут: связать изменение, событие, действие пользователя, guardrail и решение так, чтобы ошибка измерения останавливалась раньше релиза.', readingMinutes: 12 }, [ p('Инженер ускоряет экран и видит рост conversion на графике. Через несколько дней оказывается, что поменялся не выбор пользователя, а способ считать открытие: часть повторных попыток исчезла из denominator. Цена ошибки — выпуск решения по красивому числу, повторное расследование и потеря времени у продукта, аналитики и разработки.'), p('Причина в разорванной цепочке. Latency, click и confirm часто живут в разных отчётах, а failure остаётся техническим alert. Проверка начинается не с нового dashboard: нужно назвать одно решение, один вход в воронку, одно правило attribution и один guardrail. Действие — записать их в contract до того, как смотреть delta.'), h2('От технического изменения к пользовательскому решению'), p('Для примера возьмём synthetic изменение: экран подтверждения получает предзагруженную форму. Гипотеза не звучит как «latency важен». Она звучит уже: «если форма открывается без дополнительного ожидания, больше пользователей, которые открыли checkout, доходят до confirm; при этом render failures не должны стать чаще». Здесь loading step — механизм, opened и confirmed — наблюдаемые точки, а failure — цена, которую нельзя спрятать за conversion.'), figure('/assets/editorial/2025/product-metrics-2025-causal-funnel.svg', 'Причинная воронка fixed synthetic примера: техническое изменение ведёт к открытию checkout, подтверждению и решению пользователя; сбоку отдельно проходит guardrail render failure. Каждое ребро подписано событием и границей интерпретации.', 'Воронка не утверждает причинность сама по себе. Она заставляет до измерения указать, какой шаг наблюдаем, где attribution и какое ухудшение останавливает выпуск.'), table('Цепочка и проверка', ['Звено', 'Что фиксируем', 'Симптом ошибки', 'Действие'], [['Изменение', 'variant и owner', 'невозможно сказать, что сравнивали', 'остановить карточку'], ['Открытие', 'checkout_opened, subject, period', 'denominator плавает', 'считать unique subjects'], ['Подтверждение', 'checkout_confirmed + requestId', 'шаг нельзя отнести к попытке', 'записать attribution rule'], ['Guardrail', 'render_failed / opened', 'conversion растёт вместе с отказами', 'hold и расследовать']]), ...common, h2('Что считать, а что не выдавать за доказательство'), p('Microsoft Research различает local feature metrics, более общие success metrics и guardrail. Это хороший ориентир для раскладки, но не готовая формула для любого продукта. В нашем примере confirm/opened — local metric: он показывает, добрался ли пользователь до целевого шага. Он не равен долгосрочному outcome и не доказывает, что предзагрузка стала причиной. Поэтому decision record хранит формулировку гипотезы и отдельно пишет: «нужна проверка design и достаточного наблюдения».') , p('Если attribution отсутствует, функция возвращает missing-attribution-rule. Это важнее нулевого графика: нельзя понять, относится ли confirm к показанному варианту, другому устройству или повторной попытке. Следующий шаг не «взять больше строк», а зафиксировать ключ связи и проверить, что он есть у обоих событий. Только после этого имеет смысл обсуждать latency или текст кнопки.'), h2('Ограничение и следующий шаг'), p('Эта практика не заменяет randomized experiment, расчёт мощности, privacy review или реальный ownership. Fixed literals не показывают статистическую значимость и не содержат денег, команд или production telemetry. Следующий шаг: выберите одну воронку, выпишите denominator в виде множества субъектов и добавьте test, где confirmation пришёл без requestId. Ожидаемый результат — hold с понятной причиной, а не искусственный рост conversion.'), h2('Историческая граница июля 2025'), p('Использованы OpenTelemetry v1.31.0 от 11 марта 2025 и работы Microsoft Research 2017 и 2019 годов. Они существовали до 31 июля 2025. Из них взяты только дисциплина события, различение metric roles и риск разного набора наблюдений. Конкретная causal chain, поля и stop decisions — инженерная конструкция этой статьи, не нормативное следствие источников.'), ]); const mechanism = revision({ slug: 'editorial-2025-07-mechanism-product-metrics', title: 'Почему рост метрики не является решением: denominator, cohort и guardrail', categories: ['Метрики', 'Качество'], cover: '/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg', excerpt: 'Механика проверки: отделить movement локальной метрики от допустимого решения и остановить расчёт при смешанных периодах, неверном denominator или отсутствии attribution.', readingMinutes: 13 }, [ p('На отчёте treatment выглядит лучше control, но numerator и denominator собраны из разных наборов. Иногда это случается после невинной оптимизации: страница перестаёт перезагружаться, клиентский event теряется или cohort получает другое окно. Цена ошибки — false positive: команда объясняет пользователю эффект, которого не было, и выпускает изменение с неизвестным ущербом.'), p('Причина не в самой дроби. Любая ratio скрывает состав числителя и знаменателя. Проверка — разложить metric на набор субъектов, события и период, затем убедиться, что treatment и control отвечают на один и тот же вопрос. Действие — сделать эти условия частью result; если хотя бы одно не выполнено, решение получает stop status.'), h2('Metric — это контракт, а не имя на графике'), p('В fixed model conversion задан как unique subjects с checkout_confirmed, делённое на unique subjects с checkout_opened, внутри одного cohort и одного дня. Такой contract намеренно мал. Он не говорит, сколько ждать пользователя, как обрабатывать два устройства и что считать «успешным заказом». Но он не даёт случайно заменить denominator на all-events и выдать плотность telemetry за поведение людей.'), figure('/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg', 'Матрица fixed synthetic расчёта: local metric, success interpretation и guardrail разделены; строки missing attribution, wrong denominator и mixed period ведут к HOLD, а не к ship.', 'Матрица показывает границу: удачный local signal разрешает только human review, а любое нарушение измерительного контракта останавливает product decision.'), table('Negative paths, которые нельзя сгладить', ['Сбой', 'Почему delta нельзя читать', 'Машинный result', 'Ремонт'], [['Нет attribution', 'confirm не связан с вариантом', 'missing-attribution-rule', 'добавить ключ связи и test'], ['Смешан period', 'группы не наблюдались в одном окне', 'mixed-cohort-or-period', 'зафиксировать окно до запроса'], ['Неверный denominator', 'ratio отвечает на другой вопрос', 'wrong-denominator', 'считать unique opened subjects'], ['Guardrail деградирует', 'локальный выигрыш куплен отказами', 'eligible, но human hold', 'разобрать error path']]), h2('Проверить ratio до интерпретации'), p('Numerator — число подтверждений, denominator — число открывших, но оба значения имеют смысл только вместе с правилом inclusion. Cohort здесь означает вариант сравнения, attribution связывает подтверждение с попыткой, а guardrail описывает недопустимое ухудшение рядом. Эти термины не доказывают причинность: они лишь делают видимыми условия, без которых ratio отвечает на другой вопрос.'), code("import { createFixedProductMetricInput, inspectFixedProductMetric, prepareFixedProductDecision } from './upgrade-2025-07.mjs';\n\nconst report = inspectFixedProductMetric(\n createFixedProductMetricInput('fixed-wrong-denominator-v1'),\n);\nconst decision = prepareFixedProductDecision(report);\n\nconsole.log({ accepted: report.accepted, reasons: report.reasons, status: decision.status });\n// Expected: wrong-denominator and stop-before-product-decision.\n// The input is fixed synthetic data in memory; no metric is sent or published."), p('Этот пример не вычисляет «правильную conversion» задним числом. Он показывает, что имя метрики не переносится на другой denominator. Report с all-events получает stop до human decision, хотя набор событий внешне похож на valid case. Для реального расчёта отдельно нужны randomization, задержка доставки, политика идентификаторов и проверка pipeline.'), h2('Порядок проверки сравнения'), ol(['Назвать decision. Сформулировать, какое действие будет разрешено или остановлено, а не просто открыть dashboard.', 'Зафиксировать population. До запроса записать cohort, period, inclusion и attribution.', 'Разложить дробь. Показать numerator и denominator отдельно и проверить, что они относятся к одной задаче.', 'Положить рядом guardrail. Сравнить его по тому же окну и назвать владельца порога.', 'Вернуть HOLD при разрыве. Repair contract предшествует product discussion.']), p(SYNTHETIC_BOUNDARY), h2('Почему guardrail живёт рядом, а не в конце отчёта'), p('Guardrail не обязан быть «самой важной» метрикой. Его работа проще: заранее назвать ухудшение, которое делает локальный выигрыш недостаточным. В synthetic наборе один render_failed у двух opened в control не создаёт сравнения для реального продукта. Он показывает контракт: failure имеет тот же cohort и тот же denominator base, поэтому его нельзя потерять при смене панели. В реальном проекте порог и owner определяются до запуска, а не после удачного результата.'), p('Работа Safe Velocity описывает guardrail как показатель, который не должен ухудшаться при rollout, и local metric как средство объяснить изменения. Нельзя перевернуть эту фразу в гарантию: если local metric вырос, значит outcome улучшился. Между ними остаются выборка, вариант, сезонность, качество событий и другие механизмы. Поэтому автор оставляет человеческое решение последним шагом и требует доказательства, достаточного для конкретного риска.'), h2('Разбор numerator и denominator'), p('Работа Microsoft о metric pitfalls особенно полезна именно здесь: она показывает пример, где другой состав наблюдений делает delta ненадёжной, и предлагает декомпозицию numerator и denominator. Наше практическое следствие скромнее: вместе с числом хранить список правил, из которого оно получено. Если после изменения request path исчезла часть opened, alert должен говорить о denominator, а не радоваться conversion.'), p('Проверка в модуле добавляет один confirm из следующего дня. Результат mixed-cohort-or-period останавливает расчёт до сравнения. Это не статистический тест и не защита от всех ошибок: код не проверяет рандомизацию, задержку доставки или личность subject. Зато он делает распространённую подмену видимой и не позволяет default-фильтру quietly собрать удобную выборку.'), h2('Ограничение и следующий шаг'), p('Не делайте из guardrail универсальный список. У одной операции критичен отказ, у другой — отмена, доступность или безопасность; метрика получает смысл только рядом с пользовательским решением и владельцем риска. Следующий шаг: возьмите одну ratio из своего dashboard, напишите её numerator и denominator словами, затем подайте в test события из другого периода. Ожидаемый результат — расчёт остановлен с причиной, которую может исправить конкретный owner.'), h2('Историческая граница июля 2025'), p('Microsoft Research PDF 2017 года использован для риска sample/metric mismatch, а работа 2019 года — для ролей local, success и guardrail. OpenTelemetry v1.31.0 закреплён commit от марта 2025. Они не доказывают причинность fixed example и не задают пороги. Структура decision record и её stop reasons — ограниченный проектный выбор автора.'), ]); const field = revision({ slug: 'editorial-2025-07-field-product-metrics', title: 'Полевой журнал продуктовой метрики: evidence до решения', categories: ['Метрики', 'Процессы'], cover: '/assets/editorial/2025/product-metrics-2025-decision-evidence-loop.svg', excerpt: 'Полевой формат decision record: собрать цепочку, metric definition, guardrail, negative path и ограничение так, чтобы владелец мог принять или остановить решение.', readingMinutes: 13 }, [ p('Самая дорогая ошибка в продуктовой метрике часто происходит не в SQL и не в графике. В review остаётся фраза «conversion вырос», но не остаётся периода, cohort, definition и guardrail. Через неделю никто не может повторить расчёт или объяснить, почему выпуск состоялся. Цена ошибки — повторная работа, спор об owner и решение, которое нельзя защитить evidence.'), p('Причина — результат отделён от решения. Dashboard хранит число, а контекст живёт в сообщениях и памяти участников. Проверка — собрать короткий decision record до discussion: изменение, causal hypothesis, event contract, denominator, cohort/period, guardrail, evidence и limitation. Действие — отправить на human decision только карточку без red flags; остальные остановить и вернуть владельцу измерения.'), h2('Карточка решения должна помещаться в review'), table('Fixed synthetic decision record', ['Поле', 'Значение', 'Вопрос reviewer'], [['Decision', 'leave / hold после human review', 'какой риск принимаем?'], ['Hypothesis', 'форма без ожидания помогает пройти confirm', 'какое звено наблюдаем?'], ['Attribution', 'same subject and request', 'как confirm связан с вариантом?'], ['Window', '2025-07-14', 'одинаков ли период cohort?'], ['Local metric', 'confirmed unique subjects / opened unique subjects', 'каков denominator?'], ['Guardrail', 'render_failed / opened', 'что останавливает выпуск?'], ['Limitation', 'fixed synthetic only', 'чего evidence не доказывает?']]), figure('/assets/editorial/2025/product-metrics-2025-decision-evidence-loop.svg', 'Цикл evidence для product decision: определить change и hypothesis, зафиксировать event contract, проверить cohort-period-denominator и guardrail, затем создать human decision record либо HOLD с причиной ремонта.', 'Decision loop специально заканчивается человеком и ограничением. Рисунок не превращает один metric delta в автоматический ship.'), h2('Карточка хранит условия решения, а не красивый итог'), p('Cohort — это группа с одним вариантом, attribution — правило связи попытки и подтверждения, denominator — множество открывших, guardrail — отдельное ограничение на ущерб. В field record они нужны не для терминологической полноты, а чтобы reviewer увидел, какое поле надо вернуть владельцу измерения. Если отсутствует хотя бы одно, фраза «conversion вырос» не становится решением.'), p('Здесь нет настоящего пользователя, записи сессии или telemetry: subject и requestId — fixed synthetic labels. Они показывают место для связи, но не советуют хранить реальный идентификатор. Модуль создаёт только decision hand-off или HOLD; он не запрашивает dashboard и не выпускает вариант.'), h2('Как выглядит полезный negative path'), p('В этой теме остановка — результат, а не исключение. Если checkout_confirmed пришёл без requestId, отчёт не должен гадать, к какому открытию его отнести. Если в treatment попал следующий день, отчёт не должен выровнять его средним. Если denominator стал all-events, расчёт не должен сохранять прежнее название conversion. Каждая ветка возвращает короткую причину и владельца ремонта: instrumentation owner, query owner или experiment owner.'), code("import { createFixedProductMetricInput, inspectFixedProductMetric, prepareFixedProductDecision } from './upgrade-2025-07.mjs';\n\nconst report = inspectFixedProductMetric(\n createFixedProductMetricInput('fixed-mixed-period-v1'),\n);\nconsole.log(prepareFixedProductDecision(report));\n// Expected: stop-before-product-decision with mixed-cohort-or-period.\n// The input is fixed in-memory synthetic data; nothing is published or sent."), p('Такой путь полезен на review: вместо «данных мало» он говорит, какое именно условие нарушено. Вызов не исправляет cohort и не меняет production. Он только не разрешает перейти к решению. Это важно для ответственности: владелец интерфейса не обязан чинить SQL, а владелец metric contract не обязан объяснять UX-гипотезу. Карточка связывает их по явному полю, а не по догадке.'), h2('Порядок полевой проверки'), ol(['Сначала прочитать решение. Если нельзя закончить фразой «оставить или остановить вариант при таких условиях», метрика ещё не выбрана.', 'Затем проверить событие. Имя, subject, cohort, period и ключ attribution должны быть записаны до query.', 'Разложить ratio. Отдельно вывести unique numerator и unique denominator; не подменять субъект строкой telemetry.', 'Посмотреть guardrail тем же срезом. Если у него другой период или population, назвать ограничение, не сравнивать молча.', 'Прогнать один отрицательный вход. Missing attribution, mixed period или wrong denominator обязан привести к HOLD.', 'Зафиксировать evidence. Ссылка на definition, запрос, owner и следующий check делают результат воспроизводимым.']), h2('Что делать, когда local metric двигается'), p('Сначала не объявлять успех. Сверить, что метрика использует тот же event schema и что набор субъектов не менялся. Затем показать guardrail и diagnostic split: отдельно cohorts, period и numerator/denominator. Только после этого reviewer решает, нужно ли расширять наблюдение, повторять запуск или менять реализацию. Этот порядок короче бесконечного dashboard tour, потому что каждый шаг отвечает на отдельный риск.'), p('Статья о metric pitfalls предупреждает, что другой состав выборок может исказить направление delta, а потеря telemetry может быть одним из источников bias. Это подтверждает необходимость проверки, но не создаёт готовый pipeline. В конкретном проекте нужно отдельно проверить privacy, retention, идентификаторы и задержку событий. Synthetic requestId здесь лишь показывает место связи; он не является советом хранить реальный идентификатор пользователя.'), h2('Ограничение и следующий проверяемый шаг'), p('Полевой журнал не обещает, что команда научится доказывать все product effects. Он делает неизвестное явным: какую связь мы наблюдаем, какое ухудшение не принимаем и чего не хватает для решения. В этом пакете нет реальных пользователей, денег, инцидентов, SQL, сети или production query. Поэтому его fixture проверяет только deterministic stop branches и отсутствие side effect.'), p('Следующий шаг: добавьте к одному существующему review ровно четыре строки — decision, denominator, guardrail и limitation. Затем попросите другого инженера восстановить query только по карточке. Если он не может понять cohort или period, не улучшайте визуализацию: сначала исправьте event contract. Ожидаемый результат — спор о выпуске превращается в список проверяемых условий.'), h2('Историческая граница июля 2025'), p('Источники ограничены материалами, существовавшими до 31 июля 2025: Microsoft Research 2017/2019 и OpenTelemetry v1.31.0. Их утверждения отделены от нашего процесса: они поддерживают ценность раздельных metric roles, документированного события и проверки состава выборки. Формат журнала, synthetic literals и решение HOLD — практическая схема автора, а не обещание framework.'), ]); export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); if (process.argv.includes('--verify-fixture')) { const assertions = runProductMetricFixture().assertions; const failed = Object.entries(assertions).filter(([, value]) => value !== true).map(([key]) => key); if (failed.length) { process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); process.exitCode = 1; } else process.stdout.write('PASS fixture: ' + Object.keys(assertions).length + '/' + Object.keys(assertions).length + ' assertions\n'); } if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');