После релиза: аналитика, крэши, отзывы

обновления≈ 15 минут

Здесь будет встроен видео-разбор темы (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_versionConstants.expoConfig.versionкакая сборка стоит из магазина
build_numberномер сборки, его ставит EASта же версия, но какая по счёту сборка
update_idUpdates.updateIdкакой именно бандл сейчас выполняется
runtime_versionUpdates.runtimeVersionс каким набором натива этот бандл совместим
channelUpdates.channelэто боевые пользователи или тестировщики
embeddedUpdates.isEmbeddedLaunchне откатился ли телефон на вшитый бандл

В подложке — заглушка expo-updates с теми же полями (настоящий модуль в песочницу не собирается) и данные сборки из магазина. Соберём контекст и пришьём его к событию. Прогноз: одно и то же событие отправлено дважды — до отката на вшитый бандл и после; сколько разных значений окажется у app_version и сколько у update_id?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Версия в магазине одна на оба события, а код — разный, и различить их можно только по update_id. Именно поэтому «после обновления стало падать» — проверяемое утверждение, а не ощущение: в отчёте видно, на каком бандле случилось событие.

Так это выглядит с настоящим expo-updates. Все поля здесь null в разработке и в Expo Go — там обновлений нет вовсе, и это не ошибка, а честный ответ.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Событие лучше называть так, чтобы переменная величина ушла в параметры, а не в имя: set_done с параметром exercise — одно событие с разбивкой, а set_done_squat и set_done_bench — два разных, между которыми в отчёте нет ничего общего. У Firebase на это есть и жёсткие пределы: имя латиницей, не длиннее 40 символов, часть имён занята им самим.

Зал — это подвал, и сети там нет. Очередь на устройстве мы уже собирали в главе 6: операции журнала складываются в исходящую очередь и ждут синхронизации. Механизм тот же, но правила у аналитики другие, и различие важнее сходства.

Первое: событие можно потерять. Потерянный подход — это испорченный дневник, потерянное событие — минус единица в отчёте. Поэтому у очереди событий есть потолок, и при переполнении она выбрасывает самое старое, а не растёт, пока телефон не кончится. Второе: события уходят пачкой, а не по одному. Третье: подтверждения по каждому событию не нужно — достаточно, чтобы повтор не задвоил, а для этого у события есть свой id, рождённый на телефоне (ровно как opId у операций).

В подложке — рубильник сети и приёмник на той стороне, который гасит повторы по id. Прогноз: два события уходят по сети, потом сеть пропадает и человек делает семь подходов подряд, потом сеть возвращается. Сколько событий доедет до сервера и сколько потеряется?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Потерялись ev-3 и ev-4 — самые старые из накопленных: потолок в пять событий сработал ровно так, как задумано. Отказ сети не уронил ничего и не потерял пачку: события остались в очереди и ушли следующим заходом. А чистится очередь после ответа и ровно на длину пачки — если стереть её целиком, исчезнет то, что дописали, пока пачка была в пути.

Три момента: приложение ушло в фон (на телефоне это AppState, а не visibilitychange — про это глава 7), сеть появилась, и просто по таймеру. Самый важный — уход в фон: следующего запуска может не быть неделю, а отправка из фона на iOS живёт секунды. Отправлять на каждое событие нельзя: в зале это сотни запросов, каждый из которых будит радиомодуль.

Свой счётчик нужен там, где события уходят в ваш API. У готовых SDK — Firebase Analytics, AppMetrica — очередь и отправка уже внутри, и переписывать их незачем. Зато от них нужно другое: чтобы они молчали, когда сломаны. В приложении автора обёртка гасит оба вида отказа и не даёт аналитике уронить экран.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Личным данным в событиях места нет: ни почты, ни имени, ни текста заметок — в отчётах они хранятся годами и в чужой инфраструктуре. Идентификатор пользователя — id профиля, и о самом факте сбора придётся объявить в анкетах магазинов (App Privacy у Apple, «Безопасность данных» у Google).

Падение в проде приходит без имён. Hermes выполняет байткод, и в кадре стека стоит адрес внутри бандла: at anonymous (address at .../bundle.hbc:1:284531). Строка всегда первая — бандл собран в одну строку, — а смысл несёт последнее число, смещение. Формат кадра не выдумка: ровно его разбирает parseHermesStack в react-native 0.86.

Превратить адрес в имя умеет карта — тот самый .map, который Metro пишет рядом с бандлом. Просят её флагом, иначе экспорт её не пишет вовсе; вопрос «что значит адрес 284531» задают инструментом.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Внутри карта устроена как отсортированный список отрезков: для адреса ищут последний отрезок, начавшийся не позже него. Соберём эту расшифровку на трёх столбцах настоящей карты. Прогноз: тот же отчёт расшифрован дважды — картой своего обновления и картой предыдущего. Что случится во втором случае: ошибка, пустые имена или что-то третье?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Вот главная ловушка всей темы: чужая карта не падает. Адреса попали в отрезки, имена нашлись, отчёт выглядит осмысленным — и отправляет чинить RestTimer, хотя падает разметка подхода. Поэтому карта хранится по updateId, а не по версии приложения: обновлений под одной версией десятки, и у каждого свой бандл со своими смещениями.

Карта весит больше бандла и в магазин не едет: она нужна только вашему сборщику отчётов. У готовых сервисов (Sentry, Crashlytics) для этого есть загрузка карт при публикации; свой сборщик просто складывает файл рядом с записью об обновлении.

Осталось поймать само падение. Границы ошибок из части IV здесь помогают мало: они ловят ошибку рендера своего поддерева, а в проде падают обработчик нажатия, таймер и отклонённый промис. Их ловит глобальный обработчик React Native — и это первое отличие от браузера: там необработанная ошибка оставляет страницу жить, а на телефоне при isFatal процесс закрывается, и пользователь видит, как приложение просто исчезло.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Красный экран — только в разработке: его рисует LogBox поверх прежнего обработчика. В сборке из магазина ни экрана, ни текста ошибки нет, поэтому «у пользователя приложение вылетело» без отчёта на диске — это конец расследования. В стеке автора крэши собирает AppMetrica: у конфига активации есть флаг crashReporting, а из JS ошибки отдают reportError и reportUnhandledException.

Отчёты приходят россыпью, и читать их по одному бесполезно. Сводит их подпись: сообщение без переменных частей плюс несколько верхних кадров. Без нормализации database is locked (row s-1005) и …(row s-1096) — две разные причины, и одна поломка притворяется десятком.

Считать по подписи нужно две величины, и вторая важнее. Отчётов — сколько раз упало; устройств — скольких людей задело. Один человек с падением на каждом запуске даёт тридцать отчётов и не даёт повода бросать всё. И третья величина, ради которой всё затевалось: доля запусков без падений в разрезе update_id — раскатка из прошлой статьи уже разделила пользователей на две группы, и сравнение готово само собой.

В подложке — отчёты за сутки после раскатки на 10% и число сессий на каждом обновлении. Прогноз: сколько получится групп, какая окажется первой по числу задетых устройств и у какого обновления доля запусков без падений будет хуже?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первой по охвату идёт старая знакомая — блокировка базы: девять устройств, и она была до выката. А решение принимает вторая строка: причина, которой нет ни на одном телефоне вне раскатки. Доля запусков без падений подтверждает: 88,5% против 98,9%. Это и есть повод не «посмотреть завтра», а остановить раскатку сегодня — eas channel:pause или возврат ветки, как в прошлой статье; чинить можно и после.

Где смотретьЧто видноЧего там нет
Google Play Console, Android vitalsнативные падения и ANR, охват, модели устройствне знает ни про updateId, ни про JS-стек до расшифровки
App Store Connectнативные падения, символицированные Appleто же: обновление по воздуху для него невидимо
Свой сборщик или сервисJS-стек, контекст запуска, подписи и охватнет данных с телефонов, где отчёт не успел уйти

Обе консоли магазинов считают падения по версии приложения — той самой, которая после выката по воздуху перестала быть кодом. Поэтому «в Play Console всё спокойно» ничего не говорит про свежее обновление: JS-падение, пойманное вашим обработчиком, туда вообще не попадает.

Третий источник — отзывы, и он единственный, где пишут словами. Просить оценку изнутри приложения можно, но окно рисует система: вы не открываете его, а просите открыть. Система вправе не показать ничего — и ничего вам об этом не сказать. Своё окно «поставьте пять звёзд, а если что-то не так — напишите нам» обе площадки запрещают: фильтровать отзывы по настроению нельзя.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Раз ответа нет, момент выбирается вашим кодом, а не пользователем. Правила простые: приложением пользуются не первый день, только что случилось хорошее (третья законченная тренировка, а не открытие настроек), сегодня ничего не падало, и с прошлой просьбы прошло много времени. Прогноз: на каком дне сработает просьба и что произойдёт на сороковой день, если отмечать не попытку, а показ окна?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Последняя строка — та самая ловушка. Если отметку ставить по факту показа, а система окно не показала, состояние не изменится, и приложение будет проситься при каждом удобном случае. Квоту площадки это не обходит — окна всё равно не будет, — зато собственные правила перестают работать. Отмечают попытку.

И последнее, ради чего вся эта глава. Отзыв «после обновления не сохраняет подходы» приходит раньше, чем цифры наберут вес, и содержит то, чего нет ни в одном отчёте, — намерение пользователя. Дальше работает связка из трёх статей: по дате отзыва находится обновление, по 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 так, чтобы группы разделились по запущенному обновлению.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Ключ группировки — не версия из магазина, а обновление, на котором случилось падение. Версию стоит оставить рядом: она отвечает на другой вопрос — какая сборка стоит на телефоне.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь строк две: 1.0.0 / upd-0917 → 12 отчётов и 1.0.0 / upd-0915 → 10 отчётов. Двенадцать отчётов на сотне сессий против десяти на девяти сотнях — разница, ради которой всё и делалось.

Очередь, которую очистили целиком

Счётчик отправляет пачку и после ответа очищает очередь целиком. Пока пачка была в пути, человек засчитал подход — и событие исчезло, не добравшись до сервера. Почините flush: очередь должна терять ровно то, что отправлено.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Длина пачки известна, и удалять надо её, а не всё подряд:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После этого ev-3 остаётся в очереди и уходит вторым заходом: «на сервере: ev-1 ev-2 ev-3». Ловушка не в аналитике, а в любом коде, который ждёт ответ и потом трогает общий список: за время ожидания список успевает измениться.

Карта не от того обновления

Сборщик отчётов хранит карты по версии приложения. Версия у всех обновлений одна, поэтому под 1.0.0 лежит карта того выката, который загрузили последним, — и все отчёты расшифровываются ею. Сделайте так, чтобы карта выбиралась по обновлению, а при её отсутствии отчёт честно оставался нерасшифрованным.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Ключ хранения — обновление, и отсутствие карты обязано быть видимым:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь расшифровка показывает repsLabel и SetRow — место, где и правда падает. Нерасшифрованный отчёт с честной пометкой лучше правдоподобного вранья: по первому идут искать карту, по второму — чинить чужой код.