🦞

Flutter 私の考えた最強のlogger (Firebase Crashlytics , Analytics連携編)

2024/06/07に公開

初めまして!
viviONでFlutterのアプリのエンジニアやっているDiegoと申します。
私の考えたloggerの設定について皆さんに共有して、是非をお伺いさせていただこうかと考えています。
一読していただき、ぜひコメントなどでFBいただけると幸いです!

Flutter 私の考えた最強のlogger

皆さんFlutterアプリ開発時にlogの出力など行っていますでしょうか?

  • 値を確認したい時はdebug機能で止めるからいらない!
  • printなら出しているよ!
  • log出力のpackage使って出してるよー

上記のような色々な方がいらっしゃると思いますが、
私は開発時に思いもよらない挙動などしていないかを確かめるためにpackageのloggerを使用して、logの出力をしております。
loggerではlogのレベルによって色を分けてくれるので、開発していてみやすくて助かってます!

そんなloggerですが、気に入らないところが開発時の使用に留まっているところでした。
特に以下のような思いを感じています。

  • logレベルerrorで出したのに、開発中にしか確認できない。バグトラッキングのサービスに出力したい
  • バグトラッキングの際に、開発中みたいにconsoleに出力している値とか見られたら便利なのに
  • logなのに、Analytics logなどの行動logとは繋がっておらず、ユーザの行動logは別途とっている

上記の問題を解決するためにloggerを拡張し、私が考える最強のloggerを作成しました!
以下最強のloggerの構成図です。

image.png

実装の説明

今回Firebase CrashlyticsとFirebase Analytics logと連携していますが、
他のサービスでも大体同じように設定できるのではと思っています。

以下全コードを最初に貼っておきます。

<details><summary>logger.dart</summary>

import 'dart:developer' as dev;

import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:logger/logger.dart';

final logger = Logger(
  output: CrashlyticsOutput(),
  printer: PrettyPrinter(
    methodCount: 1, // 表示されるコールスタックの数
    errorMethodCount: 5, // 表示されるスタックトレースのコールスタックの数
    lineLength: 80, // 区切りラインの長さ
    printTime: true, // タイムスタンプを出力するかどうか
  ),
);

class CrashlyticsOutput extends LogOutput {
  
  void output(OutputEvent event) {
    // log出します。
    for (final e in event.lines) {
      dev.log(e, name: 'logger');
    }
    _sendCrashlytics(event);
  }

  /// FirebaseCrashlyticsにログを送信します。
  Future<void> _sendCrashlytics(OutputEvent event) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;

      if (event.level.isSerious(Level.error)) {
        await FirebaseCrashlytics.instance.recordError(
          event.origin.error,
          event.origin.stackTrace,
          reason: event.origin.message,
          printDetails: false,
          fatal: event.level == Level.fatal,
        );
      } else if (event.level.isSerious(Level.info)) {
        await FirebaseCrashlytics.instance.log(event.origin.message.toString());
      }
    } catch (e) {
      dev.log('logの出力エラー', name: 'logger');
    }
  }
}

extension LevelExt on Level {
  bool isSerious(Level level) {
    return value >= level.value;
  }
}

extension AnalyticsLogExt on Logger {
  /// FirebaseAnalyticsにログを送信します。
  Future<void> logEvent({
    required String name,
    Map<String, Object?>? parameters,
  }) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      // debug log出します。
      d(name, error: parameters);
      await FirebaseAnalytics.instance
          .logEvent(name: name, parameters: parameters);
    } catch (e, s) {
      logger.e('logの出力エラー', error: e, stackTrace: s);
    }
  }

  Future<void> setUserId(String? id) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      // FirebaseAnalyticsにuserIdを設定する
      await FirebaseAnalytics.instance.setUserId(id: id);
      // FirebaseCrashlyticsにもuserIdを設定する
      await FirebaseCrashlytics.instance.setUserIdentifier(id ?? '');
    } catch (e, s) {
      logger.e('setUserIdの実行に失敗しました。', error: e, stackTrace: s);
    }
  }

  Future<void> setUserProperty({
    required String name,
    required String? value,
  }) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      await FirebaseAnalytics.instance
          .setUserProperty(name: name, value: value);
      await FirebaseCrashlytics.instance.setCustomKey(name, value ?? '');
    } catch (e, s) {
      logger.e('setUserPropertyの実行に失敗しました。', error: e, stackTrace: s);
    }
  }
}

</details>

前提条件

  • Flutterのセットアップが完了していること
  • FlutterでのFirebaseのセットアップが完了していること
  • loggerのpackageがインストールされていること

Firebase Crashlyticsの初期設定

初っ端loggerとは関係ないですが、迷いどころのCrashlyticsの初期設定から話をさせてください。

初期設定については、公式が出しているdocumentを参照してください。FlutterFireのdocumentはあまり参考にならないので、Firebaseのdocumentを見ることをおすすめします。

注意点としては、この時設定しているのはアプリがクラッシュするようなエラーを記録しているのみになります。
私たち開発者が対処すべきエラーはアプリがクラッシュするものだけではないので、改めて別途設定する必要があります。

Crashlyticsで手動でエラーを送信する

Crashlyticsの初期設定では、致命的なエラー(クラッシュ)をキャッチすることがメインとなります。
下記の場合などはエラーとして記録されません。

  • アプリがクラッシュはしないが開発者が想定できてないエラー
  • エラーハンドリングでユーザには通知したけどクラッシュ相当として扱いたいエラー

まとめて端的に言うと「エラーとしてcatchしたけど、アプリとしては機能してないので開発者が対処すべきエラー
」については手動で送信する必要があります。

例えば、「FutureProviderを使用して、APIを実行し、server errorで返却があったが、AsyncValue.whenでエラーのUIを表示した。」などの場合は、Crashlytics的にはアプリがクラッシュしたわけではないので、初期設定ではエラーを拾えないため、自分でエラーを送信する必要があります。

try {
  // 例外が発生する可能性のあるコード
} catch (e, stackTrace) {
  FirebaseCrashlytics.instance.recordError(e, stackTrace, reason: 'エラーが発生しました');
}

このようにすることで、Crashlyticsのダッシュボードで確認することができます。

手動エラーをloggerと統合し送信する

バックエンドのコードではこのlogの出力によって、エラーを検知することが多いですが、Flutterもそのようにできると大変便利です。私は以下のコードのように記載してCrashlyticsと連携させています。

<details><summary>logger.dart</summary>

import 'dart:developer' as dev;

import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:logger/logger.dart';

final logger = Logger(
  output: CrashlyticsOutput(),
  printer: PrettyPrinter(
    methodCount: 1, // 表示されるコールスタックの数
    errorMethodCount: 5, // 表示されるスタックトレースのコールスタックの数
    lineLength: 80, // 区切りラインの長さ
    printTime: true, // タイムスタンプを出力するかどうか
  ),
);

class CrashlyticsOutput extends LogOutput {
  
  void output(OutputEvent event) {
    // log出します。
    for (final e in event.lines) {
      dev.log(e, name: 'logger');
    }
    _sendCrashlytics(event);
  }

  /// FirebaseCrashlyticsにログを送信します。
  Future<void> _sendCrashlytics(OutputEvent event) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;

      if (event.level.isSerious(Level.error)) {
        await FirebaseCrashlytics.instance.recordError(
          event.origin.error,
          event.origin.stackTrace,
          reason: event.origin.message,
          printDetails: false,
          fatal: event.level == Level.fatal,
        );
      } else if (event.level.isSerious(Level.info)) {
        await FirebaseCrashlytics.instance.log(event.origin.message.toString());
      }
    } catch (e) {
      dev.log('logの出力エラー', name: 'logger');
    }
  }
}

extension LevelExt on Level {
  bool isSerious(Level level) {
    return value >= level.value;
  }
}

</details>

以下で少し解説します。

loggerの作成

以下はloggerをインスタンス化させるコードです。アプリ内ではこのloggerを使用して、logの出力を行います。特殊な部分はoutputにCrashlyticsOutputを使用しているところです。outputは実際にlogをコンソールに出したりする処理を担っています。

final logger = Logger(
  output: CrashlyticsOutput(),
  printer: PrettyPrinter(
    methodCount: 1, // 表示されるコールスタックの数
    errorMethodCount: 5, // 表示されるスタックトレースのコールスタックの数
    lineLength: 80, // 区切りラインの長さ
    printTime: true, // タイムスタンプを出力するかどうか
  ),
);

CrashlyticsOutputクラス

このクラスはloggerがlogを出力する際に、どこにどのように出力するかを定義することができます。このlogを出力する先をCrashlyticsに設定させます。


void output(OutputEvent event) {
  // log出します。
  for (final e in event.lines) {
    dev.log(e, name: 'logger');
  }
  _sendCrashlytics(event);
}

一旦consoleにlogを出力させます。その後、Crashlyticsにeventを送信するように_sendCrashlyticsにeventを渡します。

/// FirebaseCrashlyticsにログを送信します。
Future<void> _sendCrashlytics(OutputEvent event) async {
  try {
    // 初期化されていない場合は終了
    if (Firebase.apps.isEmpty) return;

    if (event.level.isSerious(Level.error)) {
      await FirebaseCrashlytics.instance.recordError(
        event.origin.error,
        event.origin.stackTrace,
        reason: event.origin.message,
        printDetails: false,
        fatal: event.level == Level.fatal,
      );
    } else if (event.level.isSerious(Level.info)) {
      await FirebaseCrashlytics.instance.log(event.origin.message.toString());
    }
  } catch (e) {
    dev.log('logの出力エラー', name: 'logger');
  }
}

logのlevelがerrorより上の場合、recordErrorを使用し手動エラーを送信します。
logのlevelがfatalの場合は致命的なエラーとして記録します。
このようにlogのレベルによって、出力先を変更しているのでアプリ内でlogを出力する際はレベルについて再度チーム内で確認するようにしてください!

logのlevelがinfo, warning (debugを入れても良いかも)の場合は、Crashlyticsのlogとして記録します。このCrashlyticsのlogはエラーがDashboradでエラーの直前にど
のような状況だったかを知る手掛かりになります。以下の画像のようにDashboradのエラー詳細に「ログとパンくずリスト」という項目で表示されます。

後述しますが、Analytics logもここでは表示されます。

スクリーンショット 2024-06-02 22.42.23.png

unitテストでloggerを使用するための設定

最強のloggerと自負して出てきて、テストで使用できないなんてことなら私なら使う気がしません!
そんな私の同志のテストガチ勢の皆様にも、この最強のloggerはお勧めします。
(unit testぐらいでガチ勢名乗るな勢の人はごめんなさい。)

このloggerはconsoleに出力するだけでなく、Crashlyticsと連携しているので外の世界と関わりを持っています。
この時unit testなどで、このloggerを内部で使っているロジックやWidgetをテストしたら
Firebaseを初期化してないとエラーが発生してテストは失敗します。

loggerを入れたばっかりにテストが通らなくなってしまったとならないように、以下の対応を入れてます。

 if (Firebase.apps.isEmpty) return;

このように記述すると、Firebaseが初期化されていない場合はFirebaseと連携している部分のコードの実行をしないのでunit test内部でも使用することができます。

loggerとAnalyticsの統合

ユーザの行動ログを記録するのがFirebase Analyticsの責務ですが、
単純に考えて、ユーザの行動ログってlogですよね?と私はそう思ってます。
なので、loggerの拡張としてAnalyticsに送信する機能を作りました。

また、Crashlyticsでは、Firebase Analyticsと統合して、Crashlyticsのエラーの解析を手助けしてくれますので、一石二鳥です!

スクリーンショット 2024-06-02 22.42.23.png

この場合、screen_viewなどがanalyticsで送信したlogになります。エラーの状態を把握するという文脈でも、積極的にAnalyticsでlogを送信していきましょう。

<details><summary>loggerとanalyticsの統合のコード</summary>

extension AnalyticsLogExt on Logger {
  /// FirebaseAnalyticsにログを送信します。
  Future<void> logEvent({
    required String name,
    Map<String, Object?>? parameters,
  }) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      // debug log出します。
      d(name, error: parameters);
      await FirebaseAnalytics.instance
          .logEvent(name: name, parameters: parameters);
    } catch (e, s) {
      logger.e('logの出力エラー', error: e, stackTrace: s);
    }
  }

  Future<void> setUserId(String? id) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      // FirebaseAnalyticsにuserIdを設定する
      await FirebaseAnalytics.instance.setUserId(id: id);
      // FirebaseCrashlyticsにもuserIdを設定する
      await FirebaseCrashlytics.instance.setUserIdentifier(id ?? '');
    } catch (e, s) {
      logger.e('setUserIdの実行に失敗しました。', error: e, stackTrace: s);
    }
  }

  Future<void> setUserProperty({
    required String name,
    required String? value,
  }) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      await FirebaseAnalytics.instance
          .setUserProperty(name: name, value: value);
      await FirebaseCrashlytics.instance.setCustomKey(name, value ?? '');
    } catch (e, s) {
      logger.e('setUserPropertyの実行に失敗しました。', error: e, stackTrace: s);
    }
  }
}

</details>

以下、解説します。

logger.logEventの作成

/// FirebaseAnalyticsにログを送信します。
Future<void> logEvent({
  required String name,
  Map<String, Object?>? parameters,
}) async {
  try {
    // 初期化されていない場合は終了
    if (Firebase.apps.isEmpty) return;
    // debug log出します。
    d(name, error: parameters);
    await FirebaseAnalytics.instance
        .logEvent(name: name, parameters: parameters);
  } catch (e, s) {
    logger.e('logの出力エラー', error: e, stackTrace: s);
  }
}

単純にlogger.logEvent()を実行することによってFirebaseAnalyticsでログを送信するように記載しております。

AnalyticsとCrashlyticsのユーザ情報の設定

Future<void> setUserId(String? id) async {
  try {
    // 初期化されていない場合は終了
    if (Firebase.apps.isEmpty) return;
    // FirebaseAnalyticsにuserIdを設定する
    await FirebaseAnalytics.instance.setUserId(id: id);
    // FirebaseCrashlyticsにもuserIdを設定する
    await FirebaseCrashlytics.instance.setUserIdentifier(id ?? '');
  } catch (e, s) {
    logger.e('setUserIdの実行に失敗しました。', error: e, stackTrace: s);
  }
}

Future<void> setUserProperty({
  required String name,
  required String? value,
}) async {
  try {
    // 初期化されていない場合は終了
    if (Firebase.apps.isEmpty) return;
    await FirebaseAnalytics.instance
        .setUserProperty(name: name, value: value);
    await FirebaseCrashlytics.instance.setCustomKey(name, value ?? '');
  } catch (e, s) {
    logger.e('setUserPropertyの実行に失敗しました。', error: e, stackTrace: s);
  }
}

上記は少し特殊になっていて、Analyticsのuser設定とCrashlyticsのuser設定を同時に行っています。これでユーザー情報を個別に設定する必要がなく、便利だなと思っています。

最後に

以上で、私の考えた最強のloggerについて解説しました。
ご指摘ご質問、アドバイスなどあればどしどしコメントいただければと思います。

一応ですが、vivionでは一緒に働くメンバー募集中です。
Flutterのエンジニアも募集しています。メンバー増えると嬉しいです。
興味ある方は以下よりぜひ!
https://hrmos.co/pages/vivion/jobs/0000428

Discussion