diff --git a/work/projects/cpm-episode-telemetry-phase2-plan-2026-10-20.md b/work/projects/cpm-episode-telemetry-phase2-plan-2026-10-20.md new file mode 100644 index 00000000..dfdd84dc --- /dev/null +++ b/work/projects/cpm-episode-telemetry-phase2-plan-2026-10-20.md @@ -0,0 +1,74 @@ +--- +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 мин) на паузе замораживается и продолжается с остатком, не начинается заново. `response_after` после этого — время наблюдения, не wall clock. +- Замеры, уже получившие ответ, не трогаем. + +### 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 мало одновременных вкладок, значение будет другим. + +## Ограничения и риски +- Если iOS убьёт приложение в фоне, незавершённый эпизод теряется (принято в фазе 1). +- Пропущенное событие паузы ломает учёт; начальное состояние (запуск в фоне) брать из системы, не считать «активным». +- `was_stuck` меняет смысл относительно описания в PR #7376 и в любых данных до выкатки; данные до и после изменения не склеивать по этому полю. +- Сохранённые, но не используемые в этой фазе правки (`stuck` = true/false × one/many, продолжение `bg_events` через `;`) лежат на локальной ветке `alex/cpm-7376-extras-backup`.