Files
progcode/web/scripts/upgrade-2025-07.mjs
T
huncode 48bf467171
Build and deploy / deploy (push) Successful in 16s
revise July 2025 product metrics articles
2026-07-31 17:56:35 +03:00

181 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) { return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
function bodyText(content) { return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
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 '<ul>' + sources.map((source) => '<li><a href="' + source.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(source.title) + '</a> — версия: ' + escapeHtml(source.version) + '. ' + escapeHtml(source.claim) + ' Граница: ' + escapeHtml(source.boundary) + '</li>').join('') + '</ul>'; }
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 в закреплённой версии требует уникальное имя события и документированную структуру. Для продуктовых событий это полезная дисциплина: <code>product.checkout_confirmed</code> не содержит динамический id, а <code>subject</code>, <code>requestId</code>, <code>cohort</code> и <code>period</code> лежат отдельно. Но соглашение об имени не доказывает связь с решением пользователя. Эту связь сначала формулирует владелец продукта и проверяет инженерный контракт.'),
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(['<strong>Назвать решение.</strong> Не «смотрим conversion», а «оставляем вариант только если подтверждение не растёт ценой failure».', '<strong>Записать причинную цепочку.</strong> Техническое изменение → видимый шаг → действие пользователя → outcome и отдельный guardrail.', '<strong>Зафиксировать cohort, период и denominator.</strong> Эти поля пишут до подсчёта, иначе результат подстраивают под удобный ответ.', '<strong>Проверить negative path.</strong> Нехватка attribution, иной period или иной denominator должны остановить decision, а не превратиться в null на графике.', '<strong>Сохранить decision record.</strong> Указать 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 отсутствует, функция возвращает <code>missing-attribution-rule</code>. Это важнее нулевого графика: нельзя понять, относится ли 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 с <code>checkout_confirmed</code>, делённое на unique subjects с <code>checkout_opened</code>, внутри одного cohort и одного дня. Такой contract намеренно мал. Он не говорит, сколько ждать пользователя, как обрабатывать два устройства и что считать «успешным заказом». Но он не даёт случайно заменить denominator на <code>all-events</code> и выдать плотность 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 с <code>all-events</code> получает stop до human decision, хотя набор событий внешне похож на valid case. Для реального расчёта отдельно нужны randomization, задержка доставки, политика идентификаторов и проверка pipeline.'),
h2('Порядок проверки сравнения'),
ol(['<strong>Назвать decision.</strong> Сформулировать, какое действие будет разрешено или остановлено, а не просто открыть dashboard.', '<strong>Зафиксировать population.</strong> До запроса записать cohort, period, inclusion и attribution.', '<strong>Разложить дробь.</strong> Показать numerator и denominator отдельно и проверить, что они относятся к одной задаче.', '<strong>Положить рядом guardrail.</strong> Сравнить его по тому же окну и назвать владельца порога.', '<strong>Вернуть HOLD при разрыве.</strong> 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 из следующего дня. Результат <code>mixed-cohort-or-period</code> останавливает расчёт до сравнения. Это не статистический тест и не защита от всех ошибок: код не проверяет рандомизацию, задержку доставки или личность 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('В этой теме остановка — результат, а не исключение. Если <code>checkout_confirmed</code> пришёл без requestId, отчёт не должен гадать, к какому открытию его отнести. Если в treatment попал следующий день, отчёт не должен выровнять его средним. Если denominator стал <code>all-events</code>, расчёт не должен сохранять прежнее название 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(['<strong>Сначала прочитать решение.</strong> Если нельзя закончить фразой «оставить или остановить вариант при таких условиях», метрика ещё не выбрана.', '<strong>Затем проверить событие.</strong> Имя, subject, cohort, period и ключ attribution должны быть записаны до query.', '<strong>Разложить ratio.</strong> Отдельно вывести unique numerator и unique denominator; не подменять субъект строкой telemetry.', '<strong>Посмотреть guardrail тем же срезом.</strong> Если у него другой период или population, назвать ограничение, не сравнивать молча.', '<strong>Прогнать один отрицательный вход.</strong> Missing attribution, mixed period или wrong denominator обязан привести к HOLD.', '<strong>Зафиксировать evidence.</strong> Ссылка на 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');