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