🦞

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と連携していますが、
他のサービスでも大体同じように設定できるのではと思っています。

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

import 'dart:async';
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:firebase_performance/firebase_performance.dart';
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';

final logger = CustomLogger();

/// ログ出力をカスタマイズします。
///
/// t,dはconsoleに出力します。
/// i,wはFirebaseAnalyticsにログを送信します。
/// (wは集計して、どのユーザが遭遇しているかをみたい。)
/// e,fはFirebaseCrashlyticsにログを送信します。
class CustomLogger extends Logger {
  CustomLogger()
      : super(
          output: CustomOutput(),
          printer: PrettyPrinter(
            methodCount: 1, // 表示されるコールスタックの数
            errorMethodCount: 5, // 表示されるスタックトレースのコールスタックの数
            lineLength: 80, // 区切りラインの長さ
            // タイムスタンプを出力するかどうか
            dateTimeFormat: DateTimeFormat.dateAndTime,
          ),
          level: kReleaseMode ? Level.info : Level.debug,
        );

  /// FirebaseAnalyticsにログを送信します。
  Future<void> _sendLog(
    String name,
    Map<String, String>? parameters,
  ) async {
    // 初期化されていない場合は終了
    if (Firebase.apps.isEmpty) return;

    try {
      return FirebaseAnalytics.instance
          .logEvent(name: name, parameters: parameters);
    } catch (e, s) {
      super.e('logの出力エラー', error: e, stackTrace: s);
    }
  }

  /// log出力とFirebaseAnalyticsにログを送信します。
  void info({
    required String name,
    Map<String, Object?>? parameters,
  }) {
    final logParameters = parameters?.map(
      (key, value) => MapEntry(
        key,
        value.toString().adjustAnalyticsParameter,
      ),
    );

    // log出します。
    // ignore: deprecated_member_use_from_same_package
    i(
      '$name: $logParameters',
    );
    _sendLog(name, logParameters);
  }

  /// warning出力とFirebaseAnalyticsにログを送信します。
  void warning({
    required String name,
    Object? error,
    StackTrace? stackTrace,
    Map<String, dynamic>? parameters,
  }) {
    final logParameters = {
      'error': error,
      'stackTrace': stackTrace,
      'level': 'warning',
      ...parameters ?? {},
    }.map(
      (key, value) => MapEntry(
        key,
        value.toString().adjustAnalyticsParameter,
      ),
    );
    // log出します。
    // ignore: deprecated_member_use_from_same_package
    w(
      name,
      error: error,
      stackTrace: stackTrace,
    );
    _sendLog(name, logParameters);
  }

  /// iを使用しないように警告を出します。
  ('infoを使用してください。')
  
  void i(
    dynamic message, {
    DateTime? time,
    Object? error,
    StackTrace? stackTrace,
  }) {
    super.i(message, error: error, time: time, stackTrace: stackTrace);
  }

  /// wを使用しないように警告を出します。
  ('warningを使用してください。')
  
  void w(
    dynamic message, {
    DateTime? time,
    Object? error,
    StackTrace? stackTrace,
  }) {
    super.w(message, error: error, time: time, stackTrace: stackTrace);
  }

  /// FirebaseCrashlyticsにerrorを記録する。
  Future<void> _recordError(
    dynamic message, {
    required Level level,
    Object? error,
    StackTrace? stackTrace,
    List<Object?>? information,
  }) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      // リリースモード以外の場合は終了
      if (!kReleaseMode) return;
      await FirebaseCrashlytics.instance.recordError(
        error,
        stackTrace,
        reason: message,
        printDetails: false,
        fatal: level == Level.fatal,
        information: information != null
            ? information.where((e) => e != null).toList().cast<Object>()
            : [],
      );
    } catch (e) {
      dev.log('logの出力エラー', name: 'logger');
    }
  }

  
  void e(
    dynamic message, {
    DateTime? time,
    Object? error,
    StackTrace? stackTrace,
    List<Object?>? information,
  }) {
    super.e(message, time: time, error: error, stackTrace: stackTrace);
    _recordError(
      message,
      level: Level.error,
      error: error,
      stackTrace: stackTrace,
      information: information,
    );
  }

  
  void f(
    dynamic message, {
    DateTime? time,
    Object? error,
    StackTrace? stackTrace,
    List<Object?>? information,
  }) {
    super.f(message, time: time, error: error, stackTrace: stackTrace);
    _recordError(
      message,
      level: Level.fatal,
      error: error,
      stackTrace: stackTrace,
      information: information,
    );
  }

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

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

}

/// log出力をカスタマイズします。
///
/// デフォルトではiOSにて色が出力されないため、修正します。
class CustomOutput extends LogOutput {
  
  void output(OutputEvent event) {
    // log出します。
    for (final e in event.lines) {
      dev.log(e, name: 'logger');
    }
  }
}

extension on String {
  /// FirebaseAnalyticsのログに出力する際に、文字数を調整します。
  String get adjustAnalyticsParameter => length > 99 ? substring(0, 99) : this;
}

こちらのloggerの実装ではlog設計も一緒にやっています。
log設計については、以下の記事を参照ください。

https://qiita.com/DiegoHonda/items/2064849472644b7ba7af

前提条件

  • 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と連携させています。

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;
  }
}

以下で少し解説します。

loggerの作成

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

final logger = CustomLogger();
class CustomLogger extends Logger {
  CustomLogger()
      : super(
          output: CustomOutput(),
          printer: PrettyPrinter(
            methodCount: 1, // 表示されるコールスタックの数
            errorMethodCount: 5, // 表示されるスタックトレースのコールスタックの数
            lineLength: 80, // 区切りラインの長さ
            // タイムスタンプを出力するかどうか
            dateTimeFormat: DateTimeFormat.dateAndTime,
          ),
          level: kReleaseMode ? Level.info : Level.debug,
        );
        

Crashlyticsとの連携

loggerとCrashlyticsとを連携します。

  /// FirebaseCrashlyticsにerrorを記録する。
  Future<void> _recordError(
    dynamic message, {
    required Level level,
    Object? error,
    StackTrace? stackTrace,
    List<Object?>? information,
  }) async {
    try {
      // 初期化されていない場合は終了
      if (Firebase.apps.isEmpty) return;
      // リリースモード以外の場合は終了
      if (!kReleaseMode) return;
      await FirebaseCrashlytics.instance.recordError(
        error,
        stackTrace,
        reason: message,
        printDetails: false,
        fatal: level == Level.fatal,
        information: information != null
            ? information.where((e) => e != null).toList().cast<Object>()
            : [],
      );
    } catch (e) {
      dev.log('logの出力エラー', name: 'logger');
    }
  }

  
  void e(
    dynamic message, {
    DateTime? time,
    Object? error,
    StackTrace? stackTrace,
    List<Object?>? information,
  }) {
    super.e(message, time: time, error: error, stackTrace: stackTrace);
    _recordError(
      message,
      level: Level.error,
      error: error,
      stackTrace: stackTrace,
      information: information,
    );
  }

一旦consoleにlogを出力させます(super.e)。その後、Crashlyticsにeventを送信するように_recordErrorにeventを渡します。

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

:::note warn
StackTraceを渡すのを忘れないでください。StackTraceを渡さない場合は、すべてのエラーはlogger.dartより発生していることになり、エラーの場所を特定することが難しくなります。
以下のようにlogger.eを実行する場合はStackTraceも一緒に投げてください。

 logger.e('手動エラー', error: e, stackTrace: s);

:::

:::note info
アプリの速度を気にするかたように、以下公式の言葉です。

アプリの速度低下を避けるため、Crashlytics ではログのサイズを 64 KB に制限しています。セッションのログがこの制限を超えると、古いエントリからログが削除されます。

多分ですが、アプリ内に64KBの保存領域があって、Crashlyticsがエラーを記録するたびにそのエラー情報を一緒に送っているだけだと思うので、logが逐一全部送られているわけではないと推察してます。
:::

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>

  /// log出力とFirebaseAnalyticsにログを送信します。
  void info({
    required String name,
    Map<String, Object?>? parameters,
  }) {
    final logParameters = parameters?.map(
      (key, value) => MapEntry(
        key,
        value.toString().adjustAnalyticsParameter,
      ),
    );

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

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

</details>

以下、解説します。

logger.infoの作成

 /// log出力とFirebaseAnalyticsにログを送信します。
  void info({
    required String name,
    Map<String, Object?>? parameters,
  }) {
    final logParameters = parameters?.map(
      (key, value) => MapEntry(
        key,
        value.toString().adjustAnalyticsParameter,
      ),
    );

    // log出します。
    // ignore: deprecated_member_use_from_same_package
    i(
      '$name: $logParameters',
    );
    _sendLog(name, logParameters);
  }

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

Analyticsのユーザ情報の設定

analyticsにユーザ情報および、カスタムプロパティを設定できるようにしてます。
logger.setUserIdlogger.setUserPropertyで設定を呼び出せます。

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

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

最後に

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

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

Discussion