【Flutter】loggingで自由な表示形式でログを保存する

2024/03/28に公開

概要

logging、path_provider、io(標準パッケージ)を使用し、自由な表示形式でログデータを保存します。

なぜloggingを使ったのか?

筆者がandroid studioを使っているときにslf4j+log4j+android-logging-log4jライブラリを使用していて、同じような形式で出力してくれるFlutter向けloggingパッケージを使ったのが一番の理由です。ただ、loggingは表示形式を自由な形に編集しやすいので、解析時にexcelで読み込んでフィルターをかけて検索するとかがしやすくなります。

  • 良い点
     自由な形式(json形式、excelで読込みやすい形式等)にログを編集できる。
  • 悪い点
     スタックトレースからファイル名・行番号が表示できない。

※スタックトレースが出来ないので、筆者は普段使う時はクラス名を指定し、かつ、メッセージ内に関数名を入れて検索しやすいようにしています。

例えば、こんな形式。
 2024-03-26 13:21:19.962567,[INFO],[MyLogging.class], function() message.
 {"time":2024-03-26 13:21:19.962567, "level":INFO, "class":MyLogging.class, "message":function() message}

パッケージ

https://pub.dev/packages/logging
https://pub.dev/packages/path_provider

実装

  1. ターミナルで下記を実行
 flutter pub add logging path_provider
 flutter pub get
  1. 実装
main.dart
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:logging_sample2/logging_control.dart';

LogControl logControl = LogControl();
Logger log = Logger('main');

void main() {
  //LogControl().getLogger();
  logControl.init();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'logging保存',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyLogging(title: 'logging保存'),
    );
  }
}

Logger log1 = Logger('MyLogging.class');

class MyLogging extends StatefulWidget {
  const MyLogging({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyLogging> createState() => _MyLoggingState();
}

class _MyLoggingState extends State<MyLogging> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Center(
          child: Column(
        children: <Widget>[
          ElevatedButton(
              onPressed: () {
                log1.info('button1 click.');
              },
              child: const Text('ログ出力1')),
          ElevatedButton(
              onPressed: () {
                log1.warning('button2 click.');
              },
              child: const Text('ログ出力2')),
          ElevatedButton(
              onPressed: () {
                LogControl.getInstance().delete();
              },
              child: const Text('delete')),
        ],
      )),
    );
  }
}
LogControl.dart
import 'dart:io';

import 'package:path_provider/path_provider.dart';
import 'package:logging/logging.dart';

final String LOG_FILE_NAME = "trace.log";
final Level LOG_LEVEL = Level.ALL;
final int LOG_FILE_SIZE = 10; // MB
//final int LOG_FILE_NUM = 2;
final String LOG_OUTPUT_PATTERN =
    "%d [%p] [%c] : %m"; // %d:time, %p:log level, %c:class name, %m:message

final LOG_KEYWORD = ['%d', '%p', '%c', '%m'];

class _OutputPatternInfo {
  var output_order; // 表示順番
  var key_no; // キーワード番号
}

class LogControl {
  static LogControl _instance = new LogControl();
  static var keyList = <_OutputPatternInfo>[];
  static var wordList = <String>[];
  static var checkFileSizeSkip = 0;

  static LogControl getInstance() {
    return _instance;
  }

  void init() {
    Logger.root.level = LOG_LEVEL;
    _output_pattern();
    Logger.root.onRecord.listen((event) {
      _checkFileSize();
      String message = _output(event);
      // '${event.level.name}: ${event.time}: ${event.message}\r\n';
      print(message);
      _write(message);
    });
  }

  void _output_pattern() {
    //print("_output_pattern() start.");
    var key_line = [-1, -1, -1, -1];

    for (var i = 0; i < LOG_KEYWORD.length; i++) {
      key_line[i] = LOG_OUTPUT_PATTERN.indexOf(LOG_KEYWORD[i]);
    }

    for (var i = 0; i < key_line.length; i++) {
      var order = 0;
      for (var j = 0; j < key_line.length; j++) {
        if (key_line[i] == -1) continue;
        if ((key_line[i] > key_line[j]) && (key_line[j] != -1)) {
          order++;
        }
      }

      var val = _OutputPatternInfo();
      val.key_no = i;
      if (key_line[i] == -1) {
        val.output_order = -1;
        keyList.add(val);
      } else {
        val.output_order = order;
        if (keyList.length < order)
          keyList.add(val);
        else
          keyList.insert(order, val);
      }
    }

    // for (var v in keyList) {
    //   print('keylist: output_order=${v.output_order}, key_no=${v.key_no}');
    // }

    var word_start = 0;
    for (var v in keyList) {
      if (v.output_order == -1) continue;
      var word = LOG_OUTPUT_PATTERN.substring(word_start, key_line[v.key_no]);
      wordList.add(word);
      word_start = key_line[v.key_no] + 2;
    }

    // for (var v in wordList) {
    //   print('wordlist: m=${v}');
    // }

    // print("_output_pattern() end.");
  }

  String _output(event) {
    String result = '';
    var i = 0;
    for (var v in keyList) {
      if (wordList.length > i) {
        result += wordList[i];
        i++;
      }
      var key_no = v.key_no;
      if (key_no != -1) {
        switch (LOG_KEYWORD[key_no]) {
          case '%d':
            result += '${event.time}';
            break;
          case '%p':
            result += '${event.level.name}';
            break;
          case '%c':
            result += '${event.loggerName}';
            break;
          case '%m':
            result += '${event.message}';
            break;
          default:
        }
      }
    }
    if (wordList.length > i) {
      result += wordList[i];
      i++;
    }
    result += '\r\n';
    return result;
  }

  Future<String> _read() async {
    //print("read() start.");
    final directory = await getExternalStorageDirectory();
    final String? path = directory?.path;
    final file = File('$path/$LOG_FILE_NAME');
    String result = "";

    if (await file.exists()) {
      result = await file.readAsString();
      print(result);
    }
    //print("read() end.");
    return result;
  }

  void _write(String message) async {
    //print("write() start.");
    final directory = await getExternalStorageDirectory();
    final String? path = directory?.path;
    final file = File('$path/$LOG_FILE_NAME');

    if (!await file.exists()) {
      await file.create();
    }
    await file.writeAsString(message, mode: FileMode.append);
    //print("write() end.");
  }

  void delete() async {
    //print("clear() start.");
    final directory = await getExternalStorageDirectory();
    final String? path = directory?.path;
    final file = File('$path/$LOG_FILE_NAME');

    if (await file.exists()) {
      await file.delete();
    }
    //print("clear() end.");
  }

  void _checkFileSize() async {
    //print("checkFileSize() start.");
    // 毎回チェックするのは無駄なのでスキップ回数分読み飛ばし
    if (checkFileSizeSkip-- > 0) {
      return;
    }

    final directory = await getExternalStorageDirectory();
    final String? path = directory?.path;
    final file = File('$path/$LOG_FILE_NAME');

    if (await file.exists()) {
      var sizeKB = await file.length() / 1024;
      var sizeMB = sizeKB / 1024;
      //print("checkFileSize() ${sizeB}byte, ${sizeKB}KB, ${sizeMB}MB");
      // ファイルサイズチェック
      if (sizeMB > LOG_FILE_SIZE) {
        // 古いファイルを別名でコピー
        File newFile = await file.copy('$path/$LOG_FILE_NAME.1');
        if (await newFile.exists()) {
          delete(); // 古いファイル削除
        }
      } else {
        var sizeGapMB = LOG_FILE_SIZE - sizeMB;
        if (sizeGapMB > 0) {
          checkFileSizeSkip = 10000; // 1万回スキップ
        } else {
          var sizeGapKB = LOG_FILE_SIZE * 1024 - sizeKB;
          if (sizeGapKB > 100) {
            checkFileSizeSkip = 1000; // 1000回スキップ
          } else if ((sizeGapKB <= 100) && (sizeGapKB > 10)) {
            checkFileSizeSkip = 100; // 100回スキップ
          } else if ((sizeGapKB <= 10) && (sizeGapKB > 1)) {
            checkFileSizeSkip = 10; // 10回スキップ
          }
        }
      }
    }
    //print("checkFileSize() end.");
  }
}
  1. 実行
    実行すると下記の画面が表示され、ボタンを押すとログが保存されます。

  2. ログファイル
    内部ストレージ内のandroid/data/プロジェクト名/files/trace.logのログを確認するとちゃんと記録されています。

  3. コード説明
    まず、main.dartで、LogControlクラスを宣言し、main()内でLogControlの初期設定を行います。初期設定直後はログ保存できません。

LogControl logControl = LogControl();
Logger log = Logger('main');

void main() {
  //LogControl().getLogger();
  logControl.init();
  runApp(const MyApp());
}

ログを使用したいクラスで、ロガーを宣言し、ログ出力を行う(fine,info,warning,severe,shout等が出力メソッド、詳細はloggingパッケージ参照)。

Logger log1 = Logger('MyLogging.class');

class MyLogging extends StatefulWidget {}

class _MyLoggingState extends State<MyLogging> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
     ~
          ElevatedButton(
              onPressed: () {
                log1.info('button1 click.');
              },
              child: const Text('ログ出力1')),

ログのファイル名の変更、表示レベルの変更、ファイルサイズ変更、表示形式の変更したい場合は、LogControl.dartの下記のところを編集すると変更できます。
下記の場合の表示形式は
 2024-03-26 13:21:19.962567 [INFO] [MyLogging.class] message.
となります。

final String LOG_FILE_NAME = "trace.log";
final Level LOG_LEVEL = Level.ALL;
final int LOG_FILE_SIZE = 10; // MB
//final int LOG_FILE_NUM = 2;
final String LOG_OUTPUT_PATTERN =
    "%d [%p] [%c] : %m"; // %d:time, %p:log level, %c:class name, %m:message

通信データのトレース

前記事(flutter arduinoとUSBシリアル通信)にログ保存を追加し、arduinoと115200bpsで接続し、ログがちゃんと記録されるかを確認しました。

arduinoの確認用コード

USBシリアル通信115200bpsでカウント値を加算しながら送信を行います。

sketch.ino
void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);  
}

unsigned long time = 0;
                      //  0     1     2     3     4     5     6     7     8     9
                      //  0    0     0     0     0     0     1     2    \r    \n
byte send_data[10] = { 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x31, 0x32, 0x0D, 0x0A }; 

void loop() {
  // put your main code here, to run repeatedly:
  send_data[0] = (unsigned char)(time % 100000000 / 10000000 + 0x30);
  send_data[1] = (unsigned char)(time % 10000000 / 1000000 + 0x30);
  send_data[2] = (unsigned char)(time % 1000000 / 100000 + 0x30);
  send_data[3] = (unsigned char)(time % 100000 / 10000 + 0x30);
  send_data[4] = (unsigned char)(time % 10000 / 1000 + 0x30);
  send_data[5] = (unsigned char)(time % 1000 / 100 + 0x30);
  send_data[6] = (unsigned char)(time % 100 / 10 + 0x30);
  send_data[7] = (unsigned char)(time % 10 + 0x30);

  Serial.write(send_data, 10);
  Serial.flush();
  time++;
  if (time >= 100000000) time = 0;
  delay(10);
}

通信ログ

ログを見る限りでは、115200bpsのUSBシリアル通信の漏れはなさそうでしたが、もっと長く通信させるとちょっと漏れていました。ただ、ログデータを見てみると2ms以内に記録しているところもあるので、たぶんUSBシリアル通信側の方でこけてるところがあるんじゃないかと思われます。他には端末自体の性能が通信についていけてないかもかな。いずれにして、治具レベルでは問題なく使えそうなのでした。もっと早く記録したい場合はストリームで保存ですね。

おわりに

以上、loggingを使用したログ保存方法でした。
業務系アプリだとログ保存するのは標準的に必要な機能だとは思いますが、Flutterパッケージのloggerやloggingに保存する機能がなさそうだったので(あんまり調べていませんが)、自分で作成してみました(rotation_logパッケージというのもあるらしいです)。android studio向けのandroid-logging-log4jだと、外部ファイルから設定を変更出来たり、ログファイル数を変えられたり出来ましたが、この記事ではそこまで対応はしませんでした。
あと、logging、path_providerを使ってAndroid端末で実行確認を行いましたが、loggingやpath_providerパッケージ自体はiOS端末にも対応しているため、保存先を変えれば基本的に使用できると思います(iOSの場合は、LogControlクラスの_write関数の保存先を変えて下さい)。
また、【Flutter】zipファイルをメール送信の記事で、ファイルをメールで添付していますので合わせてみて頂けると助かります。

参考

https://pub.dev/packages/path_provider
https://pub.dev/packages/logging
https://zenn.dev/susatthi/articles/20220413-153500-flutter-logger
https://rizumita.medium.com/logging-in-dart-dd9c01eb459a

Discussion