Unityを触り始めたころは、つい何でもかんでも Update に書いてしまいがちですよね。入力処理も、UI更新も、セーブも、例外処理も、全部ひとつのスクリプトに押し込んでしまう…。
その結果、どこで何が起きているのか分からなくなり、いざバグやクラッシュが起きたときに「ログが追えない」「再現できない」という地獄にハマりがちです。

そこでこの記事では、「クラッシュやエラーが起きたときの対応だけ」を担当する小さなコンポーネントとして、CrashReporter を用意します。
ゲームが例外で止まったり、深刻なエラーが出たときに、その情報を自動で開発者サーバーに送信する仕組みをコンポーネント化しておくと、巨大なGodクラスにせずに、プロジェクト全体の監視体制をスッキリ整えられます。

【Unity】クラッシュを自動で見逃さない!「CrashReporter」コンポーネント

ここでは、

  • Unityのログコールバック(Application.logMessageReceived)を使ってエラー・例外を検知
  • 重要な情報(メッセージ、スタックトレース、プラットフォーム、ビルド番号など)をまとめる
  • 開発者サーバーに HTTP POST で送信する
  • エディタ実行中はテストのためにログをコンソールにも出す

という流れを、ひとつのコンポーネントに閉じ込めます。

フルコード:CrashReporter.cs


using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

/// <summary>
/// クラッシュやエラー発生時に、ログを開発者サーバーへ送信するコンポーネント。
/// ゲーム全体で1つだけ配置しておき、ログコールバックをフックします。
/// </summary>
public class CrashReporter : MonoBehaviour
{
    // ====== 設定項目(インスペクタから編集) ======

    [Header("送信先設定")]
    [SerializeField]
    private string reportEndpointUrl = "https://example.com/api/crash-report";

    [Tooltip("レポート送信時に付与するゲームIDやプロジェクト名")]
    [SerializeField]
    private string gameId = "MyAwesomeGame";

    [Tooltip("このビルドのバージョン。Application.versionと合わせてもOK")]
    [SerializeField]
    private string buildVersion = "1.0.0";

    [Header("送信条件")]
    [Tooltip("Error, Assert, Exception ログを送信対象にするか")]
    [SerializeField]
    private bool reportOnError = true;

    [Tooltip("Warning ログも送信対象にするか(ノイズが増えるので通常はOFF推奨)")]
    [SerializeField]
    private bool reportOnWarning = false;

    [Header("動作オプション")]
    [Tooltip("エディタ上でのプレイ中にも送信するか(通常はテスト用にON)")]
    [SerializeField]
    private bool enableInEditor = true;

    [Tooltip("1セッション中に送信する最大レポート件数(無限送信を防ぐため)")]
    [SerializeField]
    private int maxReportsPerSession = 5;

    [Tooltip("レポート送信に失敗したときにリトライするか")]
    [SerializeField]
    private bool retryOnFailure = true;

    [Tooltip("リトライする場合の最大試行回数")]
    [SerializeField]
    private int maxRetryCount = 3;

    [Tooltip("リトライ間隔(秒)")]
    [SerializeField]
    private float retryIntervalSeconds = 5f;

    [Header("デバッグ表示")]
    [Tooltip("送信内容をコンソールに表示するか(開発中に便利)")]
    [SerializeField]
    private bool logToConsole = true;

    // ====== 内部状態 ======

    // このセッション中に送信したレポート件数
    private int _sentReportCount = 0;

    // 既に破棄されているか(OnDestroy後にコールバックが飛んでくるのを防ぐフラグ)
    private bool _isShuttingDown = false;

    // ログコールバックを多重登録しないためのフラグ
    private bool _isRegistered = false;

    // Unity標準のログタイプをJSONに載せるためのシリアライズ用クラス
    [Serializable]
    private class CrashReportPayload
    {
        public string gameId;
        public string buildVersion;
        public string unityVersion;
        public string platform;
        public string deviceModel;
        public string deviceName;
        public string systemLanguage;
        public string logType;
        public string logMessage;
        public string stackTrace;
        public string timestampUtc;
        public string sessionId;
    }

    // セッションID(アプリ起動ごとに一意になるようにGUIDを生成)
    private string _sessionId;

    private void Awake()
    {
        // シーンをまたいでも生き続けるレポーターにしたい場合はコメントアウトを外す
        // DontDestroyOnLoad(gameObject);

        _sessionId = Guid.NewGuid().ToString();

        RegisterLogCallback();
    }

    private void OnEnable()
    {
        RegisterLogCallback();
    }

    private void OnDisable()
    {
        UnregisterLogCallback();
    }

    private void OnDestroy()
    {
        _isShuttingDown = true;
        UnregisterLogCallback();
    }

    /// <summary>
    /// ログコールバックを登録する。
    /// Awake/OnEnable から呼ばれるが、多重登録を避ける。
    /// </summary>
    private void RegisterLogCallback()
    {
        if (_isRegistered) return;

        Application.logMessageReceived += HandleLogMessage;
        _isRegistered = true;
    }

    /// <summary>
    /// ログコールバック登録を解除する。
    /// </summary>
    private void UnregisterLogCallback()
    {
        if (!_isRegistered) return;

        Application.logMessageReceived -= HandleLogMessage;
        _isRegistered = false;
    }

    /// <summary>
    /// Unityのログが出力されたときに呼ばれるコールバック。
    /// ここでエラーや例外ログを検知してレポート送信をトリガーします。
    /// </summary>
    private void HandleLogMessage(string condition, string stackTrace, LogType type)
    {
#if UNITY_EDITOR
        // エディタで無効化されている場合は何もしない
        if (!enableInEditor)
        {
            return;
        }
#endif
        if (_isShuttingDown)
        {
            return;
        }

        // ログタイプに応じて送信対象かどうか判定
        if (!ShouldReport(type))
        {
            return;
        }

        // セッション上限を超えていたら送信しない
        if (_sentReportCount >= maxReportsPerSession)
        {
            if (logToConsole)
            {
                Debug.Log($"[CrashReporter] セッション中の最大送信件数({maxReportsPerSession})に達したため、これ以上は送信しません。");
            }
            return;
        }

        // レポート用ペイロードを組み立てる
        var payload = BuildPayload(condition, stackTrace, type);

        // 送信処理をコルーチンで実行
        StartCoroutine(SendReportCoroutine(payload));
    }

    /// <summary>
    /// このログタイプをレポート対象にするかどうか。
    /// </summary>
    private bool ShouldReport(LogType type)
    {
        switch (type)
        {
            case LogType.Error:
            case LogType.Assert:
            case LogType.Exception:
                return reportOnError;
            case LogType.Warning:
                return reportOnWarning;
            case LogType.Log:
            default:
                return false;
        }
    }

    /// <summary>
    /// 送信用のペイロードを構築する。
    /// </summary>
    private CrashReportPayload BuildPayload(string message, string stackTrace, LogType type)
    {
        return new CrashReportPayload
        {
            gameId = gameId,
            buildVersion = string.IsNullOrEmpty(buildVersion) ? Application.version : buildVersion,
            unityVersion = Application.unityVersion,
            platform = Application.platform.ToString(),
            deviceModel = SystemInfo.deviceModel,
            deviceName = SystemInfo.deviceName,
            systemLanguage = Application.systemLanguage.ToString(),
            logType = type.ToString(),
            logMessage = message,
            stackTrace = stackTrace,
            timestampUtc = DateTime.UtcNow.ToString("o"), // ISO 8601 形式
            sessionId = _sessionId
        };
    }

    /// <summary>
    /// クラッシュレポートをHTTP POSTで送信するコルーチン。
    /// 必要に応じてリトライも行う。
    /// </summary>
    private IEnumerator SendReportCoroutine(CrashReportPayload payload)
    {
        _sentReportCount++;

        // JSONにシリアライズ
        string json = JsonUtility.ToJson(payload);

        if (logToConsole)
        {
            Debug.Log($"[CrashReporter] レポート送信開始: {payload.logType} / {payload.logMessage}");
            Debug.Log($"[CrashReporter] Payload JSON: {json}");
        }

        int attempt = 0;
        bool success = false;

        while (attempt <= maxRetryCount && !success)
        {
            attempt++;

            using (var request = new UnityWebRequest(reportEndpointUrl, UnityWebRequest.kHttpVerbPOST))
            {
                byte[] bodyRaw = Encoding.UTF8.GetBytes(json);
                request.uploadHandler = new UploadHandlerRaw(bodyRaw);
                request.downloadHandler = new DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");

                yield return request.SendWebRequest();

#if UNITY_2020_2_OR_NEWER
                bool hasNetworkError = request.result == UnityWebRequest.Result.ConnectionError ||
                                       request.result == UnityWebRequest.Result.ProtocolError;
#else
                bool hasNetworkError = request.isNetworkError || request.isHttpError;
#endif

                if (hasNetworkError)
                {
                    if (logToConsole)
                    {
                        Debug.LogWarning($"[CrashReporter] レポート送信失敗 (試行 {attempt}/{maxRetryCount + 1}) : {request.error}");
                    }

                    if (!retryOnFailure || attempt > maxRetryCount)
                    {
                        // リトライしない or 最大試行回数を超えたら終了
                        yield break;
                    }
                }
                else
                {
                    if (logToConsole)
                    {
                        Debug.Log($"[CrashReporter] レポート送信成功 (ステータスコード: {request.responseCode})");
                    }

                    success = true;
                }
            }

            // 成功していない && まだリトライする場合は待機
            if (!success && retryOnFailure && attempt <= maxRetryCount)
            {
                yield return new WaitForSeconds(retryIntervalSeconds);
            }
        }
    }

    // ====== テスト用メソッド(任意で使用) ======

    /// <summary>
    /// 人為的に例外を投げて、CrashReporterの動作をテストするためのメソッド。
    /// デバッグ用にインスペクタから呼び出しても良いです。
    /// </summary>
    [ContextMenu("Test Crash Report (Throw Exception)")]
    private void TestCrashReport()
    {
        throw new Exception("CrashReporter テスト用の例外です。");
    }
}

使い方の手順

  1. コンポーネントを作成する
    上記のコードを CrashReporter.cs という名前で Assets/ フォルダ内に保存します。
  2. 監視用オブジェクトを用意する
    シーン内に空の GameObject を作成し、名前を CrashReporter などにします。
    その GameObject に CrashReporter コンポーネントをアタッチします。
    (ゲーム全体で1つだけあればよいので、タイトルシーンや常駐マネージャーシーンに置くのがおすすめです)
  3. 送信先URLなどを設定する
    インスペクタから以下を設定します:
    • Report Endpoint Url:クラッシュレポートを受け取る自前サーバーのURL(例:https://your-domain.com/api/crash)。
    • Game Id:プロジェクト名やゲームID。
    • Build Version:ビルド番号(空なら Application.version が使われます)。
    • Max Reports Per Session:1起動あたり何件まで送るか。
    • Enable In Editor:エディタ実行中にも送るかどうか。
  4. 実際のゲームオブジェクトに影響を与えずにテストする
    • インスペクタのコンテキストメニューから Test Crash Report (Throw Exception) を実行すると、意図的に例外を発生させて送信フローを確認できます。
    • または、プレイヤーや敵のスクリプト内で throw new Exception("テスト"); を書いて、クラッシュ時の動作を確認してもOKです。

例として:

  • プレイヤーコンポーネントの中で、セーブデータ読み込みに失敗したときに Debug.LogException を呼ぶと、そのログが CrashReporter にフックされて自動送信されます。
  • 敵AI の状態遷移で想定外のパスに入ったときに、Debug.LogError("Unexpected state ..."); を出しておけば、そのエラーもサーバー側で集計できます。
  • 動く床 やギミックのスクリプトで、物理挙動の不整合を検知したときに Debug.LogWarning を出すようにしておき、Report On Warning をオンにすれば、ソフトクラッシュ(ゲームは止まらないが危険な状態)も拾えます。

メリットと応用

CrashReporter をひとつのコンポーネントとして分離しておくと、

  • 各プレイヤー/敵/UIスクリプトは「自分の責務」である挙動だけに集中できる
  • 例外や致命的エラーが起きても、「どこでどう送るか」を毎回書かなくてよい
  • プレハブやシーンの増減に関係なく、「CrashReporter を1つ置いておけば監視は完了」というシンプルな構成になる
  • 本番ビルドでのみ送信、有料版のみ送信、といった切り替えも CrashReporter 1か所の設定で制御できる

レベルデザイン面でも、たとえば「特定のステージでだけクラッシュが多い」といった情報をサーバー側で可視化できるようになるため、問題のあるステージを素早く特定して修正できます。
また、プレイヤーからの「落ちました」という報告だけに頼らず、実際のスタックトレース付きで原因を追えるのは大きなメリットです。

応用として、例えば「特定のエラーだけは即時ポップアップを出す」といった UI 連携も簡単に追加できます。
下記は、致命的な例外が来たときだけゲームを一時停止する改造案です。


/// <summary>
/// 致命的な例外が来たときにゲームを一時停止する例。
/// CrashReporter 内の HandleLogMessage の最後に追加するイメージです。
/// </summary>
private void PauseGameOnException(LogType type)
{
    if (type == LogType.Exception)
    {
        // Time.timeScale を 0 にしてゲームを一時停止
        Time.timeScale = 0f;
        if (logToConsole)
        {
            Debug.Log("[CrashReporter] 例外発生のためゲームを一時停止しました。");
        }
    }
}

このように、CrashReporter をベースに、「ログをどう扱うか」「どの種類のエラーでどんな挙動を取るか」を少しずつ拡張していくと、
巨大なGodクラスに頼らずとも、堅牢で観測可能なゲーム基盤を作っていけます。