Привет, Хабр!

Меня зовут Андрей Бирюков. Я — независимый эксперт в области ИТ и ИБ, преподаю в учебных центрах и пишу статьи и книги.

Существует масса приложений, которые падают в продуктивной среде, а разработчики узнают об этом через гневные отзывы в 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‑приложении.

Их три типа:

  1. Ошибки в виджетах — перехватываются через FlutterError.onError.

  2. Ошибки вне виджетов (асинхронные, в изолятах) — через PlatformDispatcher.instance.onError.

  3. Ошибки в зонах — если вы используете 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?». Записаться

Больше бесплатных уроков июля смотрите в дайджесте.

Комментарии (0)