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

14 KiB
Raw Blame History

title, date, tags
title date tags
CPM episode telemetry — фаза 2: паузы по фону/сну и смысл was_stuck (план анализа) 2026-10-20
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.