22 августа 2026 г. · 8 мин. чтения
Логирование и аналитика в iOS: практическое руководство
Логирование и аналитика часто воспринимаются как небольшие технические задачи, которые делают «для галочки»: интегрируем SDK, отправляем пару событий и забываем. У каждой из них своя задача, и если её не понимать, логи и аналитика со временем превращаются в шум, в котором нет полезной информации.
Логирование нужно разработчикам
Логирование, это техническая, «разработческая» часть: мы отправляем важные события и логируем ошибки. Пока всё работает хорошо, в логи никто не заглядывает. Но как только появляются баги, мы начинаем понимать что аналитика и логи содержат избыточную, плохо структурируемую информацию, в которой сложно что-то найти.
Мы вспоминаем про логи и аналитику только тогда, когда уже есть проблема или мы не можем нормально протестировать функциональность на проде (например, задействовано слишком много внешних систем). Именно после такого столкновения приходит понимание, что логирование и аналитику нужно проектировать заранее, а не добавлять постфактум.
Логирование и аналитика не спасают нас от багов: плохой код останется плохим даже с хорошей аналитикой. Но хорошие логи ускоряют поиск проблемы.
Зачем нужна аналитика
Аналитика прежде всего решает продуктовую задачу. Она помогает понять, сколько у продукта активных пользователей, как выглядит воронка успешного флоу, где проседает конверсия и на каком шаге. Ради этого аналитику обычно и подключают.
Основные инструменты для сбора логов
os.Logger
(нативный, Apple)
- Для чего: структурированные логи прямо на устройстве
- Цена: бесплатно, часть iOS SDK
- Свой бэкенд: не нужен, всё остаётся на устройстве
- Работа с данными: только локально (Console.app, Xcode, sysdiagnose), нет дашборда и агрегации по пользователям
- Расширенные возможности:
subsystem/categoryи уровни разведены на уровне API из коробки
- Для чего: краш-репортинг
- Цена: бесплатно, часть Firebase
- Свой бэкенд: не нужен, управляется Google
- Работа с данными: дашборд с группировкой крашей по стектрейсу, поиск по версии и устройству
- Расширенные возможности: -
- Для чего: error tracking и performance
- Цена: free-тариф с лимитом событий, дальше платно
- Свой бэкенд: можно managed (sentry.io), можно self-hosted (open source)
- Работа с данными: дашборды с фильтрами по release/environment, поиск по breadcrumbs
- Расширенные возможности: ловит App Hangs/ANR, сам собирает breadcrumbs, настраиваемый скраббинг
С чего начинать
Версия приложения и номер билда обычно собираются сторонними SDK автоматически. А вот следующие вещи нужно спроектировать самим.
1. Анонимный ID, который переживает переустановку. Приложению нужно уметь идентифицировать залогиненного и незалогиненного пользователя. Для залогиненного используется его user ID (тот, что выдаёт ваш бэкенд или auth-система), для незалогиненного можно использовать anonymous ID, например сгенерированный UUID. Такой ID удобно хранить в Keychain, чтобы можно было заново идентифицировать устройство после переустановки
enum AnonymousUserID {
static var current: String {
if let existing = Keychain.shared.string(forKey: "analytics_uid") {
return existing
}
let id = UUID().uuidString
Keychain.shared.set(id, forKey: "analytics_uid")
return id
}
}
Следующий вопрос: куда этот ID отправлять. Дёргать
Analytics.setUserId,
Crashlytics.crashlytics().setUserID и
SentrySDK.setUser явно в каждом месте кода выглядит
дублированием: три вендорских вызова размазаны по всему приложению,
и при замене одного из инструментов их придётся искать руками. Лучше
спрятать SDK за одним интерфейсом и вызывать везде именно его:
protocol AppLoggerProtocol {
func setUserId(_ id: String)
func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any])
}
final class AppLogger {
static let shared = AppLogger(destinations: [
CrashlyticsDestination(), SentryDestination(), NativeLoggerDestination(),
])
private let destinations: [AppLoggerProtocol]
init(destinations: [AppLoggerProtocol]) { self.destinations = destinations }
func setUserId(_ id: String) {
destinations.forEach { $0.setUserId(id) }
}
func log(_ level: LogLevel, _ event: String, domain: LogDomain, metadata: [String: Any] = [:]) {
destinations.forEach { $0.log(level, event, domain: domain, metadata: metadata) }
}
}
AppLogger.shared.setUserId(AnonymousUserID.current)
Каждый *Destination, тонкая обёртка над конкретным
SDK: CrashlyticsDestination.setUserId внутри просто
вызывает Crashlytics.crashlytics().setUserID, и так
далее. Если добавляется новый инструмент, мы подключаем его в одном
месте. Дальше по посту AppLogger.shared, это и есть тот
самый единый интерфейс.
После логина setUserId лучше вызвать снова с реальным
user ID вместо anonymous ID, чтобы сессии до и после логина можно
было связать в одну.
2. Severity и domain
Severity
(debug/info/notice/error/fault)
это уровень серьёзности события.
Domain
(checkout/auth/onboarding) -
это часть приложения, к которой относится событие. По
доменам можно легко будет группировать ошибки
Для простоты в примерах домены, это несколько enum-кейсов. В реальном проекте типизацию стоит продумать отдельно: единый enum на всё приложение, свой на модуль или как-то ещё, это зависит от структуры конкретного проекта, но typed-вариант почти всегда лучше сырой строки: упрощает поиск по коду и рефакторинг.
AppLogger.shared.log(.error, "payment_failed", domain: .checkout, metadata: ["reason": "timeout"])
AppLogger.shared.log(.info, "screen_appeared", domain: .checkout)
У Apple в нативном os.Logger эта развязка уже встроена
по умолчанию: category, это и есть domain, а вызов
.error/.info, severity, разведены на уровне
API, а не по договорённости внутри команды:
import os
private let checkoutLog = Logger(subsystem: "com.example.app", category: "checkout")
checkoutLog.error("payment_failed reason=timeout")
checkoutLog.info("screen_appeared")
Именно поэтому в списке обёрток при инициализации
AppLogger есть и NativeLoggerDestination -
она просто прокидывает domain в category,
а severity в уровень os.Logger, и
структурированные логи остаются доступны локально (Console.app,
sysdiagnose) даже без сети и без вендорских SDK.
3. Переходы состояния приложения, логировать явно.
Самые неприятные баги завязаны на lifecycle: пользователь свернул и
развернул приложение несколько раз подряд. Такую последовательность
не протестировать руками, но в проде она случится обязательно. Хук
можно поставить через NotificationCenter, а если
приложение построено на UIScene прямо в методах
UIWindowSceneDelegate, это точнее для приложений с
несколькими сценами:
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil, queue: .main
) { _ in AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle) }
Или то же самое в методе делегата, если приложение построено на
UIScene:
func sceneDidEnterBackground(_ scene: UIScene) {
AppLogger.shared.log(.info, "app.entered_background", domain: .lifecycle)
}
Прежде чем добавлять свой хук, стоит проверить, не логирует ли это уже подключённый SDK: у Sentry есть автоматический session tracking, привязанный именно к переходам foreground/background, и дублировать его вручную не нужно.
4. Breadcrumbs, но со скраббингом секретов, а не в исходном виде. Sentry, например, автоматически собирает breadcrumbs: сетевые запросы, навигацию, тапы. Это удобно, но именно поэтому важно чистить секреты заранее: если токен передаётся в query-параметре, Sentry честно залогирует и его тоже. Скраббинг нужно вешать до отправки, а не надеяться, что система сама разберётся:
SentrySDK.configureScope { scope in
scope.setBeforeBreadcrumb { crumb in
if var url = crumb.data?["url"] as? String {
url = redactQueryParams(url, keys: ["token", "access_token"])
crumb.data?["url"] = url
}
return crumb
}
}
5. User properties, где возможно, но только то, что реально используется в дашбордах, а не «на всякий случай».
6. Стек экранов: advanced-уровень, трекать вручную сложно.
С кастомными transition’ами, модалками поверх табов и вложенными
навигационными контроллерами понять, «на каком экране пользователь
на самом деле», не так просто. Некоторые SDK берут это на себя
автоматически: Firebase Analytics свизлит
viewDidAppear и сам шлёт screen_view, но
именем экрана становится имя класса, не всегда то, что нужно в
дашборде.
// Автоматический screen_view от Firebase покажет "CheckoutReviewViewController".
// Явный override там, где имя класса ничего не говорит:
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
AnalyticsParameterScreenName: "checkout_review",
AnalyticsParameterScreenClass: String(describing: type(of: self)),
])
7. Точка, где пользователь или тестировщик может явно отправить лог. Не всё стоит оставлять автоматической отправке, если баг воспроизвёлся у тестировщика, ему нужна кнопка «отправить логи», а не просьба описать словами, что он видел. Важно, чтобы эта точка входа была доступна в любом состоянии приложения, и залогиненном, и незалогиненном: баг может воспроизвестись прямо на экране логина, до того как пользователь вообще авторизовался. Для этого лог нужно копить в локальный буфер на диске, даже если он ещё никуда не долетел, а потом сбрасывать его по явному действию:
enum DiagnosticLogBuffer {
static func append(_ line: String) {
// кольцевой буфер с ограничением по размеру, пишется на диск
}
static func flushAndSend(reason: String) {
SupportAPI.uploadDiagnostics(readAll(), reason: reason)
}
}
// Видимая точка входа: debug-меню, экран поддержки, шейк-жест
Button("Отправить диагностику") {
DiagnosticLogBuffer.flushAndSend(reason: "user_initiated")
}
Отправка на свой сервер, не единственный вариант, а для маленькой команды часто и не нужен. Проще выгрузить буфер в файл и отдать его нативному share sheet, и тестировщику, и обычному пользователю, если баг всё-таки долетел до прода: он сам решит, кинуть файл в Slack, на AirDrop или почтой. В Slack под это обычно уже есть явное место, канал поддержки или команды, куда и так стекаются такие сообщения,, так что получателя даже не нужно придумывать заново, и никакой инфраструктуры под это поднимать не придётся:
func shareDiagnosticLogs(from viewController: UIViewController) {
let fileURL = DiagnosticLogBuffer.exportToFile()
let activity = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
viewController.present(activity, animated: true)
}
Что слать в аналитику
Резонный вопрос: раз уж завели единый интерфейс, слать ли в аналитику вообще всё подряд, трекать каждое действие пользователя, как иногда делают с Amplitude или Mixpanel? Нет. Если отправлять туда каждый чих, дашборд быстро превращается в тот же шум, о котором в начале поста,, только уже продуктовый, а не технический.
Стоит выбирать осознанно: важные продуктовые события
(checkout_started, payment_failed,
onboarding_completed), и туда же имеет смысл добавлять
критические ошибки, которые реально ломают флоу пользователя. Их
лучше видеть прямо в воронке, рядом с шагом, на котором это
случилось, а не только в отдельном логе, оторванном от контекста
конкретного пользователя. А вот сырые технические детали -
стектрейсы, промежуточные состояния, отладочная информация, туда
лучше не тащить: для этого есть логирование и краш-репортинг,
разобранные выше.
Инструменты имеют границы
У каждой системы логирования и аналитики свои ограничения, платные, бесплатные, простые, сложные. Обычно разумнее использовать сразу несколько инструментов, а не одну: у каждой свои сильные и слабые стороны, и это выясняется только на реальном коммерческом проекте.
Ни один SDK не отправляет отчёт о краше в момент самого падения - сеть и вообще любой код рядом уже ненадёжны, поэтому репорт сначала просто пишется локально на диск. Firebase Crashlytics, например, выгружает его только при следующем запуске приложения. Не открыл пользователь приложение снова, отчёт может не дойти до сервера вовсе или прийти с большой задержкой.
Sentry в этом смысле устроен иначе и умеет то, чего обычный crash-репортер не видит в принципе, ловит зависания (App Hangs на iOS, ANR на Android): ситуации, когда главный поток заблокирован на несколько секунд, но само приложение не крашится.
Для действительно критичных случаев ждать «следующего запуска» не всегда удобно, и тут пригождается тот же механизм из пункта 7 выше: если в приложении уже есть явная точка, где можно собрать все данные и отправить их (тот самый share sheet), ей можно воспользоваться и здесь, а не городить отдельный канал только под экстренные случаи.
И отдельно стоит держать в голове лимиты, особенно у платных систем, и и всегда иметь возможность отключить систему логирования, если проблему начинает создавать уже она сама.