【Cocos Creator 3.8】CrashReporter の実装:アタッチするだけでクラッシュエラーを自動送信する汎用スクリプト
このガイドでは、ゲーム中に発生したエラーやクラッシュ情報を自動で開発者サーバーへ送信する CrashReporter コンポーネントを実装します。任意の 1 ノードにアタッチしておくだけで、グローバルなエラー監視と自動送信が有効になり、他のカスタムスクリプトやマネージャに依存しない設計になっています。
本番環境でのクラッシュ原因の特定、QA ビルドでの不具合収集などに役立ちます。
コンポーネントの設計方針
1. 機能要件の整理
- ゲーム実行中に発生したエラー(例外)を検知する。
- 検知したエラー情報を HTTP POST で開発者サーバーへ送信する。
- 送信内容には、少なくとも以下を含める:
- エラーメッセージ
- スタックトレース
- 発生時刻
- プラットフォーム情報(Web / Native / Editor など)
- 任意のゲームバージョンやユーザー識別用 ID(任意設定)
- 送信先 URL や送信有無などはすべてインスペクタから設定可能とする。
- エラー多発時のスパムを防ぐため、一定時間あたりの送信数を制限できるようにする。
- 外部の GameManager やシングルトンに一切依存しない。
2. 外部依存をなくすためのアプローチ
- グローバルハンドラの登録
window.onerrorとwindow.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/onDestroyonLoadでinstallGlobalHandlers()を呼び、window.onerrorとwindow.onunhandledrejectionを登録します。onDestroyで元のハンドラに戻し、副作用を残さないようにしています。
- エラー検知
window.onerrorでは、同期的な実行時エラーやスローされた例外を捕捉。window.onunhandledrejectionでは、未処理の Promise 拒否を捕捉。- どちらも
buildPayload()で送信用データを組み立て、enqueueReport()でキューへ投入します。
- 環境判定とレート制限
shouldSendInCurrentEnvironment()でendpointUrlが空でないか- エディタ実行時に
enabledInEditorがtrueか
を確認し、送信可否を決定します。
checkRateLimit()で 1 分あたりの送信数を制限しています。
- 送信キューと HTTP 送信
_reportQueueに送信待ちのCrashReportPayloadを保持。processQueue()がキューを順次処理し、sendReport()で HTTP POST を実行します。sendReport()ではXMLHttpRequestを使用し、sendTimeoutSecondsで指定したタイムアウトを設けています。
- 防御的実装
endpointUrl未設定時は送信せず警告ログを出して終了。windowが存在しない環境(想定外)では何もしないようガード。- 既存の
window.onerror/onunhandledrejectionがあれば、上書き後も内部で呼び出すことで互換性を保っています。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - そのフォルダ上で右クリックし、Create > TypeScript を選択します。
- 作成されたファイル名を
CrashReporter.tsに変更します。 - ダブルクリックしてエディタ(VS Code など)で開き、テンプレートコードをすべて削除して、前述の
CrashReporterコードを丸ごと貼り付けて保存します。
2. テスト用ノードの作成
- Hierarchy パネルで、エラー監視を行いたいシーンを開きます。
- メニューバーから Create > Empty Node を選択し、シーンに空ノードを追加します。
- 追加したノードの名前を分かりやすく
CrashReporterNodeなどに変更します。- このノードは画面に表示される必要はありません。どのノードでも構いませんが、シーン開始時から常に存在するノードにアタッチするのがポイントです。
3. CrashReporter コンポーネントのアタッチ
- Hierarchy で先ほど作成した
CrashReporterNodeを選択します。 - 右側の Inspector パネルで、Add Component ボタンをクリックします。
- メニューから Custom > CrashReporter を選択してアタッチします。
- もし
Custom内にCrashReporterが見つからない場合は、スクリプトの@ccclass('CrashReporter')名とファイル名が正しいか、ビルドエラーが出ていないかを確認してください。
- もし
4. プロパティの設定
- endpointUrl に、実際のサーバー URL またはテスト用のエンドポイントを入力します。
- 例:
https://api.example.com/crash-report - テスト用に https://webhook.site/ などのサービスで一時 URL を発行し、そこを指定するのも便利です。
- 例:
- appVersion に現在のゲームバージョンを入力します。
- 例:
1.0.0
- 例:
- environment を
devやstagingにしておくと、サーバー側で環境別に集計できます。 - userId は任意ですが、テスト中であれば
tester-001など適当な文字列を入れておくと識別しやすくなります。 - maxReportsPerMinute は最初は
10のままで構いません。 - maxQueueSize はデフォルトの
50で問題ありません。 - sendTimeoutSeconds はネットワーク状況に応じて
5〜10秒程度を目安にします。 - includeStackTrace はテスト時は
ON(チェック)にしておくと、スタックトレースが送信されて便利です。 - debugLog はテスト中は
ONにして、送信処理のログを確認できるようにします。 - エディタ内のプレビューで実際に送信したい場合は、enabledInEditor を
ONにします。- 本番ビルドのみ送信したい場合は
OFFのままにしてください。
- 本番ビルドのみ送信したい場合は
5. 動作確認(エラー発生テスト)
CrashReporter が正しく動作しているか確認するために、意図的にエラーを発生させてみます。
- テスト用に、別のスクリプト
CrashTest.tsを作成します。- Assets > Create > TypeScript で
CrashTest.tsを作成し、以下のような簡単なコードを記述します。
- Assets > Create > TypeScript で
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()');
}
}
- 任意の表示ノード(例:
Canvas配下のSpriteなど)にCrashTestをアタッチします。 - CrashReporterNode に
CrashReporterがアタッチされていることを確認します。 - エディタ上部の Play ボタンでプレビューを開始します。
- ゲーム開始直後に
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 など)へ転送する。 environmentやappVersion別にダッシュボードを作成し、どのバージョンでどのエラーがどれだけ発生しているかを可視化する。- 致命的なクラッシュのみを送信するように、サーバー側でフィルタリングする。
といった応用が可能です。
このコンポーネントをプロジェクトの共通基盤として組み込んでおけば、ビルドごとに自動でクラッシュ情報が集まり、原因調査や品質改善のサイクルが大幅に効率化されます。必要に応じて、送信する追加情報(例えば現在のシーン名、プレイヤーの進行度など)を extra フィールドに拡張していくと、より強力なクラッシュレポート基盤として活用できます。




