Flutter で作成した Android アプリに Amazon CloudWatch RUM を導入する
Flutter で作成した Android アプリに Amazon CloudWatch RUM(Real User Monitoring)を導入し、HTTP リクエスト、クラッシュ、画面遷移、セッションなどのテレメトリデータを収集する方法を解説します。
はじめに
CloudWatch RUM は、AWS Distro for OpenTelemetry(ADOT)Android SDK を利用することで、Android アプリのリアルユーザーモニタリングが可能です。
Flutter アプリでは、全ての画面が単一の MainActivity 上で動作するため、ネイティブ SDK の自動計装だけでは画面遷移や HTTP リクエストの計測が不十分です。本記事では以下の 2 つのブリッジを構築し、RUM コンソールの全タブにデータを反映させます。
- HTTP リクエスト: Dio の通信を MethodChannel 経由でネイティブ OkHttp に中継し、ByteBuddy 自動計装で RUM に記録
-
画面遷移: GoRouter の遷移を MethodChannel 経由でネイティブの
VisibleScreenTrackerに反映
本記事で実現すること
- CloudWatch RUM App Monitor の作成と設定
- ADOT Android SDK(Zero-Code Instrumentation)の導入
- Flutter の HTTP リクエストを RUM の HTTP requests / Network errors に反映
- Flutter の画面遷移を RUM の screen.name に反映
- クラッシュレポートの収集
- Trace Map でのバックエンド接続の可視化
前提条件
- Flutter 3.x 以上
- Android minSdk 26 以上(ADOT SDK の要件)
- AWS アカウントと CloudWatch RUM へのアクセス権限
iOS 版について
iOS 版の導入手順は別記事「Flutter で作成した iOS と Android アプリに CloudWatch RUM を導入する ①」を参照してください。
アーキテクチャ
1. CloudWatch RUM App Monitor の作成
AWS マネジメントコンソールで App Monitor を作成します。
- CloudWatch コンソール → Application Signals → RUM → 「Add app monitor」
- 以下を設定:
-
App monitor name:
your-app-android - Application type: Android
- Active tracing: 有効(X-Ray 連携)
-
App monitor name:
作成後、App Monitor ID をメモしておきます。
2. リソースベースポリシーの設定
モバイルアプリからの未認証リクエストを許可するため、リソースベースポリシーを設定します。App Monitor 作成時に「Create public policy」を選択した場合はこの手順は不要です。
aws rum put-resource-policy \
--name your-app-android \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "rum:PutRumEvents",
"Resource": "arn:aws:rum:<REGION>:<ACCOUNT_ID>:appmonitor/your-app-android",
"Principal": "*"
}
]
}' \
--region <REGION>
注意: 本番環境では IP アドレス制限などの条件を追加することを推奨します。
3. Android ネイティブ側の設定
3.1 settings.gradle.kts — ByteBuddy プラグインの追加
android/settings.gradle.kts の plugins ブロックに ByteBuddy プラグインを追加します:
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
// ↓ 追加
id("net.bytebuddy.byte-buddy-gradle-plugin") version "1.15.10" apply false
}
3.2 app/build.gradle.kts — 依存関係の追加
android/app/build.gradle.kts に以下の変更を加えます:
plugins ブロックに ByteBuddy を追加:
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
// ↓ 追加
id("net.bytebuddy.byte-buddy-gradle-plugin")
}
minSdk を 26 に変更(ADOT SDK の要件):
defaultConfig {
applicationId = "com.example.your_app"
minSdk = 26 // ← flutter.minSdkVersion から変更
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
dependencies ブロックに ADOT SDK、OkHttp、ByteBuddy 計装を追加:
dependencies {
// ADOT Android SDK(Zero-Code Instrumentation)
implementation("software.amazon.opentelemetry.android:agent:1.0.0")
// OkHttp(Flutter の HTTP リクエストを中継するため)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ByteBuddy による OkHttp/HttpURLConnection 自動計装
byteBuddy("io.opentelemetry.android.instrumentation:okhttp3-agent:0.15.0-alpha")
byteBuddy("io.opentelemetry.android.instrumentation:httpurlconnection-agent:0.15.0-alpha")
}
3.3 aws_config.json — ADOT 設定ファイル
android/app/src/main/res/raw/aws_config.json を作成します:
{
"aws": {
"region": "<REGION>",
"rumAppMonitorId": "<YOUR_APP_MONITOR_ID>"
},
"otelResourceAttributes": {
"service.name": "your-app-android",
"service.version": "1.0.0"
}
}
ADOT Agent はアプリ起動時にこのファイルを自動的に読み込み、テレメトリ収集を開始します。コードの変更は不要です(Zero-Code Instrumentation)。
3.4 AndroidManifest.xml — Application クラスの登録
android/app/src/main/AndroidManifest.xml の <application> タグに android:name を追加します:
<application
android:label="your_app"
android:name=".YourApplication"
android:icon="@mipmap/ic_launcher">
3.5 Application クラス — 画面名の上書き
Flutter アプリでは全ての画面が MainActivity 上で動作するため、ADOT の自動計装では screen.name が常に MainActivity になります。これを Flutter の画面名で上書きするため、VisibleScreenTracker の内部状態をリフレクションで更新します。
android/app/src/main/kotlin/<package>/YourApplication.kt:
package com.example.your_app
import io.flutter.app.FlutterApplication
import java.util.concurrent.atomic.AtomicReference
class YourApplication : FlutterApplication() {
companion object {
@Volatile
private var lastResumedActivityRef: AtomicReference<String>? = null
fun updateScreenName(name: String) {
lastResumedActivityRef?.set(name)
}
}
override fun onCreate() {
super.onCreate()
// ADOT Agent は ContentProvider 経由で onCreate() より前に初期化済み。
// リフレクションで VisibleScreenTracker にアクセスし、
// screen.name を Flutter の画面名で上書き可能にする。
try {
val servicesClass = Class.forName(
"io.opentelemetry.android.internal.services.Services"
)
val getMethod = servicesClass.getMethod(
"get", android.app.Application::class.java
)
val services = getMethod.invoke(null, this)
val trackerMethod = servicesClass.getMethod("getVisibleScreenTracker")
val tracker = trackerMethod.invoke(services)
val field = tracker.javaClass.getDeclaredField("lastResumedActivity")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
lastResumedActivityRef = field.get(tracker) as? AtomicReference<String>
} catch (_: Exception) {}
}
}
なぜリフレクションが必要か:
VisibleScreenTrackerはinternalパッケージに属しており、直接アクセスできません。また、ADOT のOpenTelemetryRumClientConfigには SpanProcessor を追加する API がないため、リフレクションでlastResumedActivityのAtomicReferenceを取得し、Flutter の画面名で上書きします。
3.6 MainActivity — Platform Channel ハンドラ
android/app/src/main/kotlin/<package>/MainActivity.kt に、HTTP リクエスト中継と画面遷移通知の Platform Channel を実装します:
package com.example.your_app
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.opentelemetry.api.common.Attributes
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import software.amazon.opentelemetry.android.OpenTelemetryRumClient
import software.amazon.opentelemetry.android.features.span
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class MainActivity : FlutterActivity() {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val executor = Executors.newCachedThreadPool()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// HTTP 中継チャネル: Dart → OkHttp → ByteBuddy 自動計装
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.yourapp/http"
).setMethodCallHandler { call, result ->
if (call.method == "request") {
val method = call.argument<String>("method") ?: "GET"
val url = call.argument<String>("url") ?: ""
val headers = call.argument<Map<String, String>>("headers")
?: emptyMap()
val body = call.argument<String>("body")
executor.execute {
try {
val rb = Request.Builder().url(url)
headers.forEach { (k, v) -> rb.addHeader(k, v) }
val reqBody = body?.toRequestBody(
"application/json".toMediaTypeOrNull()
)
when (method.uppercase()) {
"GET" -> rb.get()
"POST" -> rb.post(
reqBody ?: "".toRequestBody(null)
)
"PUT" -> rb.put(
reqBody ?: "".toRequestBody(null)
)
"PATCH" -> rb.patch(
reqBody ?: "".toRequestBody(null)
)
"DELETE" -> if (reqBody != null)
rb.delete(reqBody) else rb.delete()
}
val resp = httpClient.newCall(rb.build()).execute()
val respBody = resp.body?.string() ?: ""
val respHeaders = mutableMapOf<String, String>()
resp.headers.forEach { (k, v) -> respHeaders[k] = v }
runOnUiThread {
result.success(mapOf(
"statusCode" to resp.code,
"body" to respBody,
"headers" to respHeaders
))
}
} catch (e: Exception) {
runOnUiThread {
result.error("HTTP_ERROR", e.message, null)
}
}
}
} else {
result.notImplemented()
}
}
// OTel チャネル: 画面遷移通知
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.yourapp/otel"
).setMethodCallHandler { call, result ->
when (call.method) {
"viewChanged" -> {
val name = call.argument<String>("name") ?: "unknown"
YourApplication.updateScreenName(name)
OpenTelemetryRumClient.span(
name = "screen.view",
attributes = Attributes.builder()
.put("screen.name", name)
.build()
) {}
result.success(null)
}
else -> result.notImplemented()
}
}
}
}
なぜ OkHttp で中継するのか
Flutter の Dio は Dart VM 内の HTTP スタックを使用するため、ADOT の ByteBuddy 自動計装(OkHttp / HttpURLConnection 対象)では捕捉できません。
カスタムスパンで HTTP 属性を手動設定する方法も試みましたが、CloudWatch RUM コンソールの HTTP requests セクションや Trace Map には反映されませんでした。RUM が認識するのは ByteBuddy 自動計装が生成する正規のスパンのみです。
そのため、Dart 側の HTTP リクエストを Platform Channel 経由でネイティブの OkHttp に中継し、ByteBuddy の自動計装に拾わせるアプローチを採用しています。
4. Dart 側の実装
4.1 NativeHttpClientAdapter — OkHttp 中継アダプター
Dio の HttpClientAdapter を差し替えて、Android では全ての HTTP リクエストをネイティブ OkHttp 経由で実行します。
lib/core/network/native_http_client_adapter.dart:
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
class NativeHttpClientAdapter implements HttpClientAdapter {
static const _channel = MethodChannel('com.yourapp/http');
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
String? body;
if (requestStream != null) {
final chunks = <int>[];
await for (final chunk in requestStream) {
chunks.addAll(chunk);
}
if (chunks.isNotEmpty) {
body = utf8.decode(chunks);
}
}
final headers = <String, String>{};
options.headers.forEach((k, v) {
if (v != null) headers[k] = v.toString();
});
try {
final response = await _channel.invokeMethod<Map>('request', {
'method': options.method,
'url': options.uri.toString(),
'headers': headers,
if (body != null) 'body': body,
});
final statusCode = response!['statusCode'] as int;
final responseBody = response['body'] as String? ?? '';
final responseHeaders = <String, List<String>>{};
if (response['headers'] is Map) {
(response['headers'] as Map).forEach((k, v) {
responseHeaders[k.toString()] = [v.toString()];
});
}
return ResponseBody.fromString(
responseBody,
statusCode,
headers: responseHeaders,
);
} on PlatformException catch (e) {
throw DioException(
requestOptions: options,
type: DioExceptionType.connectionError,
message: e.message,
);
}
}
@override
void close({bool force = false}) {}
}
4.2 DioClient — Android 時にアダプターを差し替え
lib/core/network/dio_client.dart で、Android の場合のみ NativeHttpClientAdapter を使用します:
import 'dart:io';
import 'package:dio/dio.dart';
import 'native_http_client_adapter.dart';
class DioClient {
late final Dio _dio;
DioClient() {
_dio = Dio(baseOptions);
// Android では OkHttp 経由で ADOT 自動計装を有効化
if (Platform.isAndroid) {
_dio.httpClientAdapter = NativeHttpClientAdapter();
}
_setupInterceptors();
}
Dio get instance => _dio;
void _setupInterceptors() {
// OtelHttpInterceptor は不要(OkHttp が自動でトレースヘッダーを付与)
_dio.interceptors.add(LoggingInterceptor());
_dio.interceptors.add(ConnectivityInterceptor());
_dio.interceptors.add(AuthInterceptor());
_dio.interceptors.add(ErrorInterceptor());
}
}
重要: Android では
OtelHttpInterceptor(Dart 側でトレースヘッダーを付与するインターセプター)は不要です。ByteBuddy が OkHttp のリクエストに自動でtraceparentヘッダーを付与します。iOS では引き続きOtelHttpInterceptorを使用してください。
4.3 OtelNavigatorObserver — 画面遷移の通知
lib/core/observability/otel_navigator_observer.dart:
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class OtelNavigatorObserver extends NavigatorObserver {
static const _channel = MethodChannel('com.yourapp/otel');
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
_reportRoute(route);
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
if (newRoute != null) _reportRoute(newRoute);
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (previousRoute != null) _reportRoute(previousRoute);
}
void _reportRoute(Route<dynamic> route) {
final name = route.settings.name ?? 'unknown';
_channel.invokeMethod('viewChanged', {'name': name}).catchError((_) {});
}
}
GoRouter に Observer を追加:
GoRouter(
initialLocation: '/',
observers: [OtelNavigatorObserver()],
routes: [ /* ... */ ],
);
5. ビルドと動作確認
5.1 ビルド
flutter clean
flutter pub get
flutter build apk --debug
注意: JDK 25 以降を使用している場合、Kotlin コンパイラが Java バージョンを解析できずビルドに失敗します。Android Studio 付属の JDK 21 を使用してください:
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" flutter build apk --debug
5.2 エミュレータでの動作確認
# エミュレータを起動(DNS を指定しないと RUM エンドポイントに接続できない場合がある)
emulator -avd <AVD_NAME> -dns-server 8.8.8.8
# APK をインストール
adb install -r build/app/outputs/flutter-apk/app-debug.apk
# アプリを起動
adb shell am start -n <PACKAGE>/<PACKAGE>.MainActivity
5.3 ADOT Agent の初期化確認
logcat で ADOT Agent の初期化ログを確認します:
adb logcat | grep -i "OpenTelemetryRum"
以下のようなログが表示されれば、Agent は正常に初期化されています:
D OpenTelemetryRum: onAvailable: currentNetwork=CurrentNetwork(state=TRANSPORT_WIFI, ...)
D OpenTelemetryRum: Requested cache size: %s, folder size: 10000000
5.4 HTTP スパンの確認
OkHttp の自動計装が動作しているか確認します:
adb logcat | grep -i "HttpExporter"
HttpExporter のエラーが表示されなければ、スパンは正常に送信されています。エラーが表示される場合は、エミュレータのネットワーク接続を確認してください。
5.5 CloudWatch Logs での確認
RUM に送信されたスパンは CloudWatch Logs でも確認できます:
aws logs filter-log-events \
--log-group-name /aws/vendedlogs/RUMService_<APP_MONITOR_NAME><APP_MONITOR_ID_PREFIX> \
--filter-pattern "GET" \
--region <REGION> \
--log-stream-names /aws/rum/otel-spans
ByteBuddy 自動計装が生成したスパンは scope.name が io.opentelemetry.okhttp-3.0 になります:
{
"scope": { "name": "io.opentelemetry.okhttp-3.0" },
"name": "GET",
"kind": "CLIENT",
"attributes": {
"http.request.method": "GET",
"http.response.status_code": 200,
"url.full": "https://example.com/api/v1/data",
"server.address": "example.com",
"server.port": 443,
"network.peer.address": "1.2.3.4"
},
"status": { "code": "UNSET" }
}
6. CloudWatch RUM コンソールでの確認
6.1 Performance タブ
アプリ起動時間(Cold Start)、画面描画時間(Time to First Draw)が表示されます。
6.2 Errors タブ — HTTP requests
HTTP リクエストのレイテンシとエラー率が表示されます。4xx/5xx レスポンスは自動的に HTTP error として記録されます。
6.3 Errors タブ — Crashes
アプリクラッシュ(未処理例外)が記録されます。クラッシュデータはディスクバッファに保存され、次回起動時に送信されます。
6.4 Sessions タブ
ユーザーセッションの一覧が表示されます。各セッションの画面遷移、HTTP リクエスト、エラーを時系列で確認できます。
6.5 Trace Map
pictures-android → pictures-api-fargate-otel (ECS Fargate) のように、アプリからバックエンドへの接続が可視化されます。
7. Flutter 固有の課題と対策
7.1 画面名が MainActivity になる問題
Flutter アプリでは Android の Activity が MainActivity 1 つだけのため、ADOT の自動計装では全てのスパンの screen.name が MainActivity になります。
対策: OtelNavigatorObserver で Dart 側の画面遷移を検知し、PicturesApplication.updateScreenName() で VisibleScreenTracker の lastResumedActivity を Flutter の画面名(home, pictureDetail 等)に上書きします。
ただし、アプリ起動時の AppStart、Created、time_to_first_draw スパンは Flutter エンジン初期化前に生成されるため、MainActivity のままです。
7.2 HTTP リクエストが RUM に反映されない問題
Flutter の Dio は Dart VM 内の HTTP スタックを使用するため、ByteBuddy の OkHttp/HttpURLConnection 自動計装では捕捉できません。
カスタムスパンで http.request.method、server.address 等の属性を手動設定する方法も試みましたが、RUM コンソールの HTTP requests セクションや Trace Map には反映されませんでした。
対策: NativeHttpClientAdapter で Dio の HTTP リクエストを Platform Channel 経由でネイティブの OkHttp に中継します。OkHttp のリクエストは ByteBuddy が自動計装するため、RUM が認識する正規のスパンが生成されます。
7.3 エミュレータの DNS 解決失敗
Android エミュレータでは、dataplane.rum.<region>.amazonaws.com の DNS 解決に失敗することがあります。
対策: エミュレータ起動時に DNS サーバーを明示的に指定します:
emulator -avd <AVD_NAME> -dns-server 8.8.8.8
また、Private DNS が有効な場合は無効にします:
adb shell settings put global private_dns_mode off
8. 自動計測される項目
ADOT Android SDK が自動的に計測する項目です。Flutter 側の実装は不要です。
| 項目 | 説明 |
|---|---|
| アプリ起動 | Cold/Warm Start の検出と計測 |
| Activity ライフサイクル | Created, Started, Resumed 等のイベント |
| Time to First Draw | 画面の初回描画までの時間 |
| クラッシュ | 未処理例外の検出(次回起動時に送信) |
| ANR | Application Not Responding の検出 |
| Slow Rendering | フレームドロップの検出 |
| HTTP リクエスト | OkHttp/HttpURLConnection の自動計装(ByteBuddy) |
| セッション | ユーザーセッションの開始/終了 |
| デバイス情報 | モデル、OS バージョン、地域情報 |
9. iOS 版との違い
| 項目 | Android | iOS |
|---|---|---|
| SDK | ADOT Android SDK (agent:1.0.0) |
ADOT Swift SDK (aws-otel-swift) |
| パッケージ管理 | Gradle (Maven Central) | Swift Package Manager |
| 初期化 | ContentProvider で自動(Zero-Code) |
aws_config.json + SPM で自動 |
| HTTP 計装 | ByteBuddy で OkHttp を自動計装 | カスタムスパン(手動) |
| HTTP の RUM 反映 | ✅ HTTP requests / Trace Map に反映 | ⚠️ カスタムスパンのため一部制約あり |
| 画面名の上書き | リフレクションで VisibleScreenTracker を更新 |
AwsScreenManagerProvider の API を使用 |
| クラッシュ検出 | UncaughtExceptionHandler |
PLCrashReporter |
最大の違いは HTTP リクエストの計装方式です。Android では ByteBuddy による OkHttp 自動計装が利用できるため、NativeHttpClientAdapter で OkHttp に中継することで RUM の HTTP requests セクションと Trace Map に正しく反映されます。iOS ではこの方式が使えないため、カスタムスパンでの対応となります。そのため、iOSでは Trace Map を ECS まで繋げることができませんでした。
まとめ
Flutter Android アプリに CloudWatch RUM を導入するには、以下の 3 つのブリッジが必要です:
-
HTTP リクエスト:
NativeHttpClientAdapter→ MethodChannel → OkHttp → ByteBuddy 自動計装 -
画面遷移:
OtelNavigatorObserver→ MethodChannel →VisibleScreenTracker更新 - クラッシュ/ANR/起動時間: ADOT Agent が自動計測(追加実装不要)
これにより、CloudWatch RUM の Performance、Errors(HTTP requests / Crashes)、Sessions、Trace Map の全てのセクションで Flutter アプリのテレメトリデータを確認できるようになります。
変更ファイル一覧
| ファイル | 説明 |
|---|---|
android/settings.gradle.kts |
ByteBuddy プラグイン追加 |
android/app/build.gradle.kts |
ADOT SDK、OkHttp、ByteBuddy 依存関係追加、minSdk 26 |
android/app/src/main/res/raw/aws_config.json |
ADOT 設定ファイル(リージョン、App Monitor ID) |
android/app/src/main/AndroidManifest.xml |
Application クラスの登録 |
android/app/src/main/kotlin/.../YourApplication.kt |
VisibleScreenTracker のリフレクション更新 |
android/app/src/main/kotlin/.../MainActivity.kt |
OkHttp HTTP 中継、画面遷移通知の Platform Channel |
lib/core/network/native_http_client_adapter.dart |
OkHttp 中継用 Dio アダプター |
lib/core/network/dio_client.dart |
Android 時に NativeHttpClientAdapter を使用 |
lib/core/observability/otel_navigator_observer.dart |
画面遷移を通知する NavigatorObserver |
lib/routing/app_router.dart |
GoRouter に Observer を追加 |











