公開日 2026-08-22

Sentryでクラッシュとエラーを検知する(Flutter最小構成)

Flutter へ sentry_flutter を最小導入し、runZonedGuarded、FlutterError.onError、手動送信の 3 経路でクラッシュと例外を検知できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に例外の流れをつかむ
  6. 3. プロジェクトと Sentry の準備をする
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. Sentry が何をするサービスかを先に押さえる
  11. 3-5. Sentry で DSN を確認する
  12. 3-6. sentry_flutter を追加する
  13. 4. lib/main.dart にグローバル捕捉を入れる
  14. コードのポイント
  15. 5. flutter run で送信を確認する
  16. 6. release build でも確認する
  17. 7. つまずきやすい点を先に潰す
  18. 8. まとめ

Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要) の次に入れておきたいのが、配布後の監視です。止まりやすいのは Sentry SDK の導入コマンドそのものではありません。どの例外が runZonedGuarded で拾われ、どれを FlutterError.onError で拾い、どれを Sentry.captureException() で明示送信するのかが曖昧なまま入れてしまう点にあります。この記事では sentry_flutter を使い、グローバル初期化、3 経路の例外送信、release build での確認までを最小構成で整理します。

1. ゴールと非対象

対象読者

  • Flutter プロジェクトを作成して flutter run した経験がある人
  • Android Emulator または Android 実機でアプリを動かせる人
  • 配布後のクラッシュや例外を最小構成で検知したい人
  • runZonedGuardedFlutterError.onErrorSentry.captureException() の役割差を先に整理したい人

この記事で到達する状態

  • sentry_flutter を Flutter プロジェクトへ追加できる
  • SentryFlutter.init() をアプリ起動の早い段階へ入れられる
  • runZonedGuardedFlutterError.onErrorSentry.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.onErrorFlutterError.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 の画面で devstgprod を分けやすくなります。最初は dev で十分です。

最初の画面では Create project を押します。

Sentry の All Projects 画面から Create project を押す

新規プロジェクト作成画面で Flutter を選び、プロジェクト名を決めて Create Project を押します。

Sentry の新規プロジェクト作成画面で Flutter を選ぶ

DSN が onboarding 画面で見つからないときは、SettingsSDK Setup から Client Keys (DSN) を開くと確認できます。記事中では実際の DSN は載せず、伏せた状態で扱います。

Sentry の 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() の早い段階へ置き、runZonedGuardedFlutterError.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 も避けやすくなります。

runZonedGuardedFlutterError.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
Sentry 送信確認用の Flutter サンプル画面

起動後は、画面上の 3 つのボタンを順に押します。

送信ログで 3 種類の送信操作を確認する
ボタン想定する送信内容Sentry で見るポイント
手動送信の例外を送るSentry.captureException() によるイベント明示送信した例外として Issue が出るか
非同期例外を送るrunZonedGuarded で拾う未処理例外非同期処理由来でも Issue が出るか
FlutterError を送るFlutterError.onError で受けたイベントFlutter framework error として届くか

Sentry の Issue 一覧では、タイトルと発生時刻をまず確認します。environment は一覧のフィルタや issue 詳細で見ておくと、devstg の混在に気づきやすくなります。最初の反映は少し待つことがあります。押してすぐ出ないときは数分待ってから再読込すると切り分けしやすくなります。

Sentry の Issue 一覧で 3 種類の StateError を確認する

3 つの StateError だけが並んでいれば確認は成功です。別で AssertionError が出るときは、WidgetsFlutterBinding.ensureInitialized()runApp() が同じ zone で実行されているかを見直します。

6. release build でも確認する

debug で送れたとしても、そこで終わらせないほうが安全です。実際に配布するのは release build だからです。

最短で確かめるなら、次のコマンドで release mode 起動を試せます。

ここでも DSN は同じ値を使い、SENTRY_ENV だけ stgprod に切り替えます。

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 つです。

  • environmentstgprod へ正しく切り替わっているか
  • ボタン操作でイベントが届くか
  • debug では出ていなかった初期化漏れや DSN 渡し忘れがないか

社内配布前にここまで確認しておくと、現場端末で問題が起きたときに「まず Sentry を見る」という運用へ入りやすくなります。

7. つまずきやすい点を先に潰す

詰まりどころ起きやすい原因先に見る場所
何も届かないSENTRY_DSN が空、または誤った DSN を渡している起動コマンド、画面上の DSN 状態表示
手動送信だけ届いて他が届かないrunZonedGuardedFlutterError.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 で渡す
  • runZonedGuardedFlutterError.onErrorSentry.captureException() は同じ役割ではない
  • debug だけで終わらせず、release build でも一度送信確認しておく

配布前の確認まで戻りたいときは Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要) を参照してください。自動テストで画面側の回帰も先に押さえたい場合は FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)FlutterのIntegration Test入門(ログインから一覧表示まで確認する) が続きます。

シリーズ 38/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成) 現在の記事