Files
obsidian-vault/work/projects/cpm-episode-telemetry-phase2-plan-2026-10-20.md
T

98 lines
14 KiB
Markdown
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.
---
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`.