--- title: 'CPM episode telemetry — фаза 2: паузы по фону/сну и смысл was_stuck (план анализа)' date: '2026-10-20' tags: - work - cpm - telemetry - plan --- # CPM episode telemetry — фаза 2 Статус (21 октября 2026): **реализовано и смержено в оба релиза** (см. «Что ушло в релиз»). Фаза 1 — PR #7376 (`web_extension_cpm_episode`). Состав отчёта фазы 1: [[cpm-pr0-episode-telemetry-summary-2026-10-16]]. Общий контекст: [[cpm-status-2026-10-15]]. Паузы по фону/сну и новый смысл `was_stuck` вошли в сам #7376 (коммит `88154a4c4a`), а не в отдельную ветку, как планировалось 20 октября. Obsidian MCP не подключался, файл записан напрямую. ## Что ушло в релиз Проверено 21 октября 2026 по tip релизных веток: все 8 PR смержены (squash), результат совпадает с запушенными ветками по CPM-путям, CPM-файлы пакета идентичны в двух релизах (отличаются только файлы с контентом релиза: remote-disable-enforcer и т. п.), тесты пакета зелёные (iOS 934, macOS 965), `validate-pixel-defs` для iOS и macOS = 0. | Что | iOS `release/ios/7.243.0` | macOS `release/macos/1.213.0` | |---|---|---| | Эпизодный пиксель, паузы, `was_stuck` (2+ вкладки) | #7376 `5dca645528` | #7484 `bd68e42a15` | | `tabs_affected`, `repeat_failures`, `interrupted`, токен `interruption` в `bg_events` | #7520 `507567edaf` | #7521 `8e6b98ae3b` | | Network process recovery (флаг `cpmNetworkProcessRecovery`) | #7459 `77d7589b79` | #7531 `5512905ae2` | | P1/P2/P4: старт и пробуждение фона (7379) | #7379 `543b073685` | #7536 `12ebc16fcd` | Флаги, по умолчанию: ON — `cpmNetworkProcessRecovery`, `cpmBackgroundLoadRecovery`, `cpmMessagingHangRecovery`, `cpmBackgroundDelegateProxy`, `cpmDiagnosticsRecorder`; OFF — `cpmLateBackgroundDocumentRecovery`, `cpmBackgroundGraveyardTreatment`. При отсутствии записи в remote config считаются значения по умолчанию, то есть ON-флаги работают сразу, выключаются только записью `disabled`. **Отличия от плана ниже:** - Post-reload grace 8 с (пункт в «Таймауты») **не реализован**: после перезагрузки расширения первый замер по-прежнему 4 с. 8 с применяется только к ожиданиям, прерванным паузой. - «Новых параметров нет» относится к фазе A. В релиз ушли ещё три параметра (`tabs_affected`: 1/2/3+, `repeat_failures`: 0/1/2+, `interrupted`) через #7520/#7521 с отдельным privacy-триажем. Читать данные надо уже с ними (см. «Как читать данные»). - `interrupted` = первое сработавшее ожидание (замер, открывший эпизод) было перезапущено паузой, включая ожидание загрузки расширения (30 с). Приостановка позже внутри эпизода флаг не ставит. ## Зачем Проверка кода 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) тестами не покрыта. ### #7379 × #7459: смысловые пересечения (в релиз ушли как есть) и что смотреть в данных Обе фичи в релизе, оба включены по умолчанию, текстово слиты (аудит 20 октября, слияния 21 октября), но **смысловые пересечения не устранены** и на реальном WebKit вместе не проверялись (тесты с заглушками). Это сознательное решение: правка ниже узкая, меняет поведение в уже одобренных PR и без данных не оправдана. Пересечения: 1. Рестарт #7459 (`_killWebContentProcessAndResetState`, без события жизненного цикла) невидим для записи о старте #7379. Если у менеджера в этот момент висит старт, он остаётся привязан к убитому view; следующая навигация получает старый старт и новый `loadBackgroundContent` не вызывается, пока не сработает колбэк убитого view. 2. Ошибка 6 от самого убийства может запустить повтор загрузки #7379 и потратить его бюджет (не чаще раза в 60 с); от этого повтора может прийти «ложный» `background_load_retry`. 3. Флаг «идёт восстановление» у #7379 защищает от гонки только с перезагрузкой по зависанию, не с рестартом #7459; проверка «приложение в фоне» есть у #7379, нет у #7459 (его 5-секундный опрос может сработать в фоне). 4. Окна #7379 (30 с) на реальных часах и о паузах монитора не знают (после сна истекают сами). 5. Три независимых вызова `loadBackgroundContent`: наблюдатель готовности (#7376), стартовый путь и навигационный гейт (#7379). Что смотреть в данных (общего идентификатора у пикселей нет, только по дню и платформе): - `web_extension_cpm_network_process_recovery_applied`: `_count` сильно больше `_daily` значит цикл рестартов. - `web_extension_cpm_startup_response_missing` в дни с большим `recovery_applied`; рост указывает на пересечение 1 или 3. - Эпизоды: доля `no_response_before_deadline` и `bg_events` с `died_client` после рестарта (неудачное восстановление); `reload_state` как запасная перезагрузка по зависанию. - Success-пиксели #7379 (`outcome=background_ready`) вместе с `recovery_applied`: резкий рост повторов после рестартов указывает на пункт 2. **Отложенная правка** (комментарий 21 октября 2026): подписать рестарт #7459 на сброс состояния #7379 только для перезапущенного контекста. Worktree удалены 21 октября 2026; полный патч (с тестами) сохранён в `~/DuckDuckGo/cpm-worktree-backups-20261021/alex_cpm-fix-init-loss-production.diff` (iOS) и `alex_cpm-fix-init-loss-macos.diff` (macOS); применяется поверх `release/ios/7.243.0` и `release/macos/1.213.0` через `git apply --3way`. Ветки `alex/cpm-fix-init-loss` и `alex/cpm-fix-init-loss-macos-release` смержены в релизы. Оценка: польза узкая (работает, только если на момент рестарта старт ещё не завершён; обычно он уже завершён), побочные эффекты (ждущие навигации отпускаются раньше 5 с, теряется замер первой загрузки и install/upgrade для перезапущенной попытки). Ревью дважды (независимо): безопасно, крашей и рекурсии нет; нужно добавить тест менеджера (открыть `cpmNetworkProcessRecovery` internal) и записать ограничения в описание PR. Отдельно отвергнута первая версия, которая сбрасывала состояние на каждый возврат приложения: на iOS это каждое переключение приложений и сброс легитимного старта. Ключевая часть патча (плюс два теста: «хук вызывается один раз с идентификатором» и «не вызывается при неудачном рестарте»): ```diff --- CPMNetworkProcessRecovery.swift + /// Called with the context identifier after a restart, so other recoveries can drop state tied to the replaced background. + var onBackgroundRestarted: (@MainActor (String) -> Void)? ... removeWatch(identifier: identifier, matching: watch) recovered = true + onBackgroundRestarted?(identifier) pixelFiring.fire(.cpmNetworkProcessRecoveryApplied) --- WebExtensionManager.swift (после настройки healthMonitor) + cpmNetworkProcessRecovery.onBackgroundRestarted = { [weak self] identifier in + guard let self, self.embeddedBackgroundStartupContext?.uniqueIdentifier == identifier else { return } + self.backgroundRecovery?.invalidate() + self.clearEmbeddedBackgroundStartup(identifier: identifier) + } ``` Не покрыто и не сделано: подавление ошибки 6 от самого убийства (пересечение 2), учёт события рестарта во флаге «идёт восстановление», тест связки менеджера. Если данные покажут пересечения 1–3, начинать с этого патча. ## Ограничения и риски - Если iOS убьёт приложение в фоне, незавершённый эпизод теряется (принято в фазе 1). - Пропущенное событие паузы ломает учёт; начальное состояние (запуск в фоне) брать из системы, не считать «активным». - `was_stuck` меняет смысл относительно описания в PR #7376 и в любых данных до выкатки; данные до и после изменения не склеивать по этому полю. - Сохранённые, но не используемые в этой фазе правки (`stuck` = true/false × one/many, продолжение `bg_events` через `;`) лежат на локальной ветке `alex/cpm-7376-extras-backup`.