Flutter で作成した iOS アプリに Amazon CloudWatch RUM を導入する
Flutter で作成した iOS アプリに Amazon CloudWatch RUM(Real User Monitoring)を導入し、ユーザーの画面遷移、HTTP リクエスト、クラッシュ、セッションなどのテレメトリデータを収集する方法を解説します。
はじめに
CloudWatch RUM はウェブアプリケーション向けの機能として知られていますが、AWS Distro for OpenTelemetry(ADOT)Swift SDK を利用することで、iOS アプリでも RUM のテレメトリ収集が可能です。
ただし、Flutter アプリは UIKit のビュー階層を直接使わないため、ネイティブ SDK だけでは画面遷移や HTTP リクエストの計測ができません。本記事では FlutterMethodChannel を使ったブリッジを構築し、Dart 側のイベントをネイティブの OTel エージェントに伝える方法を紹介します。
本記事で実現すること
- CloudWatch RUM App Monitor の作成と設定
- ADOT Swift SDK(aws-otel-swift)の導入
- Flutter の画面遷移を RUM の Screens タブに反映
- Dio の HTTP リクエストを RUM の Network errors に反映
- セッション管理
- クラッシュレポートの収集
前提条件
- Flutter 3.x 以上
- iOS Deployment Target 16.0 以上
- Xcode 15 以上
- AWS アカウントと CloudWatch RUM へのアクセス権限
Android 版について
Android 版の導入手順は別記事「Flutter で作成した iOS と Android アプリに CloudWatch RUM を導入する ②」を参照してください。
アーキテクチャ
1. CloudWatch RUM App Monitor の作成
AWS マネジメントコンソールで CloudWatch RUM の App Monitor を作成します。
- CloudWatch コンソール → Application Signals → RUM → 「Add app monitor」
- 以下を設定:
-
App monitor name:
your-app-ios - Application type: iOS
- Active tracing: 有効(X-Ray 連携)
-
App monitor name:
作成後、App Monitor ID をメモしておきます。
2. リソースベースポリシーの設定
モバイルアプリからの未認証リクエストを許可するため、リソースベースポリシーを設定します。
aws rum put-resource-policy \
--name your-app-ios \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "rum:PutRumEvents",
"Resource": "arn:aws:rum:<REGION>:<ACCOUNT_ID>:appmonitor/your-app-ios",
"Principal": "*"
}
]
}' \
--region <REGION>
注意: 本番環境では IP アドレス制限などの条件を追加することを推奨します。
3. aws-otel-swift の追加(Swift Package Manager)
ADOT Swift SDK は CocoaPods に対応していないため、Xcode の Swift Package Manager(SPM)で追加します。
3.1 Xcode でパッケージを追加
-
ios/Runner.xcworkspaceを Xcode で開く - Runner プロジェクトを選択 → 「Package Dependencies」タブ
- 「+」ボタンをクリック
- URL に
https://github.com/aws-observability/aws-otel-swift.gitを入力 - Version rule: Up to Next Major Version
1.0.0 - AwsOpenTelemetryAgent プロダクトのみを Runner ターゲットに追加
重要: パッケージを複数回追加しないでください。重複すると「Multiple commands produce」ビルドエラーが発生します。
3.2 aws_config.json の作成
ios/Runner/aws_config.json を作成します:
{
"aws": {
"region": "<REGION>",
"rumAppMonitorId": "<YOUR_APP_MONITOR_ID>"
},
"otelResourceAttributes": {
"service.name": "your-app-ios",
"service.version": "1.0.0"
}
}
このファイルを Xcode の Runner グループにドラッグ&ドロップして、「Copy items if needed」 にチェックを入れてプロジェクトに追加します。ビルドフェーズの「Copy Bundle Resources」に含まれていることを確認してください。
4. AppDelegate の実装
ios/Runner/AppDelegate.swift を以下のように実装します。これが Flutter と OTel エージェントのブリッジの中核です。
import Flutter
import UIKit
import AwsOpenTelemetryAgent
import AwsOpenTelemetryCore
import OpenTelemetryApi
import OpenTelemetrySdk
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var currentViewSpan: Span?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let messenger = engineBridge.pluginRegistry.registrar(forPlugin: "OtelBridge")!.messenger()
// セッション開始イベントを送信(RUM Sessions タブに表示するため)
emitSessionStart()
// OTel MethodChannel の設定
let otelChannel = FlutterMethodChannel(name: "com.yourapp/otel", binaryMessenger: messenger)
otelChannel.setMethodCallHandler { [weak self] (call, result) in
guard let args = call.arguments as? [String: Any] else {
result(FlutterMethodNotImplemented)
return
}
switch call.method {
case "viewChanged":
if let name = args["name"] as? String { self?.trackView(name: name) }
result(nil)
case "httpRequest":
self?.trackHttp(args: args)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
// MARK: - 画面トラッキング
private func trackView(name: String) {
// AwsScreenManager を更新して RUM の Screens タブに反映
AwsScreenManagerProvider.getInstance().currentScreen = name
// 画面遷移スパンを作成
currentViewSpan?.end()
let tracer = OpenTelemetry.instance.tracerProvider.get(
instrumentationName: "flutter-views", instrumentationVersion: "1.0.0"
)
currentViewSpan = tracer.spanBuilder(spanName: name)
.setAttribute(key: "view.name", value: name)
.startSpan()
}
// MARK: - HTTP リクエストトラッキング
private func trackHttp(args: [String: Any]) {
let method = args["method"] as? String ?? "UNKNOWN"
let url = args["url"] as? String ?? ""
let statusCode = args["statusCode"] as? Int ?? 0
let startTime = args["startTime"] as? Int ?? 0
let endTime = args["endTime"] as? Int ?? 0
let error = args["error"] as? String
let parsed = URL(string: url)
let start = Date(timeIntervalSince1970: Double(startTime) / 1000.0)
let end = Date(timeIntervalSince1970: Double(endTime) / 1000.0)
let tracer = OpenTelemetry.instance.tracerProvider.get(
instrumentationName: "NSURLSession", instrumentationVersion: "1.0.0"
)
let span = tracer.spanBuilder(spanName: "HTTP \(method)")
.setStartTime(time: start)
.setSpanKind(spanKind: .client)
.setAttribute(key: "http.method", value: method)
.setAttribute(key: "http.request.method", value: method)
.setAttribute(key: "http.url", value: url)
.setAttribute(key: "url.full", value: url)
.setAttribute(key: "http.status_code", value: statusCode)
.setAttribute(key: "http.response.status_code", value: statusCode)
.setAttribute(key: "server.address", value: parsed?.host ?? "")
.setAttribute(key: "url.path", value: parsed?.path ?? "")
.setAttribute(key: "url.scheme", value: parsed?.scheme ?? "")
.setAttribute(key: "network.protocol.name", value: "http")
.startSpan()
if statusCode >= 400 {
span.status = .error(description: error ?? "HTTP \(statusCode)")
}
span.end(time: end)
}
// MARK: - セッション管理
private func emitSessionStart() {
let session = AwsSessionManagerProvider.getInstance().getSession()
let logger = OpenTelemetry.instance.loggerProvider.get(
instrumentationScopeName: "software.amazon.opentelemetry.session"
)
logger.logRecordBuilder()
.setEventName("session.start")
.setTimestamp(session.startTime)
.setAttributes(["session.id": .string(session.id)])
.emit()
}
}
ポイント解説
| メソッド | 役割 |
|---|---|
emitSessionStart() |
RUM の Sessions タブにセッションを表示するため、session.start ログイベントを送信 |
trackView(name:) |
AwsScreenManager の画面名を更新し、RUM の Screens タブに Flutter の画面名を反映 |
trackHttp(args:) |
HTTP リクエストを NSURLSession instrumentation 名の CLIENT スパンとして記録し、RUM の Network errors に反映 |
5. Dart 側の実装
5.1 画面遷移の通知(NavigatorObserver)
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});
}
}
GoRouter に Observer を追加します:
GoRouter(
initialLocation: '/',
observers: [OtelNavigatorObserver()],
routes: [ /* ... */ ],
);
5.2 HTTP リクエストの通知(Dio Interceptor)
lib/core/observability/otel_http_interceptor.dart を作成します:
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
class OtelHttpInterceptor extends Interceptor {
static const _channel = MethodChannel('com.yourapp/otel');
static final _random = Random.secure();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.extra['otel_start'] = DateTime.now().millisecondsSinceEpoch;
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_report(response.requestOptions, response.statusCode ?? 0, null);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
_report(err.requestOptions, err.response?.statusCode ?? 0, err.message);
handler.next(err);
}
void _report(RequestOptions options, int statusCode, String? error) {
_channel.invokeMethod('httpRequest', {
'method': options.method,
'url': options.uri.toString(),
'statusCode': statusCode,
'startTime': options.extra['otel_start'] ?? 0,
'endTime': DateTime.now().millisecondsSinceEpoch,
if (error != null) 'error': error,
});
}
}
Dio クライアントにインターセプターを追加します。必ず最初に追加してください:
_dio.interceptors.add(OtelHttpInterceptor()); // 最初に追加
_dio.interceptors.add(LoggingInterceptor());
_dio.interceptors.add(AuthInterceptor(tokenStorage));
_dio.interceptors.add(ErrorInterceptor(tokenStorage));
重要:
OtelHttpInterceptorを最初に追加しないと、他のインターセプターが先に実行され、ログにトレースヘッダーが表示されません。
6. ビルドと動作確認
6.1 ビルド
flutter clean
flutter pub get
flutter build ios --simulator
注意: Swift 6.2 以降を使用している場合、opentelemetry-swift の古いバージョンで型不一致エラーが発生することがあります。SPM のパッケージキャッシュを削除して最新バージョンで解決してください:
rm -rf ~/Library/Developer/Xcode/DerivedData/Runner-*/SourcePackages
6.2 シミュレータでの動作確認
# シミュレータにインストール
xcrun simctl install booted build/ios/iphonesimulator/Runner.app
# アプリを起動
xcrun simctl launch booted <BUNDLE_ID>
6.3 RUM への送信確認
シミュレータのログで RUM エンドポイントへの通信を確認します:
xcrun simctl spawn booted log show \
--predicate 'process == "Runner" AND message CONTAINS "summary for task"' \
--last 30s --style compact
response_status=200 が表示されれば、テレメトリデータが正常に送信されています。
7. CloudWatch RUM コンソールでの確認
7.1 Overview
RUM コンソールの Overview ページで、セッション数やエラー数が表示されていることを確認します。
7.2 Screens
Flutter の画面名(home, pictureDetail, signIn など)が個別に表示されます。
7.3 Network errors タブ
HTTP 4xx/5xx エラーが記録されます。
7.4 Sessions タブ
ユーザーセッションの一覧が表示されます。
8. Flutter 固有の課題と対策
8.1 画面名が FlutterViewController になる問題
Flutter アプリでは UIKit のビューコントローラーが FlutterViewController 1 つだけのため、ADOT Swift SDK の自動ビュー計測では全ての画面が FlutterViewController として記録されます。
対策: OtelNavigatorObserver で Dart 側の画面遷移を検知し、AwsScreenManagerProvider.getInstance().currentScreen を更新することで、RUM の aws:screen.name に Flutter の画面名を反映します。
8.2 HTTP リクエストが記録されない問題
Flutter の Dio は Dart の HTTP クライアントを使用するため、ネイティブの URLSessionInstrumentation では捕捉できません。
対策: OtelHttpInterceptor で Dio のリクエスト/レスポンスを MethodChannel 経由でネイティブ側に通知し、NSURLSession instrumentation 名の CLIENT スパンとして記録します。これにより RUM の Network errors タブに表示されます。
8.3 セッションが表示されない問題
ADOT Swift SDK の AwsSessionEventInstrumentation が session.start イベントを送信するタイミングの問題で、RUM の Sessions タブにセッションが表示されないことがあります。
対策: AppDelegate の didInitializeImplicitFlutterEngine で emitSessionStart() を呼び出し、手動で session.start ログイベントを送信します。
8.4 SPM パッケージの重複追加
Xcode で aws-otel-swift を複数回追加すると、gRPC/NIO/SwiftProtobuf 等のフレームワークが重複ビルドされ、「Multiple commands produce」エラーが発生します。
対策: パッケージは必ず 1 回だけ追加してください。問題が発生した場合は、project.pbxproj から重複した XCRemoteSwiftPackageReference と XCSwiftPackageProductDependency セクションを削除します。
9. 自動計測される項目
ADOT Swift SDK が自動的に計測する項目は以下の通りです。Flutter 側の実装は不要です。
| 項目 | 説明 |
|---|---|
| アプリ起動 | コールド/ウォームスタートの検出と計測 |
| クラッシュ | PLCrashReporter によるネイティブクラッシュの検出と次回起動時の送信 |
| ハング | メインスレッドのブロック検出 |
| デバイス情報 | デバイスモデル、OS バージョン、地域情報 |
| CPU/メモリ | プロセスの CPU 使用率とメモリ使用量 |
10. Trace Map
iOS アプリ(RUM)とバックエンド API(X-Ray)の Trace Map での接続は、現時点ではトレースが繋がりませんでした。RUM OTLP エンドポイントがトレース ID を独自に変換するため、iOS 側で生成したトレース ID と X-Ray 上のトレース ID が一致しないようです。
ただし、traceparent ヘッダーを API サーバーに送信することで、API 側のログでトレース ID を確認し、手動でトレースを追跡することは可能です。
まとめ
Flutter iOS アプリに CloudWatch RUM を導入するには、以下の 3 つのブリッジが必要です:
-
画面遷移:
NavigatorObserver→ MethodChannel →AwsScreenManager -
HTTP リクエスト: Dio
Interceptor→ MethodChannel → OTel CLIENT スパン -
セッション:
AppDelegateでsession.startイベントを手動送信
これにより、CloudWatch RUM の Screens、Network errors、Sessions、Errors の各タブで Flutter アプリのテレメトリデータを確認できるようになります。
実際に実装してみると、自動計装ではネイティブ対応ではない弊害が多くありました。Flutter への正式対応を期待したいですね。
次回の記事では、Android 版の CloudWatch RUM 導入について解説します。
変更ファイル一覧
| ファイル | 説明 |
|---|---|
ios/Runner/AppDelegate.swift |
OTel ブリッジの中核。MethodChannel ハンドラー、画面/HTTP/セッション計測 |
ios/Runner/aws_config.json |
ADOT Swift SDK の設定ファイル |
lib/core/observability/otel_navigator_observer.dart |
画面遷移を通知する NavigatorObserver |
lib/core/observability/otel_http_interceptor.dart |
HTTP リクエストを通知する Dio Interceptor |
lib/routing/app_router.dart |
GoRouter に Observer を追加 |
lib/core/network/dio_client.dart |
Dio に Interceptor を追加 |









