Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要) の次に入れておきたいのが、配布後の監視です。止まりやすいのは Sentry SDK の導入コマンドそのものではありません。どの例外が runZonedGuarded で拾われ、どれを FlutterError.onError で拾い、どれを Sentry.captureException() で明示送信するのかが曖昧なまま入れてしまう点にあります。この記事では sentry_flutter を使い、グローバル初期化、3 経路の例外送信、release build での確認までを最小構成で整理します。
1. ゴールと非対象
対象読者
- Flutter プロジェクトを作成して
flutter runした経験がある人 - Android Emulator または Android 実機でアプリを動かせる人
- 配布後のクラッシュや例外を最小構成で検知したい人
runZonedGuarded、FlutterError.onError、Sentry.captureException()の役割差を先に整理したい人
この記事で到達する状態
sentry_flutterを Flutter プロジェクトへ追加できるSentryFlutter.init()をアプリ起動の早い段階へ入れられるrunZonedGuarded、FlutterError.onError、Sentry.captureException()を使い分けられる- Sentry の Issue 一覧で送信結果を確認できる
- release build で最終確認する観点を説明できる
非対象
- iOS の設定や TestFlight 配布
- debug symbol upload やソースマップ対応
- tracing、profiling、Session Replay、Logs
- Firebase Crashlytics との比較
- Sentry の通知ルールや組織運用の詳細
今回は「最初に入れる監視」に絞ります。配布後に何か起きたとき、まず異常を拾える状態までを先に固めます。
2. 先に例外の流れをつかむ
まず、どの例外がどこを通って Sentry へ届くかを分けます。
| 例外の種類 | 送信経路 | 今回の確認方法 | 主な用途 |
|---|---|---|---|
| 自分で捕まえた例外 | Sentry.captureException() | ボタンで明示送信する | API 失敗、想定外レスポンス、業務エラーの記録 |
| Zone 内の未処理例外 | runZonedGuarded | 非同期例外ボタンで送る | Future やタイマー経由の未処理例外 |
| Flutter フレームワークエラー | FlutterError.onError | FlutterError.reportError() で送る | build、layout、framework 内で報告されるエラー |
今回の流れは次の通りです。
flowchart LR
A[手動で catch した例外] --> B[Sentry.captureException]
C[Future や非同期処理の未処理例外] --> D[runZonedGuarded]
E[Flutter framework error] --> F[FlutterError.onError]
B --> G[Sentry]
D --> G
F --> G
G --> H[Issue 一覧で確認]
見るべき点は「例外が送れたか」だけではありません。
- 自分で握った例外は自動では届かないので、
Sentry.captureException()が必要 Futureの未処理例外はrunZonedGuarded側で拾う- Flutter フレームワークが報告するエラーは
FlutterError.onError側で拾う
ここを最初に分けておくと、後で「どの例外が Sentry に出ないのか」を切り分けやすくなります。
3. プロジェクトと Sentry の準備をする
3-1. Flutter の環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create warehouse_sentry_sample
cd warehouse_sentry_sample
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. Sentry が何をするサービスかを先に押さえる
Sentry は、アプリで発生したクラッシュや例外を収集し、管理画面で確認できるエラー監視サービスです。Flutter アプリへ SDK を入れておくと、どの端末で、どの例外が、どのスタックトレースと一緒に発生したかを後から追えます。
ユーザー向けの Web サービスというより、開発者や運用者が障害を把握するための SaaS と捉えると位置づけやすくなります。自己ホスト構成も選べますが、この段階ではクラウド版を前提に進めたほうが導入の流れを追いやすくなります。
3-5. Sentry で DSN を確認する
Sentry 側で Flutter プロジェクトを作成し、表示された DSN を控えます。この記事では DSN をコードへ直書きせず、--dart-define=SENTRY_DSN=... で渡します。
onboarding 画面に生成済みのコードが出ても、この時点では DSN の確認だけで十分です。本文では lib/main.dart を手で整える構成で進めます。
SENTRY_ENV も合わせて決めておくと、Sentry の画面で dev、stg、prod を分けやすくなります。最初は dev で十分です。
最初の画面では Create project を押します。
新規プロジェクト作成画面で Flutter を選び、プロジェクト名を決めて Create Project を押します。
DSN が onboarding 画面で見つからないときは、Settings の SDK Setup から Client Keys (DSN) を開くと確認できます。記事中では実際の DSN は載せず、伏せた状態で扱います。
3-6. sentry_flutter を追加する
プロジェクト直下で次のコマンドを実行します。
flutter pub add sentry_flutter
これで SDK の準備が揃いました。次は lib/main.dart に初期化と送信確認の画面をまとめます。
4. lib/main.dart にグローバル捕捉を入れる
lib/main.dart は次の内容で書き換えます。Sentry の初期化、環境表示、3 種類の送信ボタン、送信ログを 1 画面へまとめたサンプルです。注目点は、SDK 初期化を main() の早い段階へ置き、runZonedGuarded と FlutterError.onError を分けていることです。
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
const sentryDsn = String.fromEnvironment('SENTRY_DSN', defaultValue: '');
const sentryEnvironment = String.fromEnvironment(
'SENTRY_ENV',
defaultValue: 'dev',
);
Future<void> main() async {
await runZonedGuarded<Future<void>>(
() async {
WidgetsFlutterBinding.ensureInitialized();
await SentryFlutter.init(
(options) {
options.dsn = sentryDsn;
options.environment = sentryEnvironment;
options.sendDefaultPii = false;
options.tracesSampleRate = 0.0;
},
appRunner: () {
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
unawaited(
Sentry.captureException(
details.exception,
stackTrace: details.stack,
),
);
};
runApp(const SentrySampleApp());
},
);
},
(Object error, StackTrace stackTrace) {
unawaited(
Sentry.captureException(
error,
stackTrace: stackTrace,
),
);
},
);
}
class SentrySampleApp extends StatelessWidget {
const SentrySampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Sentry Sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: const SentryDashboardPage(),
);
}
}
class SentryDashboardPage extends StatefulWidget {
const SentryDashboardPage({super.key});
@override
State<SentryDashboardPage> createState() => _SentryDashboardPageState();
}
class _SentryDashboardPageState extends State<SentryDashboardPage> {
final List<String> _logs = <String>[
'Sentry 監視ダッシュボードを起動しました。',
];
Future<void> _sendHandledException() async {
try {
throw StateError('確認用: 手動送信の例外です。');
} catch (error, stackTrace) {
await Sentry.captureException(error, stackTrace: stackTrace);
_appendLog('Sentry.captureException() で例外を送信しました。');
}
}
void _sendZoneException() {
_appendLog('runZonedGuarded で拾う非同期例外を送信します。');
Future<void>.delayed(const Duration(milliseconds: 200), () {
throw StateError('確認用: runZonedGuarded の非同期例外です。');
});
}
void _sendFlutterFrameworkError() {
_appendLog('FlutterError.onError で拾う framework error を送信します。');
FlutterError.reportError(
FlutterErrorDetails(
exception: StateError('確認用: Flutter framework error です。'),
stack: StackTrace.current,
library: 'sentry_sample',
context: ErrorDescription('Sentry 送信確認ボタンからの実行'),
),
);
}
void _appendLog(String message) {
if (!mounted) {
return;
}
final now = TimeOfDay.now();
setState(() {
_logs.insert(
0,
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')} $message',
);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Sentry 監視サンプル'),
),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kReleaseMode ? 'RELEASE MODE' : 'DEBUG OR PROFILE MODE',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'クラッシュと例外の送信確認',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text('environment: $sentryEnvironment'),
Text(
'DSN: ${sentryDsn.isEmpty ? '未設定' : '設定済み'}',
),
],
),
),
const SizedBox(height: 24),
_ActionCard(
title: '今回の確認項目',
lines: const <String>[
'手動送信した例外が Sentry に届くか',
'非同期例外が runZonedGuarded で拾われるか',
'Flutter framework error が FlutterError.onError で届くか',
],
accentColor: theme.colorScheme.primary,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _sendHandledException,
child: const Text('手動送信の例外を送る'),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _sendZoneException,
child: const Text('非同期例外を送る'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _sendFlutterFrameworkError,
child: const Text('FlutterError を送る'),
),
const SizedBox(height: 24),
Text('送信ログ', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
for (final log in _logs)
Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(log),
),
),
],
),
);
}
}
class _ActionCard extends StatelessWidget {
const _ActionCard({
required this.title,
required this.lines,
required this.accentColor,
});
final String title;
final List<String> lines;
final Color accentColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: accentColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
for (final line in lines)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('・'),
const SizedBox(width: 8),
Expanded(child: Text(line)),
],
),
),
],
),
);
}
}
コードのポイント
① SentryFlutter.init() を main() の早い段階へ置く
Future<void> main() async {
await runZonedGuarded<Future<void>>(
() async {
WidgetsFlutterBinding.ensureInitialized();
await SentryFlutter.init(
(options) {
options.dsn = sentryDsn;
options.environment = sentryEnvironment;
},
appRunner: () {
runApp(const SentrySampleApp());
},
);
},
初期化が遅いと、アプリ起動直後の異常が監視対象から漏れます。WidgetsFlutterBinding.ensureInitialized() は runZonedGuarded の中で呼び、そのまま同じ zone で runApp() まで進める形にしておくと、Zone mismatch の AssertionError も避けやすくなります。
② runZonedGuarded と FlutterError.onError は役割が違う
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
unawaited(
Sentry.captureException(
details.exception,
stackTrace: details.stack,
),
);
};
Future<void>.delayed(const Duration(milliseconds: 200), () {
throw StateError('確認用: runZonedGuarded の非同期例外です。');
});
FlutterError.onError は Flutter フレームワークが報告するエラーの受け口です。Future やタイマー経由の未処理例外は runZonedGuarded 側で拾います。同じ仕組みではない点に注意してください。
③ 自分で捕まえた例外は自分で送る
try {
throw StateError('確認用: 手動送信の例外です。');
} catch (error, stackTrace) {
await Sentry.captureException(error, stackTrace: stackTrace);
}
try-catch で握った例外は、そのままでは Sentry へ届きません。業務エラーや API 異常を送るなら、このように Sentry.captureException() を明示的に呼びます。
5. flutter run で送信を確認する
DSN を --dart-define で渡しながら起動します。
次のコマンドにある https://examplePublicKey@o0.ingest.sentry.io/0 はサンプルです。ここを Sentry の Client Keys (DSN) 画面でコピーした自分の DSN に書き換えてください。
flutter run --dart-define=SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 --dart-define=SENTRY_ENV=dev
起動後は、画面上の 3 つのボタンを順に押します。
| ボタン | 想定する送信内容 | Sentry で見るポイント |
|---|---|---|
| 手動送信の例外を送る | Sentry.captureException() によるイベント | 明示送信した例外として Issue が出るか |
| 非同期例外を送る | runZonedGuarded で拾う未処理例外 | 非同期処理由来でも Issue が出るか |
| FlutterError を送る | FlutterError.onError で受けたイベント | Flutter framework error として届くか |
Sentry の Issue 一覧では、タイトルと発生時刻をまず確認します。environment は一覧のフィルタや issue 詳細で見ておくと、dev と stg の混在に気づきやすくなります。最初の反映は少し待つことがあります。押してすぐ出ないときは数分待ってから再読込すると切り分けしやすくなります。
3 つの StateError だけが並んでいれば確認は成功です。別で AssertionError が出るときは、WidgetsFlutterBinding.ensureInitialized() と runApp() が同じ zone で実行されているかを見直します。
6. release build でも確認する
debug で送れたとしても、そこで終わらせないほうが安全です。実際に配布するのは release build だからです。
最短で確かめるなら、次のコマンドで release mode 起動を試せます。
ここでも DSN は同じ値を使い、SENTRY_ENV だけ stg や prod に切り替えます。
flutter run --release --dart-define=SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 --dart-define=SENTRY_ENV=stg
APK で確認したい場合は、次のコマンドで build します。
flutter build apk --release --dart-define=SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 --dart-define=SENTRY_ENV=stg
できあがった APK を端末へ入れる手順は Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要) で扱った通りです。ADB で試すなら、例えば次のように上書きインストールできます。
adb install -r build/app/outputs/flutter-apk/app-release.apk
release build で見るべき点は次の 3 つです。
environmentがstgやprodへ正しく切り替わっているか- ボタン操作でイベントが届くか
- debug では出ていなかった初期化漏れや DSN 渡し忘れがないか
社内配布前にここまで確認しておくと、現場端末で問題が起きたときに「まず Sentry を見る」という運用へ入りやすくなります。
7. つまずきやすい点を先に潰す
| 詰まりどころ | 起きやすい原因 | 先に見る場所 |
|---|---|---|
| 何も届かない | SENTRY_DSN が空、または誤った DSN を渡している | 起動コマンド、画面上の DSN 状態表示 |
| 手動送信だけ届いて他が届かない | runZonedGuarded や FlutterError.onError の役割を混同している | main() の初期化順、ボタンごとの送信経路 |
AssertionError: Zone mismatch が出る | WidgetsFlutterBinding.ensureInitialized() と runApp() を別の zone で実行している | main() の初期化順、runZonedGuarded の内側で binding を初期化しているか |
| debug では届くが release で届かない | release 起動時に --dart-define を付け忘れた | build / run コマンド、environment 表示 |
try-catch したエラーが届かない | 例外を握っただけで送っていない | Sentry.captureException() の呼び出し有無 |
特に誤解しやすいのは、「Sentry を入れれば全部自動で届く」という見方です。自分で捕まえた例外は自動では送られません。グローバルに拾う経路と、手動で送る経路は分けて考えます。
8. まとめ
sentry_flutterは起動の早い段階で初期化し、DSN は--dart-defineで渡すrunZonedGuarded、FlutterError.onError、Sentry.captureException()は同じ役割ではない- debug だけで終わらせず、release build でも一度送信確認しておく
配布前の確認まで戻りたいときは Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要) を参照してください。自動テストで画面側の回帰も先に押さえたい場合は FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成) と FlutterのIntegration Test入門(ログインから一覧表示まで確認する) が続きます。