1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter で作成した iOS と Android アプリに CloudWatch RUM を導入する ①

1
Last updated at Posted at 2026-02-22

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 を導入する ②」を参照してください。

アーキテクチャ

architecture.png

1. CloudWatch RUM App Monitor の作成

AWS マネジメントコンソールで CloudWatch RUM の App Monitor を作成します。

  1. CloudWatch コンソール → Application Signals → RUM → 「Add app monitor」
  2. 以下を設定:
    • App monitor name: your-app-ios
    • Application type: iOS
    • Active tracing: 有効(X-Ray 連携)

Screenshot 2026-02-23 at 8.19.17.png

作成後、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 でパッケージを追加

  1. ios/Runner.xcworkspace を Xcode で開く
  2. Runner プロジェクトを選択 → 「Package Dependencies」タブ
  3. 「+」ボタンをクリック
  4. URL に https://github.com/aws-observability/aws-otel-swift.git を入力
  5. Version rule: Up to Next Major Version 1.0.0
  6. AwsOpenTelemetryAgent プロダクトのみを Runner ターゲットに追加

Screenshot 2026-02-23 at 8.23.31.png

重要: パッケージを複数回追加しないでください。重複すると「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」に含まれていることを確認してください。

Screenshot 2026-02-23 at 8.24.16.png

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 が表示されれば、テレメトリデータが正常に送信されています。

Screenshot 2026-02-23 at 8.27.04.png

7. CloudWatch RUM コンソールでの確認

7.1 Overview

RUM コンソールの Overview ページで、セッション数やエラー数が表示されていることを確認します。

Screenshot 2026-02-23 at 8.29.54.png

7.2 Screens

Flutter の画面名(home, pictureDetail, signIn など)が個別に表示されます。

Screenshot 2026-02-23 at 8.31.17.png

7.3 Network errors タブ

HTTP 4xx/5xx エラーが記録されます。

Screenshot 2026-02-23 at 8.31.52.png

7.4 Sessions タブ

ユーザーセッションの一覧が表示されます。

Screenshot 2026-02-23 at 8.32.07.png

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 の AwsSessionEventInstrumentationsession.start イベントを送信するタイミングの問題で、RUM の Sessions タブにセッションが表示されないことがあります。

対策: AppDelegatedidInitializeImplicitFlutterEngineemitSessionStart() を呼び出し、手動で session.start ログイベントを送信します。

8.4 SPM パッケージの重複追加

Xcode で aws-otel-swift を複数回追加すると、gRPC/NIO/SwiftProtobuf 等のフレームワークが重複ビルドされ、「Multiple commands produce」エラーが発生します。

対策: パッケージは必ず 1 回だけ追加してください。問題が発生した場合は、project.pbxproj から重複した XCRemoteSwiftPackageReferenceXCSwiftPackageProductDependency セクションを削除します。

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 を確認し、手動でトレースを追跡することは可能です。

Screenshot 2026-02-23 at 8.13.22.png

まとめ

Flutter iOS アプリに CloudWatch RUM を導入するには、以下の 3 つのブリッジが必要です:

  1. 画面遷移: NavigatorObserver → MethodChannel → AwsScreenManager
  2. HTTP リクエスト: Dio Interceptor → MethodChannel → OTel CLIENT スパン
  3. セッション: AppDelegatesession.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 を追加
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?