DAU и MAU показывают число уникальных активных пользователей за день или за 30 дней; retention D7 означает долю пользователей, установивших приложение в определённый день и вернувшихся на седьмой день. Для всех трёх нужны стабильный id пользователя, событие установки и одно событие «активности» на сессию.
Определите «активность» один раз: открытие приложения или session_start, а не фоновая синхронизация или полученный пуш, иначе тихие пуши раздувают DAU
День установки: first_open (Firebase логирует его автоматически) задаёт когорту пользователя; переустановка не должна создавать нового человека
Стабильный id: анонимный id, переживающий перезапуски и связанный с аккаунтом при логине; новый id на каждом запуске превращает каждый день в «новых пользователей»
AndroidMiddle
Аналитика и эксперименты
Таксономия событий, воронки, crash-free rate, ANR, трейсинг, remote config, A/B, сэмплинг
Закрыто вопросов
0 / 20
- 0Повторить
- 1Часто спрашивают
Вопросы
Feature flag служит переключателем вкл/выкл вокруг уже выпущенного кода, remote config представляет собой серверное хранилище, которое доставляет значения флагов и другие параметры, а A/B-тест случайно распределяет пользователей по вариантам и измеряет разницу.
Remote config: доставка ключ-значение с дефолтами в приложении, условиями таргетинга и циклом fetch: Firebase Remote Config, LaunchDarkly, Statsig
Таргетинг не является экспериментом: включив фичу для «пользователей Android 15», вы меняете, кто её видит, но без случайного распределения не можете приписать ей изменение метрик
Эти двадцать имён сводятся к одному событию button_tapped со свойством button_id. Существительное в имени события почти всегда переодетое свойство, и из-за него вопрос «на какие кнопки жмут на этом экране» превращается в запрос по двадцати именам.
Имя события: что произошло, в форме object_action в прошедшем времени: button_tapped, checkout_completed
Свойство события: какое именно и где: screen, button_id; checkout против cart живёт здесь
Свойство пользователя: характеристика человека, задаётся один раз, а не на каждом событии: plan, signup_cohort
Лимиты инструментов: Firebase Analytics допускает 500 разных имён событий и 25 параметров на событие, так что имя на каждую кнопку быстро кончится
Реестр: закоммиченный список событий и их свойств с проверкой в CI держит словарь закрытым
Обратная ошибка: одно app_event со свойством type ломает любой инструмент, который строит воронки по имени события
В слое навигации, подключённый один раз: роутер и так видит каждую смену экрана, поэтому новый экран попадает в аналитику, и никому не нужно помнить про отдельную строчку.
Single-activity приложения: автоматический screen_view в Firebase привязан к activity, поэтому в Compose-приложении каждый экран выглядит как MainActivity; логируйте из NavController
Имя из шаблона маршрута: order/{orderId}, а не order/8f2c и тем более не локализованный заголовок, иначе один экран превращается в тысячи имён
Screen view и действия: screen view говорит, где находится пользователь; события-действия несут screen как свойство, чтобы по нему можно было группировать
Частая ошибка: лог в onResume или в эффекте без стабильного ключа срабатывает повторно при возврате из фона или при рекомпозиции; дедуплицируйте по записи back stack
Сделайте схему событий единственным способом отправить данные: типизированные события с allowlist свойств, значения только enum, id и числа, и один слой очистки перед SDK каждого вендора.
Никакого свободного API: спрячьте log(name: String, props: Map<String, Any?>); именно через нетипизированную map в событие попадает email
Описывайте, а не копируйте: отправляйте search_length и results_count вместо самого запроса, id screen вместо заголовка, в котором может оказаться имя
Псевдонимные id: собственный случайный id пользователя, но никогда не email, телефон или рекламный ID в свойствах; условия Firebase запрещают отправлять PII
Один фильтр перед всеми вендорами: отбрасывает необъявленные ключи и свободный текст, вырезает query string из URL в breadcrumbs крэш-репортов
Ревью реестра: каждое новое свойство может поменять ответы в форме Data safety в Play, поэтому список событий ревьюит тот, кто за эту форму отвечает
Воронка склеивает события в одну попытку одного человека, поэтому каждому шагу нужны одна и та же идентичность пользователя, надёжный порядок и id самой попытки. Одни имена шагов дают пять несвязанных счётчиков.
Идентичность: анонимный id, а после логина user id, связанные вызовом alias или identify; логин посреди сценария становится главным местом, где воронка распадается надвое
Экземпляр сценария: checkout_id, созданный на старте и протянутый через все шаги, чтобы брошенная попытка и более поздний успех не слиплись
Порядок: клиентский timestamp плюс порядковый номер в сессии: два события могут попасть в одну миллисекунду
Session id: сбрасывается примерно после 30 минут бездействия, чтобы корзина в понедельник и оплата в пятницу не стали одним визитом
Явная ошибка: отправляйте checkout_failed с reason; отсутствие следующего шага может означать крэш, закрытое приложение или потерянный батч, и вы не отличите одно от другого
Спрячьте SDK вендора за собственным интерфейсом Analytics, проверяйте фейк в unit- и UI-тестах, а реальный пайплайн перед релизом смотрите вручную в Firebase DebugView. События образуют контракт с аналитиками, и тестировать их стоит как API.
Фейковый трекер: складывает события в список; тест ViewModel проверяет имя и свойства после действия
Проверяйте контракт: свойства, по которым строят запросы аналитики, и то, что одно действие логируется один раз, а не повторно при повороте или рекомпозиции
DebugView: adb shell setprop debug.firebase.analytics.app <package> заставляет устройство отправлять события сразу, без батчинга; .none. выключает режим
Проверка схемы в CI: сверяйте имена и свойства с реестром событий, чтобы переименованное свойство ломало сборку, а не дашборд
Частая ошибка: тестировать через сам Firebase SDK: он батчит, требует настроенный FirebaseApp и устройство или Robolectric и не даёт ничего, что можно проверить
Каждое событие нужно сохранить на диск сразу после track() (на фоновом писателе, не в главном потоке), а очередь должна переживать смерть процесса, повторять отправку с backoff и удалять строки только после того, как сервер их принял. Список в памяти при принудительном закрытии теряет всю сессию.
Сначала диск: track() дописывает строку в таблицу Room или append-only файл и возвращается; сеть внутри он не трогает
Батчи: отправка каждые ~30 событий или ~30 секунд, плюс запланированный flush при уходе приложения в фон
WorkManager для flush: уникальная задача с сетевым ограничением и экспоненциальным backoff переживает процесс; корутину, запущенную в onStop, могут убить посреди отправки; после Force stop задача отменяется, поэтому делайте flush и при следующем запуске
Удаление только после 2xx: удаление в момент отправки превращает каждую нестабильную сеть в потерянные данные
Лимит очереди: ограничение размера с вытеснением самых старых строк, чтобы устройство, месяц бывшее офлайн, не забило диск
Потерянные батчи не являются случайным шумом: потери коррелируют с плохой сетью и длинными сессиями, поэтому завышают retention и вовлечённость.
Давайте каждому событию собственный id в момент вызова track(), храните его вместе с событием, и пусть сервер отбрасывает дубликаты по этому id. Тогда клиент может повторять отправку сколько угодно.
Таймаут неоднозначен: сервер мог сохранить батч и потерять ответ. Клиент этого не знает, поэтому обязан отправить повторно, и распознать повтор может только сервер.
Ключ на событие, а не на запрос: UUID, созданный один раз и сохранённый в строке очереди; если генерировать его при отправке, каждый повтор получит новый id
У вендоров поле уже есть: $insert_id в Mixpanel, insert_id в Amplitude, messageId в Segment
Не хешируйте содержимое: два одинаковых тапа с разницей в секунду дают два настоящих события
С деньгами иначе: покупки ключуйте по id заказа и дедуплицируйте ещё и в хранилище; источником правды по выручке служит бэкенд или Play Billing, а не поток аналитики
Отправляйте оба: клиентское время нужно, чтобы упорядочить события внутри сессии, а серверное нужно, чтобы его скорректировать. Сервер при загрузке измеряет расхождение часов устройства и сдвигает на него каждое событие, так что часы, спешащие на два дня, компенсируются.
Клиентское время: снимается в момент действия; единственные часы, которые знают, когда что-то произошло офлайн
Время получения на сервере: подделать нельзя, но для батча, ждавшего в тоннеле, это время загрузки, на сорок минут позже
Коррекция расхождения: батч несёт client_upload_time; сервер считает event_time = client_event_time - (client_upload_time - server_upload_time), как это делает Amplitude
Длительности: измеряйте через SystemClock.elapsedRealtime(); настенные часы, поправленные NTP посреди сессии, могут дать отрицательную длительность
Retention считается как «день события минус день установки», поэтому нескорректированное расхождение переносит пользователей не в ту ячейку когорты, а многие пайплайны просто отбрасывают события из будущего.
Они делят на разное: пользователь считается упавшим на всё окно, если упала хотя бы одна из его сессий, поэтому crash-free users почти всегда ниже. Алерт ставьте на crash-free сессии по релизу, а crash-free users показывайте продукту.
Crash-free sessions: 1 − сессии с крэшем ÷ все сессии; релиз, добавивший фоновые сессии, раздувает знаменатель и может спрятать регрессию; крэш-луп у нескольких пользователей сильно бьёт по сессиям и почти не трогает пользователей
Crash-free users: 1 − пользователи с крэшем ÷ все пользователи; падает с расширением окна, поэтому цифры за 1 день и за 7 дней несравнимы
Переведите в людей: при двух миллионах пользователей в месяц 99,5% означают десять тысяч человек, увидевших крэш
Алерт по версии: сравнивайте новую версию с той, которую она заменяет; общая цифра по приложению растворяет плохой релиз в здоровой базе установок
Это верхняя граница: крэши до инициализации SDK, убийства по памяти и ANR в неё не входят
Зависание означает решение ОС, что приложение не отвечает, поэтому запись о нём делает платформа, а не ваш обработчик крэшей: Play vitals и ApplicationExitInfo на Android. В crash-free они не попадают никогда.
ANR: возникает, когда событие ввода не обработано примерно за 5 секунд или broadcast receiver либо сервис превысили свой таймаут; Play Console показывает частоту и стек главного потока
Прочитайте сами: при следующем запуске getHistoricalProcessExitReasons (Android 11+) вернёт REASON_ANR с трейсом и REASON_LOW_MEMORY для убийств по памяти
Crashlytics: на Android 11+ собирает ANR через тот же API и показывает их отдельным типом событий, вне crash-free
Короткие подвисания: заморозка на 1–4 секунды не становится ANR; увидеть её помогут JankStats или watchdog главного потока
Crash-free считает смерти, которые вызвал ваш код; ANR и убийства по памяти относятся к смертям, которые вызвала ОС, а пользователь разницы не видит.
Отправляйте пойманную ошибку как non-fatal с отдельным типом исключения и окружайте её breadcrumbs и custom keys. Стектрейс без состояния, которое к нему привело, воспроизвести нельзя.
**Один recordException: на том слое, который понимает смысл ошибки, а не на каждом, что её пробрасывает, и один раз на операцию, а не на каждый повтор
Breadcrumbs**: log() последних действий: открытый экран, нажатая кнопка, путь и статус запроса
Custom keys: состояние, о котором вы спросили бы по телефону: шаг загрузки, тип сети, включённые флаги; Crashlytics хранит до 64 ключей
Стабильная группировка: issues группируются по типу исключения и стеку, поэтому изменчивые данные кладите в ключи, а не в сообщение, и у каждого вида ошибки свой тип
Найдите отчёт: setUserId с псевдонимным id позволяет поддержке найти тикет; но никогда не email
Пока загруженный конфиг не активирован, каждое чтение возвращает дефолты, вшитые в приложение. У свежей установки ничего нет в кэше, поэтому до первой активации она работает на дефолтах, и kill switch с дефолтом «включено» оставляет фичу включённой.
Fetch не означает activate: fetch скачивает, activate подменяет значения, fetchAndActivate делает и то и другое; до этого getBoolean отвечает из дефолтов
Как долго: до завершения первого fetchAndActivate() (секунды) или всю сессию, если приложение только скачивает сейчас, а активирует при следующем старте; 12-часовой интервал влияет на уже установленные приложения, а не на первый fetch
Интервал fetch: по умолчанию minimumFetchIntervalInSeconds равен 12 часам, поэтому уже установленное приложение может не увидеть изменение до следующего разрешённого fetch
Real-time обновления: addOnConfigUpdateListener доставляет изменения в запущенное приложение, пока оно открыто и в сети
Безопасные дефолты: дефолт kill switch в приложении выбирает безопасную ветку, «выключено»; тогда упавший или запоздавший fetch никогда не включит фичу
Частая ошибка: блокировать сплэш на fetchAndActivate(): fetchTimeoutInSeconds по умолчанию 60, и плохая сеть задерживает холодный старт
Сплит, отличающийся от настроенного, называется sample ratio mismatch (SRM), и на мобильных чаще всего причина в событии экспозиции, а не в распределении по бакетам. Пока соотношение не исправлено, ни одной метрике теста верить нельзя.
Детерминированное распределение: вариант возвращает сервер, либо клиент считает hash(experimentKey + userId) % 10000; случайное число на клиенте воспроизвести нельзя
Бакет по user id: не по install id, иначе один человек с телефоном и планшетом попадёт в обе группы
Экспозиция там, где пользователь видит фичу: логируйте при первом рендере фичи, а не при чтении флага на старте, иначе выборка заполнится людьми, которые до неё не дошли
Логируйте все группы: классический SRM: ветка контроля делает ранний return до строки экспозиции, и считается только вариант
Сначала проверьте соотношение: хи-квадрат по экспонированным пользователям в каждой группе; p < 0,001 значит «стоп, ищем баг», потому что потерянные пользователи никогда не случайная выборка
Передавайте контекст трейса по сети: приложение открывает корневой спан на тапе и отправляет его id в заголовке W3C traceparent, и спан сервера становится дочерним в том же трейсе, а не начинает новый.
На устройстве
Корневой спан на взаимодействии: view model, кэш и HTTP-вызовы становятся дочерними спанами с тем же trace id
Корутины: OpenTelemetry хранит текущий спан в ThreadLocal, который не следует за корутиной между потоками; переносите его через asContextElement()
Асинхронный OkHttp: самописный interceptor читает Context.current() на потоке диспетчера OkHttp, когда Retrofit вызывает enqueue, не находит спан и не ставит заголовок; инструментация OpenTelemetry для OkHttp захватывает контекст в момент создания вызова
По сети
Заголовок: traceparent: 00-<trace-id>-<span-id>-01, где последний байт задаёт флаг sampled
Стандартные имена: семантические конвенции OpenTelemetry вроде http.request.method, иначе дашборды бэкенда не узнают спаны
Сэмплирование
Решает корень: с parent-based сэмплером сервер следует флагу из заголовка, поэтому именно клиент решает, запишется ли медленный запрос
Head sampling не видит ошибок: трейс, отброшенный на корне, никогда не дойдёт до коллектора, поэтому оставляйте 100% холодных стартов и чекаута на корне (собственный Sampler по имени спана), а tail sampling для ошибок делайте на стороне бэкенда или экспортируйте в коллектор все клиентские спаны
Сравнивайте длительности, а не timestamp: часы устройства и сервера расходятся
У staged rollout нет контрольной группы: он сравнивает разных людей в разные недели, поэтому любая разница смешивает изменение с тем, кто обновляется первым, и со всем остальным, что случилось за неделю. Он нужен, чтобы ловить крэши, а не измерять прирост.
Первые обновившиеся не случайны: первые 5% смещены в сторону автообновления, Wi-Fi и новых телефонов, и они конвертируются лучше ещё до любых изменений
Другое окно времени: сборку прошлой недели мерили в другие выходные, с другой кампанией и другой ценой
Ошибка выжившего: недовольные обновлением реже открывают приложение и тихо выпадают из знаменателя
Как выглядит настоящий мобильный эксперимент
Обе группы в одном бинарнике: за флагом, с рандомизацией внутри одной сборки, чтобы у них были общие версия и окно времени
Горизонт и размер выборки заранее: ежедневно смотреть p-value и останавливаться на первом «зелёном» означает раздувать долю ложных срабатываний
Платформы анализируйте отдельно: пользователи iOS и Android образуют две разные популяции, а не две половины одной
Пока нет: сначала посмотрите результат по классам устройств. Чистые +3% могут скрывать потери на слабых телефонах, где 80 мс могут стать 300; выкатывайте, если прирост держится и там, а холодный старт укладывается в guardrail, согласованный до запуска.
Guardrail представляет собой метрику, которая не должна ухудшаться, даже если основная выиграла, с порогом, согласованным до запуска: crash-free users, ANR rate, cold start p90. Согласованный задним числом порог превращается в торг, который выигрышный вариант выигрывает всегда.
Прирост уже включает цену: эксперимент измерял весь опыт, включая 80 мс, так что +3% и есть чистый результат
Среднее прячет форму: +4% на флагманах и −1% в нижнем квартиле означают регрессию для пользователей с наименьшим запасом
Редким событиям нужен rollout: несколько сотен тысяч пользователей не заметят сдвиг крэш-рейта или ANR на 0,01 п.п.; этот guardrail проверяется во время staged rollout
Цена старта накапливается: пять экспериментов по 80 мс дают 400 мс, которые никто не помнит, как набежали
Если выкатываете: заведите 80 мс как отслеживаемую регрессию; иначе уберите новую работу варианта с пути до первого кадра и перезапустите тест
Сначала агрегируйте на устройстве, а если сэмплировать всё же нужно, сэмплируйте пользователей, а не события. Монетка на каждое событие оставит одному человеку первый шаг, другому третий, и любая конверсия, построенная на этом, будет неверной.
Агрегируйте до отправки: одно событие feed_impressions на экран со счётчиком и id элементов заменяет сотни событий
Сэмплируйте по хешу пользователя: оставляйте пользователя, если hash(userId) % 100 < 10; сохраняются все его события, и воронки и retention по-прежнему склеиваются
Записывайте долю: свойство sample_rate, на которое умножают обратно при запросе, иначе каждый дашборд молча покажет в десять раз меньше
Не сэмплируйте редкие и важные события: ошибки, покупки и экспозиции экспериментов остаются на 100%; по объёму это шум, а по смыслу это весь пайплайн
Сэмплирование, которое незаметно смещает данные
Отбрасывание при переполнении очереди: выкидывает самые тяжёлые сессии, то есть ваших самых активных пользователей
Только Wi-Fi или по модели устройства: смещает выборку к новым телефонам и хорошим сетям, к пользователям, у которых и так всё хорошо
Разный хеш для разных типов событий: пользователи попадают в разные выборки для разных событий, и склейка снова ломается
Измеряйте по когорте с согласием и сравнивайте доли, а не абсолютные числа, плюс смоделированные итоги из Consent Mode; события в очереди держите до ответа пользователя, отправляйте только разрешённые категории, а при отказе удаляйте.
Ваши пользователи не изменились: вы потеряли право считать пятую часть из них. Задача решается планом измерений, который отделяет «не собрано» от «не произошло», и очередью, которая никогда не отправит то, на что не дали согласия.
Что остаётся
Отказ в согласии: останавливает сбор для этой цели; сбор крэшей часто опирается на другое правовое основание, но решает это юрист, а не дефолт SDK
Consent Mode: при отказе в ANALYTICS_STORAGE Firebase не отправляет идентификаторы, а Google Analytics моделирует пробел: итоговые числа, а не воронки по пользователям
Рекламный ID: на Android 13+ нужно объявить разрешение AD_ID, а пользователь может обнулить идентификатор, поэтому не стройте на нём ни аналитику, ни атрибуцию; используйте собственный псевдонимный id
Сравнивайте доли, а не абсолютные числа: отказы распределены неравномерно по регионам и версиям ОС, поэтому сравнивайте конверсию внутри когорты с согласием, а долю согласий берите из своей платформы согласий
События в очереди
До ответа: держите события недолго (в памяти или на диске с коротким TTL и без user id), помечая каждое категорией согласия, которая ему нужна, и не инициализируйте SDK вендора
При согласии: выставьте consent, запустите SDK и отправьте только разрешённые категории
При отказе или отзыве: удалите очередь и смените анонимный id; никогда не досылайте задним числом