Firebase Performance Monitoring Perfect ガイド for Flutter

こんにちは。  
FlutterエンジニアのDiegoです。

今日は、[Firebase Performance Monitoring](https://firebase.google.com/products/performance?hl=ja)のFlutterでの設定方法（iOS、Android）と実際にどんな風に使用しているかを紹介します。  
完璧なガイドではないかもしれませんが、主な機能を網羅しつつ、使用例を示していく予定です。ぜひ、ご意見やご感想をコメントでお寄せください！

---
# はじめに
皆さん、　FirebaseのPerformance Monitoring使用されていますでしょうか？
Performance Monitoringを使用することで、ユーザが不満を覚えそうな部分をいち早く運営側が察知し、
アプリを安定的に運営することができます。

そんなとても便利な機能が無料で使えてしまいます。(Google様の太っ腹！)
また、すでにFirebaseを使用しているプロジェクトであれば導入も簡単となっています。

特に現在以下のような悩みを抱えている方、この記事を読んでPerformance Monitoringの導入をご検討ください！

- アプリの修正によってアプリの起動速度が遅くなってきたと感じる
- どこがなどはわからないけど、なんとなくアプリが重くなってきたと感じる
- アプリのCoreな機能の処理が実際のユーザにはどのくらいの処理速度で提供できているか知りたい
- アプリの運営とかしたことないから、やり方わからない


# Performance Monitoringの概要

![スクリーンショット 2024-09-14 16.52.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/2f3fa1c9-eac9-a13d-733d-292c19734391.png)

Firebase Performance Monitoringでは主に以下の3つのデータを収集し、アプリのパフォーマンスを計測します。

- **ネットワークリクエストの情報**  
  → 画像内の「ネットワークリクエスト」タブに記載
- **カスタムコードの実行情報**  
  → 画像内の「カスタムトレース」タブに記載
- **画面の読み込み情報**  
  → 「画面のレンダリング」タブに記載

> ※ 「画面の読み込み情報」はFlutterでは対象外です。


# ネットワーク リクエストの情報

Flutterでネットワークリクエストの情報を収集する方法およびそのデータを用いてどのように解析するかを説明します。

## 収集方法

- **iOS**：何もしなくても自動的に収集されます
- **Android**：自分で設定しないと収集されません

iOSでは自動取得されますが、自分で管理するようにすると便利です。私はDioのInterceptorに収集処理を追加しています。参考として、以下のコードをご覧ください。


<details><summary>performance_interceptor.dart</summary>

```dart
class PerformanceInterceptor extends Interceptor {
  late final FirebasePerformance _performance = FirebasePerformance.instance;

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    try {
      if (Firebase.apps.isEmpty) return handler.next(options);

      /// debugの時はmetricsを取得しない
      /// break pointを貼ると正確な時間などが取れないので
      // if (kDebugMode) return handler.next(options);

      /// metricsの作成
      final metric = _performance.newHttpMetric(
        // urlの設定
        options.uri.toString(),
        // methodの設定
        switch (options.method) {
          'GET' => HttpMethod.Get,
          'POST' => HttpMethod.Post,
          'PUT' => HttpMethod.Put,
          'DELETE' => HttpMethod.Delete,
          'PATCH' => HttpMethod.Patch,
          _ => HttpMethod.Get,
        },
      );

      // メトリックを開始
      await metric.start();
      // リクエストのサイズを取得し、メトリックに設定
      metric.requestPayloadSize = await _calculatePayloadSize(options.data);

      // メトリックをdioのextraプロパティに保存
      options.extra['metric'] = metric;
    } catch (e, s) {
      logger.e('metricsの設定に失敗しました。', error: e, stackTrace: s);
    }

    return handler.next(options);
  }

  @override
  Future<void> onResponse(
    Response response,
    ResponseInterceptorHandler handler,
  ) async {
    /// apiの処理を邪魔したくないので先にnextを呼ぶ
    handler.next(response);

    /// metricsの処理
    try {
      if (Firebase.apps.isEmpty) return;

      // extraプロパティからメトリックを取得
      final metric = response.requestOptions.extra['metric'] as HttpMetric?;
      if (metric == null) return;

      // メトリックにレスポンスのサイズとステータスコードを設定
      metric
        ..responsePayloadSize = await _calculatePayloadSize(response.data)
        ..httpResponseCode = response.statusCode ?? 0;

      // メトリックを停止
      await metric.stop();
    } catch (e, s) {
      logger.e('metricsの解析に失敗しました。', error: e, stackTrace: s);
    }
  }

  @override
  Future<void> onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    /// apiの処理を邪魔したくないので先にnextを呼ぶ
    handler.next(err);

    /// metricsの処理
    try {
      if (Firebase.apps.isEmpty) return;

      // extraプロパティからメトリックを取得
      final metric = err.requestOptions.extra['metric'] as HttpMetric?;
      if (metric == null) return;

      // メトリックにレスポンスのサイズとステータスコードを設定
      metric
        ..responsePayloadSize = await _calculatePayloadSize(err.response?.data)
        ..httpResponseCode = err.response?.statusCode ?? 0;
      
      // メトリックを停止
      await metric.stop();
    } catch (e, s) {
      logger.e('metricsの取得に失敗しました。', error: e, stackTrace: s);
    }
  }

  /// ペイロードのサイズを計算する
  Future<int> _calculatePayloadSize(dynamic data) async {
    return compute((data) => utf8.encode(jsonEncode(data)).length, data);
  }
}


```
</details>


## 解析方法

上記のデータ収集を行うと、次のようにFirebase Performance Monitoring上に表示されます。

![スクリーンショット 2024-09-14 14.59.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/c09daa87-c013-18d6-9e27-2f7e13c2084a.png)

ネットワークリクエストの詳細画面では、応答時間、成功率、レスポンスペイロードサイズ、リクエストペイロードサイズの統計情報が表示されます。

![スクリーンショット 2024-09-14 15.06.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/8d366190-3435-7247-36c3-dfe124b156f9.png)


また、カスタムURLパターンを作成することで、特定のリクエストパターンに基づいた統計データを集計することができます。

![スクリーンショット 2024-09-14 15.01.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/a633b97c-116b-42f3-b6ba-51ccd9bd408a.png)


さらに、収集されたネットワークリクエストごとにセッションの詳細を確認することもできます。このセッション詳細には、端末の状態、CPU使用率、同時に行われた他のネットワークリクエスト、カスタムトレースの情報が含まれます。

![firebase_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/84a4aefc-3ed0-89a2-8943-138ca09b3000.png)



# カスタムコードの実行情報

カスタムコードの実行情報とは、アプリ独自の処理でパフォーマンスを収集したい場合に使用します。たとえば、リモートAPIからデータを取得し、ローカルのデータベース（例: `SharedPreferences`）に保存する処理を考えてみましょう。


```dart
@riverpod
PostRepository postRepository(ProviderRef ref) {
  return PostRepository(ref);
}

class PostRepository {
  PostRepository(this.ref);
  final ProviderRef ref;

  /// fetchしてlocal databaseに保存する
  void fetchAndSave(String postId) async {
    // apiリクエストの実行
    final httpClient = ref.read(httpClientProvider);
    final response = await httpClient
        .get('https://jsonplaceholder.typicode.com/posts/${postId}');

    // local　databaseに保存する
    final sharedPreferences = await ref.read(sharedPreferencesProvider.future);
    await sharedPreferences.setString(
        "post:${postId}", response.data.toString());
  }
}

```

この一連の流れで以下のことを知りたいとします。
- 一連の流れでどのくらい時間が掛かっているのか？
- APIリクエストの処理だけでどのくらい時間が掛かっていて、localに保存するのにどれくらい時間が掛かっているのか？

Firebase Performance Monitoringで収集する方法を記載します。

## 収集方法
Firebase Performance Monitoringではtraceというものでカスタムコードの処理を記録します。


```dart

  void fetchAndSave(String postId) async {
    // カスタムトレースの作成
    final trace =
        FirebasePerformance.instance.newTrace("PostRepository.fetchAndSave");

    // 時刻を記録
    DateTime tempTime = DateTime.now();
    // traceの計測開始
    unawaited(trace.start());

    try {
      // apiリクエストの実行
      final httpClient = ref.read(httpClientProvider);
      final response = await httpClient
          .get('https://jsonplaceholder.typicode.com/posts/${postId}');

      // apiリクエストの処理終了時刻を記録
      trace.setMetric(
          "api request", DateTime.now().difference(tempTime).inMilliseconds);
      tempTime = DateTime.now();

      // local　databaseに保存する
      final sharedPreferences =
          await ref.read(sharedPreferencesProvider.future);
      await sharedPreferences.setString(
          "post:${postId}", response.data.toString());
      // local databaseの処理終了時刻を記録
      trace.setMetric("save local database",
          DateTime.now().difference(tempTime).inMilliseconds);
      trace.putAttribute("success", "true");
    } catch (e) {
      trace.putAttribute("success", "false");
    } finally {
      unawaited(trace.stop());
    }
```

`trace.setMetric`でメトリックを記録します。
カスタムトレースのメトリックについては、実際の統計情報を見ながら記載します。

また、`trace.putAttribute`でこのカスタムトレースに付加情報を追加します。attributeについては次のようなものを入れると良いと思います。

- 一連の流れが成功した時のtraceなのか、失敗した時のtraceなのか？
    - 私は`success`keyで登録しています
- 仮にcacheを使っている場合、cacheのヒットが合ったものなのか？cacheが無くてAPIリクエストを行ったのか？
    - `cache` keyなどで登録すると良いかもです

:::note info
trace.start,trace.stopは非同期の処理になっていますが、
終了を待つ必要がないので、unawaited()で囲んであげます！
:::


上記で、カスタムコードのtraceの集計は終わりなのですが、少しリファクタします。
traceの集計は処理に影響しません。ですが、このtraceを入れることでコード量が増えてしまっているのが気になります。なので、以下のように`traceWrapper`を用意して簡単に導入できるようにします。

```dart
  /// 関数を包みその関数の実行時間を計測する。
  Future<T> traceWrapper<T>(
    String functionName,
    Future<T> Function(Trace? trace, void Function(String name) recordTime)
        function,
  ) async {
    final trace = Firebase.apps.isEmpty
        ? null
        : FirebasePerformance.instance.newTrace(functionName);
    final startTime = DateTime.now();
    unawaited(trace?.start());

    var tempTime = 0;

    T result;
    try {
      // 関数を実行して結果を取得
      result = await function(
        trace,
        (name) {
          final time = DateTime.now().difference(startTime).inMilliseconds;
          final diff = time - tempTime;
          tempTime = time;
          trace?.setMetric(name, diff);
        },
      );
      trace?.putAttribute('success', 'true');
    } catch (e) {
      trace?.putAttribute('success', 'false');
      rethrow;
    } finally {
      // traceを終了
      unawaited(trace?.stop());
    }

    return result; // 関数の戻り値をそのまま返す
  }
```

`traceWrapper`を以下のように使用します。

```dart
  void fetchAndSave(String postId) async {
    traceWrapper("PostRepository.fetchAndSave",
        (trace, recordTime) async {
      // apiリクエストの実行
      final httpClient = ref.read(httpClientProvider);
      final response =
          await httpClient.get('https://jsonplaceholder.typicode.com/posts/1');
      recordTime("api request");

      // local　databaseに保存する
      final sharedPreferences =
          await ref.read(sharedPreferencesProvider.future);
      await sharedPreferences.setString("post:${postId}", response.data.toString());
      recordTime("save local database");
    });
  }
```

リファクタのおかげで、スッキリしました！
これで必要最低限のコードで計測を簡単にできるようにしましょう！

## 解析方法
カスタムトレースはコンソール上で次のように表されます。
![スクリーンショット 2024-09-14 16.05.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/f207195a-cce5-d78c-395d-14db5d5f5244.png)

:::note info
_app_startは自動で計測されるカスタムトレースです！
:::

カスタムトレースの詳細に入ると次のようになります。
![スクリーンショット 2024-09-14 16.08.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/f1b4e613-f0ad-8118-47a7-5a6695dc4b4c.png)

ここで、上のタブで「期間」,「api request」,「save local database」のところを見てください。ここにmetricが表示されます。
- 期間は自動で集計されるmetricです。`trace.start`から`trace.stop`までの時間が表示されます
- 「api request」,「save local database」は`setMetric`で設定した独自のmetricです
    - metricの横に「6103」などの数字がありますが、こちらはsetMetricで登録したvalueの値の統計データです

:::note info
今回はmsでvalueを登録しているので、api requestは6s程かかっているみたいです。
save local databaseは36なので、36msで終了しているようです。
やはり、apiと比べると雲泥の差がありますね。
:::


カスタムトレースのfilterを開いてみると以下のようになっています。

![スクリーンショット 2024-09-14 16.19.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/85bdfc09-461b-d455-6306-647691fde48b.png)

ここで「success」のfilterがあると思います。
これは`putAttribute`で設定したkeyが表示され、そのvalueでfilterをかけることができます。
これによって、成功した処理だけの統計データを見ることができます。
Attributeは特定の条件だけ集めて統計データを解析することができますので、そのような用途で使用しましょう！


また、ネットワークリクエスト同様に、統計データからsamplingしたイベントの詳細を確認することができます。

![スクリーンショット 2024-09-14 16.25.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/12f8e510-7c62-e3aa-5320-1936dba7d4de.png)


# アラートの設定

Firebase Performance Monitoringでは、各指標で特定の条件を下回った際に、通知する機能があります。
これは異常を検知するための仕組みとして用意されています。積極的に設定して異常があった際に通知がきて対応できるようにしておきましょう。

使用用途としては次のものになるかなと思います。
- ネットワークリクエストが異常に遅くなっている場合、アラートを出す
- ネットワークリクエストの成功率が特定の%を下回った場合、アラートを出す
- カスタムトレースで特定の処理時間が異常に遅くなっている場合、アラートを出す
    - まだmetricの値に対してのアラートを設定することはできません。今後のupdateに期待？？

![スクリーンショット 2024-09-14 16.38.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/77429700-4206-e6ba-1b72-923d7df3fcb7.png)

![スクリーンショット 2024-09-14 16.41.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/3d25f5db-d404-83b8-ad4c-6c451dc4ae0a.png)

# ダッシュボードの設定

ダッシュボードに自分たちが気にしている指標を設定することで、一目で現在の状況を知ることができるようになります。

![スクリーンショット 2024-09-14 16.47.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/951917/9f745a80-aa95-6a8f-fb1b-427c1ca6f36c.png)

# 注意事項！

Flutterのdebugモード(kDebugModeがtrue)で動作させる場合は、ネットワークリクエストおよびカスタムトレースを記録しないようにしてください。

breakポイントで処理が止まった場合、正確な値が記録された内容になってしまいます。

# 最後に

この記事を読んで、良くわからないけどとりあえず入れてみるでも
損はないと思います！ぜひPerformance Monitoringの導入を検討してみてください。