【Cocos Creator】アタッチするだけ!CrashReporter (エラー送信)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】CrashReporter の実装:アタッチするだけでクラッシュエラーを自動送信する汎用スクリプト

このガイドでは、ゲーム中に発生したエラーやクラッシュ情報を自動で開発者サーバーへ送信する CrashReporter コンポーネントを実装します。任意の 1 ノードにアタッチしておくだけで、グローバルなエラー監視自動送信が有効になり、他のカスタムスクリプトやマネージャに依存しない設計になっています。

本番環境でのクラッシュ原因の特定、QA ビルドでの不具合収集などに役立ちます。


コンポーネントの設計方針

1. 機能要件の整理

  • ゲーム実行中に発生したエラー(例外)を検知する。
  • 検知したエラー情報を HTTP POST で開発者サーバーへ送信する。
  • 送信内容には、少なくとも以下を含める:
    • エラーメッセージ
    • スタックトレース
    • 発生時刻
    • プラットフォーム情報(Web / Native / Editor など)
    • 任意のゲームバージョンやユーザー識別用 ID(任意設定)
  • 送信先 URL や送信有無などはすべてインスペクタから設定可能とする。
  • エラー多発時のスパムを防ぐため、一定時間あたりの送信数を制限できるようにする。
  • 外部の GameManager やシングルトンに一切依存しない。

2. 外部依存をなくすためのアプローチ

  • グローバルハンドラの登録
    • window.onerrorwindow.onunhandledrejection を利用して、未処理エラーと未処理 Promise 拒否を捕捉。
    • Cocos 独自のログハンドラを差し替えることはせず、標準ブラウザ / JS の仕組みの範囲で実装。
  • 送信処理は内部の HTTP 実装のみ使用
    • 標準の XMLHttpRequest(Web / ネイティブ両対応)を使用。
    • fetch は古い環境との互換性を考慮して使用しない。
  • 状態管理はコンポーネント内部のみ
    • 送信キューやレート制限用のカウンタはすべてコンポーネントのプライベートフィールドで管理。
    • 他のノードやコンポーネントを参照しない。

3. インスペクタで設定可能なプロパティ設計

CrashReporter では、以下のプロパティをインスペクタから設定できるようにします。

  • enabledInEditor (boolean)
    • エディタ内プレビュー(Preview / Simulator)でもエラー送信を行うかどうか。
    • true: エディタ実行時も送信。
    • false: エディタ実行時はログ出力のみでサーバー送信しない。
  • endpointUrl (string)
    • エラーログ送信先の HTTP(s) URL。
    • 例: https://api.example.com/crash-report
    • 空文字の場合は送信処理を行わず、警告ログを出す。
  • appVersion (string)
    • ゲームのバージョン文字列。
    • 例: 1.0.3, beta-2025-01-01
    • 未設定でも動作するが、設定するとサーバー側で集計しやすくなる。
  • environment (string)
    • 環境種別を任意に指定。
    • 例: production, staging, dev
  • userId (string)
    • ユーザー識別用 ID(任意)。
    • ログインユーザー ID やデバイス ID を外部から設定しておく想定。
    • 未設定の場合は空文字として送信。
  • maxReportsPerMinute (number)
    • 1 分あたりに送信する最大レポート数。
    • 例: 10 にすると、1 分間に 10 件を超えるエラーはサーバー送信されない(内部ログは残す)。
    • 0 以下の場合は制限なしとして扱う。
  • maxQueueSize (number)
    • 送信待ちキューに溜めておく最大レポート数。
    • 例: 50 にすると、51 件目以降は古いレポートを破棄して新しいものを優先。
  • sendTimeoutSeconds (number)
    • 1 件の送信にかける最大秒数(HTTP タイムアウト)。
    • 例: 5 秒。
  • includeStackTrace (boolean)
    • スタックトレースを送信するかどうか。
    • セキュリティやログ量の都合でオフにしたい場合に利用。
  • debugLog (boolean)
    • 送信処理やエラー検知の詳細ログをコンソールに出すかどうか。
    • 開発中は true、本番では false 推奨。

このように、実行環境や開発フェーズに合わせて挙動を細かくチューニングできるようにします。


TypeScriptコードの実装


import { _decorator, Component, sys } from 'cc';
const { ccclass, property } = _decorator;

interface CrashReportPayload {
    message: string;
    stack?: string;
    timestamp: string;
    platform: string;
    isNative: boolean;
    isBrowser: boolean;
    appVersion: string;
    environment: string;
    userId: string;
    userAgent?: string;
    url?: string;
    extra?: any;
}

@ccclass('CrashReporter')
export class CrashReporter extends Component {

    @property({
        tooltip: 'エディタ実行時(Preview / Simulator)にもレポート送信を行うかどうか。\n通常、本番ビルドのみ送信したい場合は OFF にします。',
    })
    public enabledInEditor: boolean = false;

    @property({
        tooltip: 'クラッシュレポートの送信先 URL。\n例: https://api.example.com/crash-report\n空の場合、送信は行われず警告ログのみ出力されます。',
    })
    public endpointUrl: string = '';

    @property({
        tooltip: 'アプリ / ゲームのバージョン文字列。\n例: 1.0.0, beta-2025-01-01',
    })
    public appVersion: string = '';

    @property({
        tooltip: '環境名。\n例: production, staging, dev など。',
    })
    public environment: string = 'production';

    @property({
        tooltip: 'ユーザー識別用 ID(任意)。\nログインユーザー ID やデバイス ID などを設定しておくと、サーバー側での追跡が容易になります。',
    })
    public userId: string = '';

    @property({
        tooltip: '1分あたりに送信する最大レポート数。\n0以下の場合は無制限になります。',
    })
    public maxReportsPerMinute: number = 10;

    @property({
        tooltip: '送信待ちキューに保持する最大レポート数。\n超過した場合は古いレポートから破棄されます。',
    })
    public maxQueueSize: number = 50;

    @property({
        tooltip: '1件の送信にかける最大秒数(HTTPタイムアウト)。',
    })
    public sendTimeoutSeconds: number = 5;

    @property({
        tooltip: 'スタックトレースをサーバーへ送信するかどうか。',
    })
    public includeStackTrace: boolean = true;

    @property({
        tooltip: 'デバッグ用ログをコンソールに出力するかどうか。\n開発中のみ ON を推奨します。',
    })
    public debugLog: boolean = true;

    // 内部状態
    private _originalOnError: OnErrorEventHandler | null = null;
    private _originalOnUnhandledRejection: any = null;

    private _reportQueue: CrashReportPayload[] = [];
    private _sending: boolean = false;

    private _currentMinuteStart: number = 0;
    private _reportsSentThisMinute: number = 0;

    onLoad() {
        // グローバルエラーハンドラの登録
        this.installGlobalHandlers();
    }

    onDestroy() {
        // グローバルエラーハンドラの解除
        this.uninstallGlobalHandlers();
    }

    /**
     * グローバルエラー / 未処理Promise拒否のハンドラを登録
     */
    private installGlobalHandlers() {
        if (typeof window === 'undefined') {
            // 非ブラウザ環境(理論上)では何もしない
            return;
        }

        // 既存のハンドラを保存しておく
        this._originalOnError = window.onerror;
        this._originalOnUnhandledRejection = (window as any).onunhandledrejection;

        window.onerror = (message: any, source?: string, lineno?: number, colno?: number, error?: any) => {
            const msg = typeof message === 'string' ? message : String(message);
            const stack = error && error.stack ? error.stack : undefined;

            if (this.debugLog) {
                console.error('[CrashReporter] window.onerror captured:', msg, 'at', source, lineno, colno, error);
            }

            const payload: CrashReportPayload = this.buildPayload(msg, stack, {
                source,
                lineno,
                colno,
                type: 'onerror',
            });

            this.enqueueReport(payload);

            // 既存のハンドラがあれば呼び出す
            if (this._originalOnError) {
                try {
                    return this._originalOnError(message, source as any, lineno as any, colno as any, error);
                } catch (e) {
                    console.error('[CrashReporter] Error in original window.onerror handler:', e);
                }
            }

            // false / true を返すとブラウザのデフォルト挙動に影響するが、
            // ここでは既存の挙動を極力変えないように null を返す。
            return null;
        };

        (window as any).onunhandledrejection = (event: PromiseRejectionEvent) => {
            const reason = event.reason;
            let msg = 'Unhandled Promise rejection';
            let stack: string | undefined;

            if (reason) {
                if (typeof reason === 'string') {
                    msg = 'Unhandled Promise rejection: ' + reason;
                } else if (reason.message) {
                    msg = 'Unhandled Promise rejection: ' + reason.message;
                }

                if (reason.stack) {
                    stack = reason.stack;
                }
            }

            if (this.debugLog) {
                console.error('[CrashReporter] onunhandledrejection captured:', msg, reason);
            }

            const payload: CrashReportPayload = this.buildPayload(msg, stack, {
                type: 'unhandledrejection',
                reasonSummary: String(reason),
            });

            this.enqueueReport(payload);

            // 既存のハンドラがあれば呼び出す
            if (this._originalOnUnhandledRejection) {
                try {
                    return this._originalOnUnhandledRejection(event);
                } catch (e) {
                    console.error('[CrashReporter] Error in original onunhandledrejection handler:', e);
                }
            }

            return null;
        };

        if (this.debugLog) {
            console.log('[CrashReporter] Global error handlers installed.');
        }
    }

    /**
     * グローバルハンドラを元に戻す
     */
    private uninstallGlobalHandlers() {
        if (typeof window === 'undefined') {
            return;
        }

        if (this._originalOnError !== null) {
            window.onerror = this._originalOnError;
        }

        if (this._originalOnUnhandledRejection !== null) {
            (window as any).onunhandledrejection = this._originalOnUnhandledRejection;
        }

        if (this.debugLog) {
            console.log('[CrashReporter] Global error handlers uninstalled.');
        }
    }

    /**
     * 送信するペイロードを組み立てる
     */
    private buildPayload(message: string, stack?: string, extra?: any): CrashReportPayload {
        const now = new Date();
        const timestamp = now.toISOString();

        const isNative = sys.isNative;
        const isBrowser = sys.isBrowser;
        const platform = sys.platform.toString();

        const payload: CrashReportPayload = {
            message,
            timestamp,
            platform,
            isNative,
            isBrowser,
            appVersion: this.appVersion || '',
            environment: this.environment || '',
            userId: this.userId || '',
            extra,
        };

        if (this.includeStackTrace && stack) {
            payload.stack = stack;
        }

        if (typeof window !== 'undefined') {
            try {
                payload.userAgent = (window.navigator && window.navigator.userAgent) || '';
                payload.url = (window.location && window.location.href) || '';
            } catch {
                // 取得に失敗しても無視
            }
        }

        return payload;
    }

    /**
     * レポートをキューに追加し、送信処理をトリガー
     */
    private enqueueReport(payload: CrashReportPayload) {
        if (!this.shouldSendInCurrentEnvironment()) {
            if (this.debugLog) {
                console.warn('[CrashReporter] Reporting is disabled in this environment. Payload is ignored.');
            }
            return;
        }

        // レート制限チェック
        if (!this.checkRateLimit()) {
            if (this.debugLog) {
                console.warn('[CrashReporter] Rate limit exceeded. This report will be dropped.');
            }
            return;
        }

        // キューサイズ管理
        if (this._reportQueue.length >= this.maxQueueSize) {
            // 古いものから1件削除
            const dropped = this._reportQueue.shift();
            if (this.debugLog) {
                console.warn('[CrashReporter] Queue is full. Dropping oldest report:', dropped);
            }
        }

        this._reportQueue.push(payload);

        if (this.debugLog) {
            console.log('[CrashReporter] Enqueued crash report. Queue length =', this._reportQueue.length);
        }

        // 送信ループを開始
        this.processQueue();
    }

    /**
     * エディタ / エンドポイント設定に基づき、送信すべきかを判定
     */
    private shouldSendInCurrentEnvironment(): boolean {
        // エンドポイント未設定なら送信しない
        if (!this.endpointUrl) {
            console.warn('[CrashReporter] endpointUrl is empty. Crash reports will not be sent.');
            return false;
        }

        // エディタ環境での送信を制御
        if (sys.isEditor && !this.enabledInEditor) {
            if (this.debugLog) {
                console.log('[CrashReporter] Running in editor and enabledInEditor = false. Reporting disabled.');
            }
            return false;
        }

        return true;
    }

    /**
     * 1分あたりの送信数を制限する
     */
    private checkRateLimit(): boolean {
        if (this.maxReportsPerMinute <= 0) {
            return true; // 無制限
        }

        const now = Date.now();
        const minute = 60 * 1000;

        if (now - this._currentMinuteStart >= minute) {
            // 1分経過したらカウンタリセット
            this._currentMinuteStart = now;
            this._reportsSentThisMinute = 0;
        }

        if (this._reportsSentThisMinute >= this.maxReportsPerMinute) {
            return false;
        }

        this._reportsSentThisMinute++;
        return true;
    }

    /**
     * キューに溜まったレポートを順次送信
     */
    private async processQueue() {
        if (this._sending) {
            return; // 既に送信中
        }

        this._sending = true;

        while (this._reportQueue.length > 0) {
            const payload = this._reportQueue[0];

            try {
                await this.sendReport(payload);
                if (this.debugLog) {
                    console.log('[CrashReporter] Report sent successfully.');
                }
            } catch (e) {
                console.error('[CrashReporter] Failed to send crash report:', e);
                // ネットワークエラーなど一時的な失敗の場合、ここでリトライ戦略を取ることも可能。
                // シンプルな実装として、失敗しても破棄せず、ループを抜けて次回に再試行してもよい。
                // 今回は無限ループ防止のため、一旦破棄する。
            } finally {
                // 成否にかかわらず、先頭を取り除く
                this._reportQueue.shift();
            }
        }

        this._sending = false;
    }

    /**
     * 実際にHTTP POSTでレポートを送信
     */
    private sendReport(payload: CrashReportPayload): Promise<void> {
        return new Promise((resolve, reject) => {
            const url = this.endpointUrl;
            if (!url) {
                reject(new Error('endpointUrl is empty.'));
                return;
            }

            const xhr = new XMLHttpRequest();
            xhr.open('POST', url, true);
            xhr.setRequestHeader('Content-Type', 'application/json');

            let timeoutId: any = null;
            const timeoutMs = Math.max(1, this.sendTimeoutSeconds) * 1000;

            xhr.onreadystatechange = () => {
                if (xhr.readyState === 4) {
                    if (timeoutId !== null) {
                        clearTimeout(timeoutId);
                        timeoutId = null;
                    }

                    if (xhr.status >= 200 && xhr.status < 300) {
                        resolve();
                    } else {
                        reject(new Error('HTTP status ' + xhr.status + ': ' + xhr.responseText));
                    }
                }
            };

            xhr.onerror = () => {
                if (timeoutId !== null) {
                    clearTimeout(timeoutId);
                    timeoutId = null;
                }
                reject(new Error('Network error while sending crash report.'));
            };

            timeoutId = setTimeout(() => {
                try {
                    xhr.abort();
                } catch {
                    // ignore
                }
                reject(new Error('Crash report request timed out after ' + timeoutMs + ' ms.'));
            }, timeoutMs);

            try {
                const json = JSON.stringify(payload);
                if (this.debugLog) {
                    console.log('[CrashReporter] Sending payload:', json);
                }
                xhr.send(json);
            } catch (e) {
                if (timeoutId !== null) {
                    clearTimeout(timeoutId);
                    timeoutId = null;
                }
                reject(e);
            }
        });
    }
}

コードのポイント解説

  • onLoad / onDestroy
    • onLoadinstallGlobalHandlers() を呼び、window.onerrorwindow.onunhandledrejection を登録します。
    • onDestroy で元のハンドラに戻し、副作用を残さないようにしています。
  • エラー検知
    • window.onerror では、同期的な実行時エラーやスローされた例外を捕捉。
    • window.onunhandledrejection では、未処理の Promise 拒否を捕捉。
    • どちらも buildPayload() で送信用データを組み立て、enqueueReport() でキューへ投入します。
  • 環境判定とレート制限
    • shouldSendInCurrentEnvironment()
      • endpointUrl が空でないか
      • エディタ実行時に enabledInEditortrue

      を確認し、送信可否を決定します。

    • checkRateLimit() で 1 分あたりの送信数を制限しています。
  • 送信キューと HTTP 送信
    • _reportQueue に送信待ちの CrashReportPayload を保持。
    • processQueue() がキューを順次処理し、sendReport() で HTTP POST を実行します。
    • sendReport() では XMLHttpRequest を使用し、sendTimeoutSeconds で指定したタイムアウトを設けています。
  • 防御的実装
    • endpointUrl 未設定時は送信せず警告ログを出して終了。
    • window が存在しない環境(想定外)では何もしないようガード。
    • 既存の window.onerror / onunhandledrejection があれば、上書き後も内部で呼び出すことで互換性を保っています。

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. そのフォルダ上で右クリックし、Create > TypeScript を選択します。
  3. 作成されたファイル名を CrashReporter.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、テンプレートコードをすべて削除して、前述の CrashReporter コードを丸ごと貼り付けて保存します。

2. テスト用ノードの作成

  1. Hierarchy パネルで、エラー監視を行いたいシーンを開きます。
  2. メニューバーから Create > Empty Node を選択し、シーンに空ノードを追加します。
  3. 追加したノードの名前を分かりやすく CrashReporterNode などに変更します。
    • このノードは画面に表示される必要はありません。どのノードでも構いませんが、シーン開始時から常に存在するノードにアタッチするのがポイントです。

3. CrashReporter コンポーネントのアタッチ

  1. Hierarchy で先ほど作成した CrashReporterNode を選択します。
  2. 右側の Inspector パネルで、Add Component ボタンをクリックします。
  3. メニューから Custom > CrashReporter を選択してアタッチします。
    • もし Custom 内に CrashReporter が見つからない場合は、スクリプトの @ccclass('CrashReporter') 名とファイル名が正しいか、ビルドエラーが出ていないかを確認してください。

4. プロパティの設定

  1. endpointUrl に、実際のサーバー URL またはテスト用のエンドポイントを入力します。
    • 例: https://api.example.com/crash-report
    • テスト用に https://webhook.site/ などのサービスで一時 URL を発行し、そこを指定するのも便利です。
  2. appVersion に現在のゲームバージョンを入力します。
    • 例: 1.0.0
  3. environmentdevstaging にしておくと、サーバー側で環境別に集計できます。
  4. userId は任意ですが、テスト中であれば tester-001 など適当な文字列を入れておくと識別しやすくなります。
  5. maxReportsPerMinute は最初は 10 のままで構いません。
  6. maxQueueSize はデフォルトの 50 で問題ありません。
  7. sendTimeoutSeconds はネットワーク状況に応じて 510 秒程度を目安にします。
  8. includeStackTrace はテスト時は ON(チェック)にしておくと、スタックトレースが送信されて便利です。
  9. debugLog はテスト中は ON にして、送信処理のログを確認できるようにします。
  10. エディタ内のプレビューで実際に送信したい場合は、enabledInEditorON にします。
    • 本番ビルドのみ送信したい場合は OFF のままにしてください。

5. 動作確認(エラー発生テスト)

CrashReporter が正しく動作しているか確認するために、意図的にエラーを発生させてみます。

  1. テスト用に、別のスクリプト CrashTest.ts を作成します。
    • Assets > Create > TypeScriptCrashTest.ts を作成し、以下のような簡単なコードを記述します。

import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;

@ccclass('CrashTest')
export class CrashTest extends Component {
    start() {
        // 故意にエラーを発生させる
        throw new Error('CrashReporter test error from CrashTest.start()');
    }
}
  1. 任意の表示ノード(例: Canvas 配下の Sprite など)に CrashTest をアタッチします。
  2. CrashReporterNodeCrashReporter がアタッチされていることを確認します。
  3. エディタ上部の Play ボタンでプレビューを開始します。
  4. ゲーム開始直後に CrashTest.start()throw によりエラーが発生します。
    • コンソールに [CrashReporter] から始まるログが表示され、エラーが捕捉されたことを確認できます。
    • endpointUrl に指定したサーバー側(例: webhook.site)で、POST された JSON を確認します。
    • JSON には message, stack, timestamp, platform, appVersion, environment, userId などが含まれます。

これで、CrashReporter がグローバルエラーを正しく検知し、サーバーへ送信していることが確認できます。


まとめ

この CrashReporter コンポーネントは、

  • 任意の 1 ノードにアタッチするだけで、ゲーム全体のクラッシュや未処理エラーを自動監視。
  • インスペクタから送信先 URL やバージョン、環境、ユーザー ID、レート制限などを柔軟に設定可能。
  • 他のカスタムスクリプトやシングルトンに依存せず、スクリプト単体で完結。
  • レート制限とキュー制御により、エラー多発時のサーバー負荷やログスパムを抑制。

といった特徴を持つ、再利用性の高い汎用コンポーネントです。

実運用では、

  • サーバー側で CrashReportPayload の JSON を受け取り、ログストレージや監視サービス(Sentry など)へ転送する。
  • environmentappVersion 別にダッシュボードを作成し、どのバージョンでどのエラーがどれだけ発生しているかを可視化する。
  • 致命的なクラッシュのみを送信するように、サーバー側でフィルタリングする。

といった応用が可能です。

このコンポーネントをプロジェクトの共通基盤として組み込んでおけば、ビルドごとに自動でクラッシュ情報が集まり、原因調査や品質改善のサイクルが大幅に効率化されます。必要に応じて、送信する追加情報(例えば現在のシーン名、プレイヤーの進行度など)を extra フィールドに拡張していくと、より強力なクラッシュレポート基盤として活用できます。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!