Привет, Хабр!
Меня зовут Андрей Бирюков. Я — независимый эксперт в области ИТ и ИБ, преподаю в учебных центрах и пишу статьи и книги.
Существует масса приложений, которые падают в продуктивной среде, а разработчики узнают об этом через гневные отзывы в Google Play. Но, проблема не в том, что ошибки случаются — они случаются всегда. Проблема в том, что стандартные отчёты об ошибках часто бесполезны без контекста. Вы видите стектрейс, но не знаете, что пользователь делал за 10 секунд до падения, какое у него состояние приложения и какие сетевые запросы висели в очереди.
В этой статье мы поговорим о том, как построить гибридную стратегию сбора ошибок: Firebase Crashlytics для быстрой статистики и массовых инцидентов, Sentry для детального анализа с хлебными крошками, и Datadog для корпоративного мониторинга с полной трассировкой. А главное — как заставить их работать вместе, а не мешать друг другу.
Почему один инструмент не решает все задачи
Конечно, всем бы хотелось, чтобы было какое‑то одно решение, позволяющее что называется, увидеть все. Но в реальности все несколько сложнее, и мы будем рассматривать сразу три решения. При этом, каждый из трёх сервисов решает свою задачу, и у каждого есть своя слепая зона.
Firebase Crashlytics — это чемпион по скорости и простоте. Он легковесный, мгновенно показывает, какой процент пользователей затронут конкретным крашем, и интегрируется с Firebase Analytics, позволяя видеть, какие события предшествовали падению. Но его кастомизация ограничена, а работа с изолятами и фоновыми ошибками оставляет желать лучшего.
Sentry даёт «хлебные крошки» (breadcrumbs) — вы можете записывать каждое действие пользователя, каждый переход между экранами и каждый сетевой запрос. Ошибка приходит в Sentry уже с контекстом: пользователь был на экране профиля, нажал кнопку «сохранить», перед этим получил ответ от API. Это бесценно для воспроизведения сложных багов.
Datadog — это enterprise‑решение с Real User Monitoring (RUM), которое показывает не только ошибки, но и производительность: долгие задачи, фризы интерфейса, сетевые задержки.
Правильная стратегия — использовать Crashlytics для быстрого реагирования на массовые проблемы, Sentry для глубокого анализа сложных кейсов, а Datadog (если бюджет позволяет) для проактивного мониторинга производительности.
Глобальный перехват ошибок
Прежде чем говорить об отправке ошибок в конкретные сервисы, нужно понять, как перехватывать все ошибки в Flutter‑приложении.
Их три типа:
Ошибки в виджетах — перехватываются через
FlutterError.onError.Ошибки вне виджетов (асинхронные, в изолятах) — через
PlatformDispatcher.instance.onError.Ошибки в зонах — если вы используете
runZonedGuarded.
Ниже приведена базовая структура инициализации, которая ловит все три типа ошибок:
import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); // Инициализация Firebase — обязательна перед Crashlytics await Firebase.initializeApp(); // Настройка Sentry await SentryFlutter.init( (options) { options.dsn = 'YOUR_SENTRY_DSN'; options.tracesSampleRate = 0.2; // 20% сессий для производительности }, appRunner: () => runApp(MyApp()), ); // Глобальные обработчики ошибок FlutterError.onError = (FlutterErrorDetails details) { // Отправляем в Sentry Sentry.captureException( details.exception, stackTrace: details.stack, hint: SentryHint(details), ); // Отправляем в Crashlytics как фатальную ошибку FirebaseCrashlytics.instance.recordFlutterFatalError(details); // Отправляем в Datadog DatadogSdk.instance.rum?.handleFlutterError(details); // Выводим в консоль в дебаге if (kDebugMode) print(details.toString()); }; PlatformDispatcher.instance.onError = (error, stack) { // Асинхронные ошибки Sentry.captureException(error, stackTrace: stack); FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); DatadogSdk.instance.rum?.addErrorInfo( error.toString(), RumErrorSource.source, stackTrace: stack, ); return true; // Говорим, что обработали }; runApp(const MyApp()); }
Здесь стоит обратить внимание на важное предостережение: если вы используете Zones, убедитесь, что WidgetsFlutterBinding.ensureInitialized() и runApp() выполняются в одной и той же зоне. Начиная с Flutter 3.10, нарушение этого правила вызывает предупреждение в консоли. Если вы работаете с зонами, оборачивайте всё в единую runZonedGuarded:
runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(); await SentryFlutter.init(...); runApp(MyApp()); }, (error, stack) { // Зонные ошибки сюда не дойдут, если вы правильно настроили PlatformDispatcher // Но подстраховаться стоит });
Быстрая диагностика массовых проблем
Начнем с Crashlytics — это ваш первый рубеж обороны. Он показывает, какие сбои чаще всего происходят и сколько пользователей они задевают. Но его сила не в автоматическом сборе, а в контексте, который вы добавляете руками.

Дело в том, что не все проблемы убивают приложение. Ошибка парсинга JSON или неудачный API‑запрос — это не сбой, но это проблема. Crashlytics позволяет логировать такие события как non‑fatal:
try { final result = int.parse('invalid_number'); } catch (e, stack) { FirebaseCrashlytics.instance.recordError( e, stack, fatal: false, reason: 'Number parsing failed in profile setup', ); }
Флаг fatal: false говорит Crashlytics, что это не сбой, но проблема заслуживает внимания. В дашборде такие ошибки группируются отдельно и не влияют на показатель «Безотказных пользователей».
При этом, главной фишкой Crashlytics являются custom keys. Вы можете привязать к каждому отчёту метаданные о состоянии приложения:
FirebaseCrashlytics.instance.setCustomKey('screen', 'CheckoutScreen'); FirebaseCrashlytics.instance.setCustomKey('cart_items', cartItems.length); FirebaseCrashlytics.instance.setCustomKey('payment_method', selectedMethod);
Теперь, когда на экране оформления заказа произойдёт сбой, вы увидите в Crashlytics, что у пользователя было 3 товара в корзине и выбран метод оплаты «Карта». Это сужает круг поиска с «где‑то в checkout» до конкретного сценария.
Также Crashlytics позволяет с определенными оговорками отслеживать действия пользователей. Так, если вы хотите понимать, затронул ли баг конкретного пользователя, добавьте идентификатор:
FirebaseCrashlytics.instance.setUserIdentifier(userId);
Но будьте осторожны с персональными данными и другой нормативкой. Чтобы ничего не нарушить, анонимизируйте данный идентификатор (например, возьмите хеш от email).
Хлебные крошки и кастомные контексты
Наш следующий инструмент это Sentry. Он выигрывает там, где Crashlytics пасует — в сложных, редко воспроизводимых багах. Его главное оружие — breadcrumbs (хлебные крошки) и контексты.

Sentry автоматически добавляет breadcrumbs для навигации, сетевых запросов и событий жизненного цикла при использовании SentryWidget:
await SentryFlutter.init( (options) { options.dsn = 'YOUR_DSN'; options.sendDefaultPii = true; // Добавляет IP и заголовки запросов options.enableLogs = true; // Логи тоже превращаются в breadcrumbs }, appRunner: () => runApp( SentryWidget( child: MyApp(), ), ), );
Но самые ценные данные — те, что вы добавляете руками в ключевые моменты бизнес‑логики:
import 'package:sentry/sentry.dart'; void submitOrder(Order order) async { Sentry.addBreadcrumb(Breadcrumb( message: 'Order submitted', category: 'checkout', data: { 'order_id': order.id, 'total': order.total, 'items_count': order.items.length, }, )); try { await api.submitOrder(order); } catch (e, stack) { // При ошибке все breadcrumbs уйдут вместе с отчётом await Sentry.captureException(e, stackTrace: stack); } }
Когда через три дня после релиза упадёт оформление заказа, вы откроете Sentry, увидите стектрейс, а под ним — цепочку действий пользователя: «открыл корзину → применил промокод → нажал «Оформить» → отправил заказ → сбой». Именно эта цепочка превращает «где‑то упало» в «упало при применении промокода длиннее 10 символов».
Также Sentry позволяет добавлять глобальный контекст, который будет прикреплён к каждой ошибке:
Sentry.configureScope((scope) { scope.setTag('app_version', '2.1.0'); scope.setTag('environment', 'production'); scope.setContext('user', {'id': userId, 'plan': 'premium'}); scope.setExtra('device_info', DeviceInfoPlugin().deviceInfo); });
Производительность и мониторинг в реальном времени
Datadog RUM (Real User Monitoring) — это не про ошибки, а про состояние приложения в каждый момент времени. Он показывает, сколько времени занимает загрузка экрана, где происходят фризы и какие сетевые запросы тормозят интерфейс.

Далее мы посмотрим несколько примеров использования Datadog. Начнем с инициализации и трассировки.
import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; final configuration = DatadogConfiguration( clientToken: 'YOUR_CLIENT_TOKEN', env: 'production', site: DatadogSite.us1, nativeCrashReportEnabled: true, rumConfiguration: DatadogRumConfiguration( applicationId: 'YOUR_RUM_APP_ID', sessionSamplingRate: 50.0, // 50% сессий для экономии трафика traceSampleRate: 20.0, // 20% ресурсов с APM-трассировкой reportFlutterPerformance: true, // Сбор метрик FPS и времени сборки ), ); await DatadogSdk.runApp(configuration, TrackingConsent.granted, () async { runApp(MyApp()); });
Для автоматического отслеживания навигации вы можете указать Datadog, на каком экране находится пользователь, добавив observer, как в примере ниже:
MaterialApp( navigatorObservers: [ DatadogNavigationObserver(DatadogSdk.instance), ], //... );
А если вы используете go_router, observer добавляется в конфигурацию роутера:
final router = GoRouter( routes: [...], observers: [ DatadogNavigationObserver(datadogSdk: DatadogSdk.instance), ], );
Каждое нажатие кнопки пользователем можно легко превратить в событие RUM:
RumUserActionDetector( rum: DatadogSdk.instance.rum, child: Scaffold( // Все тапы внутри этого дерева будут автоматически залогированы ), );
Наконец, если нужен кастомный трекинг:
DatadogSdk.instance.rum?.addAction('checkout_completed', attributes: { 'order_value': total, });
Гибридная стратегия: когда что использовать
Итак, у вас есть три инструмента. Как в них не запутаться, куда и что отправлять? Здесь можно воспользоваться простым правилом. Firebase Crashlytics предназначен для всего, что прерывает работу приложения. Сбои, фатальные ошибки, и non‑fatal ошибки с высоким приоритетом. Здесь вы смотрите на тренды: «за последний час упало 500 пользователей на экране логина».
В свою очередь, Sentry используется для ошибок, требующих глубокого контекста. Нефатальные ошибки бизнес‑логики, проблемы с API, парсингом данных. Сюда же — все ошибки с тегами и breadcrumbs, чтобы потом воспроизвести сценарий.
А Datadog используем для мониторинга производительности и сетевых запросов. Здесь вы видите, что экран профиля грузится 3 секунды, хотя должен загружаться за 500 мс. И видите, какой именно эндпоинт тормозит.
В коде это выглядит так:
Future<Result<User>> fetchUser(String id) async { try { final response = await dio.get('/users/$id'); final user = User.fromJson(response.data); // Успешный кейс — ничего не отправляем return Success(user); } catch (e, stack) { // 1. В Crashlytics — как non-fatal с важностью FirebaseCrashlytics.instance.recordError(e, stack, fatal: false); // 2. В Sentry — с контекстом и breadcrumbs Sentry.addBreadcrumb(Breadcrumb( message: 'Failed to fetch user', category: 'api', data: {'user_id': id}, )); await Sentry.captureException(e, stackTrace: stack); // 3. В Datadog — как ошибка в RUM-сессии DatadogSdk.instance.rum?.addErrorInfo( 'Failed to fetch user: $e', RumErrorSource.source, stackTrace: stack, ); return Failure(mapError(e)); } }
Обработка ошибок на уровне UI
Последняя миля — показать пользователю что‑то вменяемое вместо белого экрана. Для этого переопределяем ErrorWidget.builder:
import 'package:flutter/material.dart'; void main() { ErrorWidget.builder = (FlutterErrorDetails details) { // Логируем ошибку (она уже перехвачена глобальным обработчиком) // Показываем дружелюбный UI return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 64, color: Colors.grey), SizedBox(height: 16), Text( 'Что-то пошло не так', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), SizedBox(height: 8), Text( 'Попробуйте перезапустить приложение', style: TextStyle(color: Colors.grey), ), ], ), ), ); }; runApp(MyApp()); }
Этот кастомный виджет сработает, когда Flutter не может отрендерить рабочий виджет — например, при null в build или ошибке в initState.
Три инструмента — одна стратегия
В завершении статьи давайте подведем краткий итог. Итак, ни один инструмент не закроет все потребности. Crashlytics даёт скорость и массовость, Sentry — глубину и контекст, Datadog — производительность и полную картину.
Главное правило: ошибки не должны быть безмолвными. Если вы не видите ошибку в своём мониторинге — её не существует для вашей команды. А значит, её не починят.
Настройте глобальный перехват, продумайте, какие ошибки куда отправлять, и добавьте контекст везде, где это возможно. Тогда даже самый редкий баг, пойманный одним пользователем в Таиланде на Android 9, превратится в понятный кейс с хлебными крошками, стектрейсом и информацией об устройстве. И вы сможете починить его до того, как он станет массовым.
Когда ошибки, логи и трассировки живут в разных системах, даже хороший стек мониторинга не всегда помогает быстро найти причину сбоя.
На бесплатных занятиях разберём, как связать наблюдаемость, логирование и архитектурные требования в одну рабочую схему, чтобы замечать проблемы раньше пользователей и быстрее возвращать приложение в нормальное состояние. Присоединяйтесь:
4 августа, 20:00. «OpenTelemetry — наблюдаемость на блюдечке». Записаться
5 августа, 20:00. «Влияние нефункциональных требований на архитектуру». Записаться
17 августа, 20:00. «Системы логирования: ELK, EFK или Graylog?». Записаться
Больше бесплатных уроков июля смотрите в дайджесте.