--- title: 'CPM episode telemetry — фаза 2: паузы по фону/сну и смысл was_stuck (план анализа)' date: '2026-10-20' tags: - work - cpm - telemetry - plan --- # CPM episode telemetry — фаза 2 Статус: **план, не реализовано.** Фаза 1 — PR #7376 (`web_extension_cpm_episode`, `was_stuck` = любой поздний не-restoration таймаут). Состав отчёта фазы 1: [[cpm-pr0-episode-telemetry-summary-2026-10-16]]. Общий контекст: [[cpm-status-2026-10-15]]. Работа ведётся отдельной веткой поверх #7376 (`alex/cpm-episode-background-pause`, стекнутый PR). Obsidian MCP не подключался, файл записан напрямую. ## Зачем Проверка кода 20 октября 2026 показала два смещения: 1. **Таймеры считают wall clock.** `now` по умолчанию `Date.init`; `discardExpiredState()` вызывается в начале `handle()`, `reportFailure` и `reportSuccess`. Если процесс спал или был в фоне внутри 5-минутного окна, первое событие после пробуждения закрывает эпизод как `no_response_before_deadline`, а пришедший сразу ответ теряется. Для `Task.sleep(nanoseconds:)` считает ли он сон — не проверено. 2. **4-секундный замер тоже.** Фон/сон внутри 4 с даёт ложный первый таймаут, потому что веб-процесс ещё просыпается. Следствие: завышена доля `no_response_before_deadline`, занижены доли исходов с ответом. Число эпизодов, `_daily`/`_count` и диагностический снимок не страдают. ## Решения (согласованы 20 октября 2026) ### Таймауты - Первый замер: **4 с без изменений**. Ожидание загрузки расширения: 30 с без изменений. - События паузы: iOS `didEnterBackground` / `willEnterForeground`; macOS `NSWorkspace.willSleep` / `didWake`. `resignActive` не паузит: процесс жив. - На паузе замеры, ждавшие ответа, замораживаются. **На пробуждении они перезапускаются полным окном 8 с** (отдельная константа, стартовое значение; общий 4 с не трогаем, чтобы не сдвигать базу сравнения). - Дедлайн эпизода (5 мин) на паузе замораживается и продолжается с остатком, не начинается заново, но **не меньше 30 с после пробуждения** (если остаток меньше, ждём 30 с). `response_after` после этого — время наблюдения, не wall clock. - Первый замер после перезагрузки расширения (post-reload): та же длинная grace **8 с** (общая константа с пробуждением); остальные замеры остаются на 4 с. Поздний ответ на том же замере всё равно закрывает эпизод как `failed_measurement_response` с `reload_state=response_timed_out`, так что «медленная перезагрузка» и «не помогла» различимы по `response_after`. - Замеры, уже получившие ответ, не трогаем. ### was_stuck - **Переопределяем смысл (тип boolean не меняется):** `was_stuck=true` — в рамках эпизода таймаут минимум в **двух разных вкладках** (вторая не restoration). Повторы в одной вкладке остаются `was_stuck=false`. - **Триггер автоперезагрузки `onConfirmedHang` не меняем:** любой поздний не-restoration таймаут, как сейчас. Отчётность и логика развязаны. Иначе у пользователей с одной активной вкладкой (iOS) перезагрузка не наступила бы никогда. - Новых параметров нет (счётчик повторов и число вкладок потребовали бы нового privacy-триажа). Цена: счётчик повторов в одной вкладке недоступен. - Частичная замена счётчику: `was_stuck=false` + `reload_state` не `none` ≈ «в одной вкладке сбой повторился, перезагрузили». Не точно: `reload_state` меняется от любых событий перезагрузки расширения, не только от нашего подтверждения. - Требуется обновить описание в json5 (iOS и macOS) и сообщить владельцу privacy-ревью; нужен ли повторный триаж — решает процесс. ## Как читать данные (после изменения) Поля: `reason`, `was_stuck`, `outcome`, `reload_state`, `response_after`, плюс снимок первого сбоя (`bg_*`, 8 булевых, `bg_events`). | Конфигурация | `was_stuck` | `outcome` | `reload_state` | Смысл | |---|---|---|---|---| | One tab, timeout, then response on the same measurement | false | failed_measurement_response | none | Slow, not broken (warm-up / slow response). `response_after` gives the delay bucket. | | One tab, timeout, later navigation in that tab answers | false | failed_tab_response | none | Transient; recovered without reload. | | One tab, timeout repeated on 2+ navigations (reload fired) | false | depends (see below) | pending / loaded / failed / response_timed_out | Single-tab confirmed hang; automatic reload applied. | | ... then a post-reload measurement answers | false | failed_tab_response (or other_tab_response) | loaded | Reload fixed it. | | ... then post-reload measurement times out again | false | any | response_timed_out | Reload did not help in this tab. | | ... then nothing observed | false | no_response_before_deadline | loaded / pending / failed | Reload done, no evidence either way. | | Timeouts in 2+ distinct tabs (2nd is not restoration) | true | no_response_before_deadline | loaded or response_timed_out | Likely persistent, not proven (deadline is not proof). | | Timeouts in 2+ distinct tabs, then any tab answers | true | other_tab_response / failed_tab_response | any | Extension-wide issue that recovered; other_tab_response does not prove the failed tabs recovered. | | Restoration-batch timeouts only | false | usually other_tab_response / failed_tab_response | none | Startup race; restoration failures cannot confirm each other. `reason=session_restoration`. | | First post-reload measurement fails | any | any | response_timed_out | `reason=extension_reload`: reload itself did not restore messaging. | | Idle after first timeout, no further navigation | false | no_response_before_deadline | none | Undetermined. Before the pause fix this also includes sleep/background (biased). | | Response matched several documents | any | unattributed_response | none or loaded | Extension answers, tab not attributable. After a reload this outcome cannot close the episode. | Outcome `no_response_before_deadline` is never proof of permanent failure. `reload_state` is `none` unless an extension reload lifecycle event occurred during the episode. ## Фаза 2 анализа (после выкатки) 1. Распределение `outcome` × `was_stuck` × `reload_state`, по платформам. 2. Эффективность автоперезагрузки: среди `reload_state=loaded` доля `failed_tab_response` против `response_timed_out`. 3. Оценка смещения фона/сна: сравнить долю `no_response_before_deadline` в сборках до и после паузы (фаза 1 vs фаза 2). Разница — размер смещения. Нужны обе когорты, поэтому фазу 1 нужно выкатить первой. 4. Прогрев против поломки по снимку: сильные признаки — `bg_process_alive=false`, `bg_process_responsive=false`, error 6 (`bg_load_error_*`), `bg_ready_after=failed`; прогрев — `bg_ready_after=pending` при малом `bg_view_age`. 5. `was_stuck=true` долю смотреть отдельно по iOS и macOS: на iOS мало одновременных вкладок, значение будет другим. 6. Оценка fix-пикселей #7379 (P1/P2/P4) — см. следующий раздел. ### Как читать fix-пиксели #7379 вместе с эпизодом (ревью 20 октября 2026) Дизайн P1/P2: **либо fix срабатывает и шлёт success-пиксель, либо мы продолжаем получать обычную failure-телеметрию (эпизод).** Отдельных пикселей «fix не помог» нет, и они не нужны: сбой, видимый пользователю, всегда проходит через 4-секундный таймаут навигации и начинает эпизод. - **Success-пиксели:** `web_extension_cpm_{install_readiness, upgrade_readiness, background_wake}` и `web_extension_cpm_background_load_retry`, `outcome=background_ready`. Означает только успешный callback `loadBackgroundContent` (P1: после ≥1 с и если навигация ждала; P2: успешный retry). **Не доказывает, что messaging заработал**: после него эпизод всё ещё может прийти. - **P1:** gate 5 с держит навигацию. Ready ≤5 с и ответ вовремя → есть success-пиксель, эпизода нет. Gate истёк на 5 с и старт всё ещё идёт → эпизод есть (4-секундный таймер начинается после снятия gate), success-пиксель придёт позже, когда callback завершится. Разделить «ready ≤5 с» и «ready поздно» по самому пикселю нельзя. - **P2:** retry успешен до таймаута навигации → success-пиксель, эпизода нет. Retry пропущен (cooldown 60 с, приложение в фоне, kill switch) или снова error 6 → success-пикселя нет; если навигация упала, эпизод несёт `bg_load_error_*` и `bg_ready_after=failed`. Если навигаций не было, не видно ничего, но и пользовательского сбоя нет. - **P4:** `web_extension_cpm_startup_response_missing` — это failure-сигнал, не success. Шлётся в том же месте, где начинается эпизод, независимо от флага late reload. Рост означает, что gate не удержал (первая загрузка >5 с). Что сравнивать после выкатки: 1. Долю эпизодов в когортах P1/P2/P4 (по снимку: `bg_ready_after`, `bg_load_error_*`, recent wake/load) до и после включения fix. Success-пиксели считать не «исправленными», а «fix сработал технически». 2. Отношение success-пикселей к эпизодам той же когорты по installation-day; общего идентификатора у них нет, склейка только по дню. 3. P4: динамику `startup_response_missing` отдельно от эпизодов; она показывает остаточные первые загрузки сверх бюджета gate. Ограничения: - Обе стороны зависят от выкатки нового эпизодного пикселя (#7376, стек); иначе остаются старые раздельные daily-пиксели без парных переходов. Новый baseline нужен. - В тестах #7379 нет проверки, что менеджер реально шлёт success-пиксели P1/P2 (только маппинг метаданных). Тихо пропавший success-пиксель выглядел бы как «fix не работает». - P2 retry идёт под общим флагом `cpmMessagingHangRecovery` (по умолчанию ON), своего kill switch нет. Повторный error 6 через ~30 с попадает в cooldown 60 с. - Доказательства fix: P1/P4 — red/green на harness; P2 — частично (последний полный ON-прогон 8 PASS / 1 P2 FAIL), iOS-часть (основная доля error 6 в stuck) тестами не покрыта. ## Ограничения и риски - Если iOS убьёт приложение в фоне, незавершённый эпизод теряется (принято в фазе 1). - Пропущенное событие паузы ломает учёт; начальное состояние (запуск в фоне) брать из системы, не считать «активным». - `was_stuck` меняет смысл относительно описания в PR #7376 и в любых данных до выкатки; данные до и после изменения не склеивать по этому полю. - Сохранённые, но не используемые в этой фазе правки (`stuck` = true/false × one/many, продолжение `bg_events` через `;`) лежат на локальной ветке `alex/cpm-7376-extras-backup`.