モバイルクライアントアプリにおける「どんなログを出してるのか?」「どの様に役立てているのか?」事例

このエントリは【カヤック】面白法人グループ Advent Calendar 2023の19日目の記事です。

こんにちは。カヤックアキバスタジオ須藤崇浩(@p_chin)と申します。

本記事では私のログに対する考えと、実際にどの様なログを出して、どの様に役立ててるかを紹介します。

また、記事内ではUnity製のモバイルゲームでの事例に限定して話をします。(モバイル以外のクライアントアプリにも応用可能だと思いますが)

おそらく周りでも同じ様な事をやっていそうですが、開発の参考になれば幸いです。

私のログを出す際の考え方

基本的にはバグの調査のために使うので、それに役立つ情報をログに残します。

問題が起きた場合のログもそうですが、問題が起きてない事を証明するためのログを出しておくこともします。

クライアントアプリの場合は基本的にログが端末にしか残らないので容量などの懸念は考慮せずに、コードの可読性が落ちない範囲でログは残しすぎても損はないと考えてます。

ただログ出力の処理は多少の負荷があるので性能がシビアに求められる場面では負荷計測などの用途でOFFに出来ると良いと思います。*1

具体的にどんな場合にログを出して役立てているのか?の例

  • 画面の読み込み時+表示完了時
    • 「今どの画面にいるのか?」をログから分かりやすくするため
    • ログにタイムスタンプも出す様にすれば読み込みが長すぎる画面も把握しやすくなるため

端末時刻: 2023-12-14 17:22:23.8983 [ChangeScene] Start type:HogeScene, paramater:, state:

端末時刻: 2023-12-14 17:22:24.3912 [ChangeScene] Complete type:HogeScene

  • シールド*2の表示/非表示時
    • エラーが出ずに進行不能バグが起きた場合にシールドを表示しっぱなしという状況が多かったので
    • シールドの状態はカウンターでも管理してるので、カウンターの残数も一緒にログを出しています*3

[ClearShield] Show displayCounter:1

[ClearShield] Show displayCounter:2

[ClearShield] Hide displayCounter:1

  • 通信のリクエスト(paramater + header)+レスポンス受信時
    • リクエストパラメータとレスポンスの内容を表示することでサーバー実装 or マスターデータなどに原因がある可能性を早期発見できるから
  • 3rd-party製のSDKなどのログ
    • ログレベルが実装されてるモノなら必ず開発ビルドでは必ずVerbose設定にしています
    • アプリの独自実装と、SDKのどちらに原因の所在があるか明確にするためにかなり役立っています
      • SDKに技術サポート窓口などがある場合にはそのままログを提出できるケースもありました
  • Exception発生時
    • catchする際にも画面から分かりにくい処理を書いてる場合にはログ出す様にしてます
    • 例外を投げっぱなしにする(try-catch / try-finalyで囲わない)場合はUnityが勝手にログ出力してくれるはず

実際に出しているログファイルについて紹介

私の参加してるチームが共有用のログを出力する際には、以下の構造になっています。

logs-{timestamp}.zip
┃
┣ error-logs.txt // エラーリスト
┣ info.txt // ログ出力時の機種 / アプリ特有の状態リスト
┣ logs.txt // 全てのログを含めたもの
┣ prev-error-logs.txt // 以前に起動した時のエラーリスト
┣ resource-info.txt // ログ出力時にメモリに載っているリソースリスト
┣ ss.png // ログ出力時の画面スクショ

各ファイルの中身について具体的に以下で紹介します。

エラーリスト

これは単純に Application.logMessageReceived で通知されるエラーを都度書き込んでいるログファイルを共有してるだけです。

前後の文脈を省略してますが以下の様な内容を書き込んでいます。

実装例はこちら

void OnLogMessageReceived(string condition, string stacktrace, LogType type)
{
    var text =
        @$"{type} frameCount: {Time.frameCount} 
端末時刻: {DateTime.Now:yyyy-MM-dd HH:mm:ss.ffff} 
サーバー時刻: {ゲームサーバー時刻を取得}
{condition}
{stacktrace}";

    if (type is LogType.Assert or LogType.Error or LogType.Exception)
    {
        _errorLog.Add(text);
    }
}

ログ出力時の機種 / アプリ特有の状態リスト

主に端末に関係するデータを沢山取っています。

空き容量、対応するTextureFormat、ビルドを作ったバージョン管理システムのrevisionなど...etc。

テスト環境起因での問題を調査する際などに役立つログです。

実装例はこちら ⚠️案件依存な部分はコメント説明に代替してます

using UnityEngine;
using UnityEngine.Profiling;
using System.IO;
using System.IO.Compression;


void WriteInfo(ZipArchiveEntry entry)
{
    using var writer = new StreamWriter(entry.Open());

    writer.WriteLine("■Application");
    // 端末とゲームサーバーの時刻
    // 接続先のゲームサーバー名
    // プレイヤーID
    writer.WriteLine($"  Cache path : {Caching.currentCacheForWriting.path}");
    writer.WriteLine();

    writer.WriteLine("■System");
    writer.WriteLine($" OS              : {SystemInfo.operatingSystem}");
    writer.WriteLine($" Device Name     : {SystemInfo.deviceName}");
    writer.WriteLine($" Device Model    : {SystemInfo.deviceModel}");
    writer.WriteLine($" CPU Type        : {SystemInfo.processorType}");
    writer.WriteLine($" CPU Count       : {SystemInfo.processorCount}");
    writer.WriteLine($" Battery Status  : {SystemInfo.batteryStatus}");
    writer.WriteLine($" Battery Level   : {SystemInfo.batteryLevel}");
    // ディスクの空き容量

    writer.WriteLine();
    writer.WriteLine("■BuildManifest");
    // gitのブランチ名
    // headのcommit-hash
    // ScriptingDefineSymbols

    writer.WriteLine();
    writer.WriteLine("■Graphics");
    writer.WriteLine($" Device Name     : {SystemInfo.graphicsDeviceName}");
    writer.WriteLine($" Device Type     : {SystemInfo.graphicsDeviceType}");
    writer.WriteLine($" Device Version  : {SystemInfo.graphicsDeviceVersion}");
    writer.WriteLine($" Device Vendor   : {SystemInfo.graphicsDeviceVendor}");
    writer.WriteLine($" Device Id       : {SystemInfo.graphicsDeviceID}");
    writer.WriteLine($" Shader Level    : {SystemInfo.graphicsShaderLevel}");
    writer.WriteLine($" MultiThreaded   : {SystemInfo.graphicsMultiThreaded}");
    writer.WriteLine($" Max Buffer Size : {SystemInfo.maxGraphicsBufferSize}");
    writer.WriteLine($" Max Texture Size: {SystemInfo.maxTextureSize}");

    writer.WriteLine();
    writer.WriteLine("■Unity");
    writer.WriteLine($" Version                : {Application.unityVersion}");
    writer.WriteLine($" isDebugBuild           : {UnityEngine.Debug.isDebugBuild}");
    writer.WriteLine($" System Language        : {Application.systemLanguage}");
    writer.WriteLine($" Platform               : {Application.platform}");
    writer.WriteLine($" Application Version    : {Application.version}");
    writer.WriteLine($" Application Identifier : {Application.identifier}");
    writer.WriteLine($" vSync Count            : {QualitySettings.vSyncCount}");
    writer.WriteLine($" Quality Level          : {QualitySettings.names[QualitySettings.GetQualityLevel()]}({QualitySettings.GetQualityLevel()})");

    var internetReachability =
        Application.internetReachability == NetworkReachability.NotReachable ? "disconnected" :
        Application.internetReachability == NetworkReachability.ReachableViaCarrierDataNetwork ? "carrier" : "wifi";

    writer.WriteLine();
    writer.WriteLine("■Network");
    writer.WriteLine($" internetReachability : {internetReachability}");

    writer.WriteLine();
    writer.WriteLine("■Display");
    writer.WriteLine($" Resolution  : {Screen.currentResolution.width} x {Screen.currentResolution.height}");
    writer.WriteLine($" Screen      : {Screen.width} x {Screen.height}");
    writer.WriteLine($" DPI         : {Screen.dpi}");
    writer.WriteLine($" Orientation : {Screen.orientation}");

#if UNITY_IOS
    writer.WriteLine();
    writer.WriteLine("■iOS");
    writer.WriteLine($" Generation                   : {UnityEngine.iOS.Device.generation}");
    writer.WriteLine($" Advertising Tracking Enabled : {UnityEngine.iOS.Device.advertisingTrackingEnabled}");
#endif

    writer.WriteLine();
    writer.WriteLine("■Memory");
    writer.WriteLine($" Profiler {Profiler.enabled}");
    writer.WriteLine($" System Memory   : {GetSizeText(SystemInfo.systemMemorySize*1024L*1024L)}");
    writer.WriteLine($" Graphics Memory : {GetSizeText(SystemInfo.graphicsMemorySize*1024L*1024L)}");
    writer.WriteLine($" Mono Heap {GetSizeText(Profiler.GetMonoUsedSizeLong())} / {GetSizeText(Profiler.GetMonoHeapSizeLong())}");
    writer.WriteLine($" Unity Memory {GetSizeText(Profiler.GetTotalAllocatedMemoryLong())} / {GetSizeText(Profiler.GetTotalReservedMemoryLong())} Unused {GetSizeText(Profiler.GetTotalUnusedReservedMemoryLong())}");
    writer.WriteLine($" Graphics {GetSizeText(Profiler.GetAllocatedMemoryForGraphicsDriver())}");

    var textureFormats = new []
    {
        TextureFormat.ETC2_RGB,
        TextureFormat.ETC2_RGBA1,
        TextureFormat.ETC2_RGBA8,
        TextureFormat.ETC2_RGBA8Crunched,
        TextureFormat.ASTC_4x4,
        TextureFormat.ASTC_5x5,
        TextureFormat.ASTC_6x6,
        TextureFormat.ASTC_8x8,
        TextureFormat.ASTC_10x10,
        TextureFormat.ASTC_12x12,
    };
    writer.WriteLine();
    writer.WriteLine("■Supports Texture Format");
    var formatTextLength = textureFormats.Max(format => format.ToString().Length);
    foreach(var format in textureFormats)
    {
        writer.WriteLine($" {format.ToString().PadRight(formatTextLength, ' ')} : {SystemInfo.SupportsTextureFormat(format)}");
    }
}

// 1024累乗じゃなくて1000の累乗で割ってるのはキロとかメガの定義に従ってるからなのですが、1024の累乗で割ったもので表記するのもかなり一般的だと思うのであえて1000の累乗で割ってる事をここにコメントしておきます。
// (1024の累乗を使う時は厳密には単位がKiB / MiBみたいな表記になる。)
// ref https://ja.wikipedia.org/wiki/2進接頭辞0
static string GetSizeText(long size)
{
    if (size < 1000)
    {
        return $"{size}B";
    }

    string suffix;
    if (size < 100_000)
    {
        suffix = "KB";
    }
    else if (size <= 1000_000_000)
    {
        suffix = "MB";
        size /= 1000;
    }
    else
    {
        suffix = "GB";
        size /= 1000_000;
    }

    if (size % 100 >= 10)
    {
        // 小数第2位を切り上げ
        size += 100;
    }
    size /= 100;

    return $"{size / 10f:0.0}{suffix}";
}

全てのログを含めたもの

処理の開始から完了、画面の操作を防ぐシールドのカウンター....などエラー以外の色々なログを拾って全て書き出してます。

実装例は割愛します。

(これの取得方法はエラーと同じ方法で全てのLogTypeを書き込んでるだけなので)

私は個人的にエラーの前後の文脈も見たいのでこればかり見てます。

以前に起動した時のエラーリスト

これはアプリ起動時に前回作成したエラーログがあったらバックアップしておいて、次のログ共有時に含めるだけです。

発生が稀なバグに遭遇した場合にうっかりアプリ終了させてしまった場合や、アプリが強制終了するバグに遭遇した際にもエラーログを共有するために用意してます。

(実際にはそこまで役立つことはありませんが、送っておいても損は無いので送ってます)

ログ出力時にメモリに載っているリソースリスト

Resources.FindObjectsOfTypeAll で取得した UnityEngine.Object のリストに対して Profiler.GetRuntimeMemorySizeLong でメモリ上のサイズを取得して出力しています。

あまり参照する事は無いですが取っておいて損は無いと思います。

ログ出力時の画面スクショ

クライアントアプリの問題はログに出ない/出してない場合もあり、当時の画面の見た目を知れるだけでも役立つ情報です。

一応スクショ時には端末時刻とゲームサーバー時刻を画面の端に表示する様にもしてます。

(例えば開始時刻になっても特定のイベントが表示されないバグが報告されたとして、原因が開発環境の時刻設定が間違ってるだけだったケースもたまにあるので、それを画像単体で一部証明する為のモノです。)

実行ログは誰でも閲覧/共有できる環境にすると便利

エンジニア以外のチームメンバーがプレイ中に見つけた問題をすぐに報告可能な状態を作っています。

ログをSlackに送信するボタンを設ける様にしています。

アプリ内のログ共有デバッグ画面
Slackにログが投稿された図

また共有先のSlackにアクセスできない外部の方向けに、OSのシェア機能でログのzipをDLする手段も提供してます。

実装例は割愛しますが、 NativeShare というシステムを利用してます。

現在メンテナンスは終了してますが現状動いていて、過去案件でも使用実績があり手軽に使えたので採用してます。

github.com

iOSのシェア機能によるログ共有

おわりに

今回紹介したTipsによって、皆様のバグ調査時間を減らすヒントになれば幸いです。

お読み頂きありがとうございました 🙏

*1:私はIS_DEBUGの様な名前のScriptingDefineSymbolsを定義してビルド時にON/OFF制御可能な様に作ってます。

*2:弊社グループで良く使う、画面を操作不能な状態にする概念。(通信中、画面読み込み時などに)

*3:進行不能バグ報告のシールドログの最後でこのカウンターが0になってない場合はそれが原因だと分かる。