После релиза: аналитика, крэши, отзывы
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья кончилась раскаткой на 10% и откатом: возвратом канала на прежнюю ветку или новой публикацией — смотря где лежит плохое обновление. Осталось главное: откуда вы узнаете, что стало хуже. Магазин покажет рейтинг через неделю, а бандл уехал час назад. После релиза у приложения три источника правды о себе: события, падения и отзывы — и все три после первого же выката по воздуху ломаются одинаково, потому что версия в магазине больше не равна коду на телефоне. Соберём счётчик событий, который это учитывает и переживает зал без сети; расшифруем стек с телефона по карте; сложим четыре десятка отчётов в три причины и решим по цифрам, чинить или откатывать. Сверено с react-native 0.86, expo-updates 57, @expo/cli 57.0.18 и metro-symbolicate 0.84.5.
До обновлений по воздуху версия в магазине и была кодом: 1.0.0 — это ровно один бандл, собранный один раз. Теперь под той же 1.0.0 живут вшитый бандл из магазина, вчерашнее обновление, сегодняшнее и то, на которое часть телефонов откатилась. Отчёт «падает 1.0.0» после этого не значит ничего: он складывает в одну кучу четыре разных кода.
Лечится это одним приёмом: к каждому событию пришивается контекст запуска, и в нём — id запущенного обновления. Считать его нужно один раз за запуск: до перезапуска рантайма эти значения не меняются (обновление подменяет бандл целиком — это мы разбирали в статье про runtimeVersion).
| Поле события | Откуда берётся | На какой вопрос отвечает |
|---|---|---|
app_version | Constants.expoConfig.version | какая сборка стоит из магазина |
build_number | номер сборки, его ставит EAS | та же версия, но какая по счёту сборка |
update_id | Updates.updateId | какой именно бандл сейчас выполняется |
runtime_version | Updates.runtimeVersion | с каким набором натива этот бандл совместим |
channel | Updates.channel | это боевые пользователи или тестировщики |
embedded | Updates.isEmbeddedLaunch | не откатился ли телефон на вшитый бандл |
В подложке — заглушка expo-updates с теми же полями (настоящий модуль в песочницу не собирается) и данные сборки из магазина. Соберём контекст и пришьём его к событию. Прогноз: одно и то же событие отправлено дважды — до отката на вшитый бандл и после; сколько разных значений окажется у app_version и сколько у update_id?
Версия в магазине одна на оба события, а код — разный, и различить их можно только по update_id. Именно поэтому «после обновления стало падать» — проверяемое утверждение, а не ощущение: в отчёте видно, на каком бандле случилось событие.
Так это выглядит с настоящим expo-updates. Все поля здесь null в разработке и в Expo Go — там обновлений нет вовсе, и это не ошибка, а честный ответ.
Событие лучше называть так, чтобы переменная величина ушла в параметры, а не в имя: set_done с параметром exercise — одно событие с разбивкой, а set_done_squat и set_done_bench — два разных, между которыми в отчёте нет ничего общего. У Firebase на это есть и жёсткие пределы: имя латиницей, не длиннее 40 символов, часть имён занята им самим.
Зал — это подвал, и сети там нет. Очередь на устройстве мы уже собирали в главе 6: операции журнала складываются в исходящую очередь и ждут синхронизации. Механизм тот же, но правила у аналитики другие, и различие важнее сходства.
Первое: событие можно потерять. Потерянный подход — это испорченный дневник, потерянное событие — минус единица в отчёте. Поэтому у очереди событий есть потолок, и при переполнении она выбрасывает самое старое, а не растёт, пока телефон не кончится. Второе: события уходят пачкой, а не по одному. Третье: подтверждения по каждому событию не нужно — достаточно, чтобы повтор не задвоил, а для этого у события есть свой id, рождённый на телефоне (ровно как opId у операций).
В подложке — рубильник сети и приёмник на той стороне, который гасит повторы по id. Прогноз: два события уходят по сети, потом сеть пропадает и человек делает семь подходов подряд, потом сеть возвращается. Сколько событий доедет до сервера и сколько потеряется?
Потерялись ev-3 и ev-4 — самые старые из накопленных: потолок в пять событий сработал ровно так, как задумано. Отказ сети не уронил ничего и не потерял пачку: события остались в очереди и ушли следующим заходом. А чистится очередь после ответа и ровно на длину пачки — если стереть её целиком, исчезнет то, что дописали, пока пачка была в пути.
Три момента: приложение ушло в фон (на телефоне это AppState, а не visibilitychange — про это глава 7), сеть появилась, и просто по таймеру. Самый важный — уход в фон: следующего запуска может не быть неделю, а отправка из фона на iOS живёт секунды. Отправлять на каждое событие нельзя: в зале это сотни запросов, каждый из которых будит радиомодуль.
Свой счётчик нужен там, где события уходят в ваш API. У готовых SDK — Firebase Analytics, AppMetrica — очередь и отправка уже внутри, и переписывать их незачем. Зато от них нужно другое: чтобы они молчали, когда сломаны. В приложении автора обёртка гасит оба вида отказа и не даёт аналитике уронить экран.
Личным данным в событиях места нет: ни почты, ни имени, ни текста заметок — в отчётах они хранятся годами и в чужой инфраструктуре. Идентификатор пользователя — id профиля, и о самом факте сбора придётся объявить в анкетах магазинов (App Privacy у Apple, «Безопасность данных» у Google).
Падение в проде приходит без имён. Hermes выполняет байткод, и в кадре стека стоит адрес внутри бандла: at anonymous (address at .../bundle.hbc:1:284531). Строка всегда первая — бандл собран в одну строку, — а смысл несёт последнее число, смещение. Формат кадра не выдумка: ровно его разбирает parseHermesStack в react-native 0.86.
Превратить адрес в имя умеет карта — тот самый .map, который Metro пишет рядом с бандлом. Просят её флагом, иначе экспорт её не пишет вовсе; вопрос «что значит адрес 284531» задают инструментом.
Внутри карта устроена как отсортированный список отрезков: для адреса ищут последний отрезок, начавшийся не позже него. Соберём эту расшифровку на трёх столбцах настоящей карты. Прогноз: тот же отчёт расшифрован дважды — картой своего обновления и картой предыдущего. Что случится во втором случае: ошибка, пустые имена или что-то третье?
Вот главная ловушка всей темы: чужая карта не падает. Адреса попали в отрезки, имена нашлись, отчёт выглядит осмысленным — и отправляет чинить RestTimer, хотя падает разметка подхода. Поэтому карта хранится по updateId, а не по версии приложения: обновлений под одной версией десятки, и у каждого свой бандл со своими смещениями.
Карта весит больше бандла и в магазин не едет: она нужна только вашему сборщику отчётов. У готовых сервисов (Sentry, Crashlytics) для этого есть загрузка карт при публикации; свой сборщик просто складывает файл рядом с записью об обновлении.
Осталось поймать само падение. Границы ошибок из части IV здесь помогают мало: они ловят ошибку рендера своего поддерева, а в проде падают обработчик нажатия, таймер и отклонённый промис. Их ловит глобальный обработчик React Native — и это первое отличие от браузера: там необработанная ошибка оставляет страницу жить, а на телефоне при isFatal процесс закрывается, и пользователь видит, как приложение просто исчезло.
Красный экран — только в разработке: его рисует LogBox поверх прежнего обработчика. В сборке из магазина ни экрана, ни текста ошибки нет, поэтому «у пользователя приложение вылетело» без отчёта на диске — это конец расследования. В стеке автора крэши собирает AppMetrica: у конфига активации есть флаг crashReporting, а из JS ошибки отдают reportError и reportUnhandledException.
Отчёты приходят россыпью, и читать их по одному бесполезно. Сводит их подпись: сообщение без переменных частей плюс несколько верхних кадров. Без нормализации database is locked (row s-1005) и …(row s-1096) — две разные причины, и одна поломка притворяется десятком.
Считать по подписи нужно две величины, и вторая важнее. Отчётов — сколько раз упало; устройств — скольких людей задело. Один человек с падением на каждом запуске даёт тридцать отчётов и не даёт повода бросать всё. И третья величина, ради которой всё затевалось: доля запусков без падений в разрезе update_id — раскатка из прошлой статьи уже разделила пользователей на две группы, и сравнение готово само собой.
В подложке — отчёты за сутки после раскатки на 10% и число сессий на каждом обновлении. Прогноз: сколько получится групп, какая окажется первой по числу задетых устройств и у какого обновления доля запусков без падений будет хуже?
Первой по охвату идёт старая знакомая — блокировка базы: девять устройств, и она была до выката. А решение принимает вторая строка: причина, которой нет ни на одном телефоне вне раскатки. Доля запусков без падений подтверждает: 88,5% против 98,9%. Это и есть повод не «посмотреть завтра», а остановить раскатку сегодня — eas channel:pause или возврат ветки, как в прошлой статье; чинить можно и после.
| Где смотреть | Что видно | Чего там нет |
|---|---|---|
| Google Play Console, Android vitals | нативные падения и ANR, охват, модели устройств | не знает ни про updateId, ни про JS-стек до расшифровки |
| App Store Connect | нативные падения, символицированные Apple | то же: обновление по воздуху для него невидимо |
| Свой сборщик или сервис | JS-стек, контекст запуска, подписи и охват | нет данных с телефонов, где отчёт не успел уйти |
Обе консоли магазинов считают падения по версии приложения — той самой, которая после выката по воздуху перестала быть кодом. Поэтому «в Play Console всё спокойно» ничего не говорит про свежее обновление: JS-падение, пойманное вашим обработчиком, туда вообще не попадает.
Третий источник — отзывы, и он единственный, где пишут словами. Просить оценку изнутри приложения можно, но окно рисует система: вы не открываете его, а просите открыть. Система вправе не показать ничего — и ничего вам об этом не сказать. Своё окно «поставьте пять звёзд, а если что-то не так — напишите нам» обе площадки запрещают: фильтровать отзывы по настроению нельзя.
Раз ответа нет, момент выбирается вашим кодом, а не пользователем. Правила простые: приложением пользуются не первый день, только что случилось хорошее (третья законченная тренировка, а не открытие настроек), сегодня ничего не падало, и с прошлой просьбы прошло много времени. Прогноз: на каком дне сработает просьба и что произойдёт на сороковой день, если отмечать не попытку, а показ окна?
Последняя строка — та самая ловушка. Если отметку ставить по факту показа, а система окно не показала, состояние не изменится, и приложение будет проситься при каждом удобном случае. Квоту площадки это не обходит — окна всё равно не будет, — зато собственные правила перестают работать. Отмечают попытку.
И последнее, ради чего вся эта глава. Отзыв «после обновления не сохраняет подходы» приходит раньше, чем цифры наберут вес, и содержит то, чего нет ни в одном отчёте, — намерение пользователя. Дальше работает связка из трёх статей: по дате отзыва находится обновление, по updateId — его группа падений, по подписи — место в коде. Если чинить некогда, у канала есть пауза и откат, а новая правка полетит тем же eas update — но только если отпечаток не изменился.
В песочнице Platform.OS — это «web», модулей Expo нет, и всё, что здесь выполнялось, — механизмы: контекст, очередь, карта, подпись, правило момента. Настоящие Updates.updateId, ErrorUtils, окно оценки и консоли магазинов живут на телефоне, и проверяются они там же.
- После первого выката по воздуху версия в магазине перестаёт быть кодом: под одной
1.0.0живут вшитый бандл и все обновления. К каждому событию и каждому отчёту пришивается контекст запуска —Updates.updateId,runtimeVersion,channel,isEmbeddedLaunch. - Счётчик событий — очередь на устройстве с потолком: отправка пачкой, id события с телефона против дублей, а при переполнении выбрасывается самое старое. Это её отличие от исходящей очереди журнала: дневник терять нельзя, статистику — можно.
- Очередь чистится после ответа и ровно на длину пачки: пока запрос шёл, в неё успели дописать.
- Стек из прода состоит из адресов в байткоде Hermes:
address at bundle.hbc:1:284531. Имена возвращает карта — её просят уexpo export --source-maps, а спрашивают черезmetro-symbolicate. - Чужая карта не падает, а врёт: адрес попадает в какой-нибудь отрезок всегда. Поэтому карты хранят по
updateId, а не по версии приложения. - Отчёты сводятся подписью — сообщение без переменных частей плюс верхние кадры; считают не отчёты, а задетые устройства и долю запусков без падений по обновлениям. Новая причина, которой нет вне раскатки, — повод остановить раскатку, а не ждать.
- Окно оценки рисует система: показ не гарантирован и никак не сообщается, поэтому в состоянии отмечают попытку, а не показ. Просят после хорошего события и не в день падения.
1/4После выката по воздуху в отчёте видно: «падает 1.0.0». Что добавить к событию, чтобы вопрос «где сломалось» получил ответ?
2/4Отчёт со старого обновления расшифровали картой свежего. Что получится?
3/4Пачка событий ушла на сервер, и, пока ждали ответ, пользователь засчитал ещё один подход. Что сделать с очередью после успешного ответа?
4/4requestReview() вызвали, окно оценки не появилось. Что записать в состояние приложения?
Группировка, которая прячет виновника
Отчёт за сутки сводят по версии приложения, и получается одна строка: «1.0.0 — двадцать два отчёта». Виновника в ней не видно, хотя данные для этого есть. Поправьте groupKey так, чтобы группы разделились по запущенному обновлению.
Ключ группировки — не версия из магазина, а обновление, на котором случилось падение. Версию стоит оставить рядом: она отвечает на другой вопрос — какая сборка стоит на телефоне.
Теперь строк две: 1.0.0 / upd-0917 → 12 отчётов и 1.0.0 / upd-0915 → 10 отчётов. Двенадцать отчётов на сотне сессий против десяти на девяти сотнях — разница, ради которой всё и делалось.
Очередь, которую очистили целиком
Счётчик отправляет пачку и после ответа очищает очередь целиком. Пока пачка была в пути, человек засчитал подход — и событие исчезло, не добравшись до сервера. Почините flush: очередь должна терять ровно то, что отправлено.
Длина пачки известна, и удалять надо её, а не всё подряд:
После этого ev-3 остаётся в очереди и уходит вторым заходом: «на сервере: ev-1 ev-2 ev-3». Ловушка не в аналитике, а в любом коде, который ждёт ответ и потом трогает общий список: за время ожидания список успевает измениться.
Карта не от того обновления
Сборщик отчётов хранит карты по версии приложения. Версия у всех обновлений одна, поэтому под 1.0.0 лежит карта того выката, который загрузили последним, — и все отчёты расшифровываются ею. Сделайте так, чтобы карта выбиралась по обновлению, а при её отсутствии отчёт честно оставался нерасшифрованным.
Ключ хранения — обновление, и отсутствие карты обязано быть видимым:
Теперь расшифровка показывает repsLabel и SetRow — место, где и правда падает. Нерасшифрованный отчёт с честной пометкой лучше правдоподобного вранья: по первому идут искать карту, по второму — чинить чужой код.