Godotでそこそこ規模のあるゲームを書き始めると、テスト環境では再現できないクラッシュやエラーに悩まされますよね。特にリリース後、「ユーザー環境だけで起こる謎クラッシュ」を追うのは本当に大変です。

Godot標準でも --verbose でログを出したり、エディタ上ではスタックトレースが見られますが、

  • 本番ビルドではユーザーのコンソールログを回収するのが難しい
  • クラッシュ情報を手動でメールやスクショでもらうのは現実的ではない
  • 毎回ゲームごとに「例外ハンドラ+HTTP送信処理」を書くのが面倒

こういう「横断的なエラーログ送信機能」を、プレイヤーや敵などのゲームロジックにベタ書きすると、あっという間にスクリプトがカオスになります。そこで登場するのが、今回のコンポーネント「CrashReporter」です。

CrashReporter コンポーネントをシーンに 1 つアタッチしておくだけで、

  • 未処理エラー(push_error / push_warning など)をフック
  • スタックトレース・OS情報・ゲームバージョンなどをまとめて JSON 化
  • 開発者サーバーへ HTTP POST で自動送信

といったことができるようになります。ゲームロジックとは完全に独立した「監視・通報コンポーネント」として組み込めるので、まさに「継承より合成」で、どんなプロジェクトにもポン付けしやすい構造になっています。

【Godot 4】クラッシュも怖くない!「CrashReporter」コンポーネント

以下では、Godot 4 用の CrashReporter コンポーネントの完全な実装例を紹介します。シーンに 1 つ置くだけで動くようにしてあるので、そのままコピペして試してみてください。

フルコード(GDScript / Godot 4)


extends Node
class_name CrashReporter
## ゲーム内で発生したエラーやクラッシュ情報をサーバーに送信するコンポーネント。
##
## シーンに 1 つ置いておくと、エラー発生時に自動でログを収集して
## HTTP POST で開発者サーバーに送信します。
##
## 想定用途:
## - リリースビルドでのクラッシュレポート収集
## - ユーザー環境でのみ発生する謎エラーの調査
## - QA ビルドでの自動エラーレポート

## ==========================
## エクスポート設定
## ==========================

@export var enabled: bool = true:
	set(value):
		enabled = value
		if not is_inside_tree():
			return
		# 有効/無効切り替え時にシグナル接続を更新
		_update_error_connections()

## エラー送信先の URL(必須)
## 例: "https://example.com/api/crash-report"
@export var endpoint_url: String = ""

## HTTP タイムアウト秒数
@export var request_timeout_sec: float = 10.0

## 送信時に一緒に送るゲームバージョン
## ビルドスクリプトなどから自動で書き換えると便利
@export var game_version: String = "1.0.0"

## 任意の環境タグ(例: "dev", "staging", "production")
@export var environment: String = "production"

## ログをローカルファイルにも書き出すかどうか
## リモート送信に失敗したときの保険として有効
@export var write_local_log: bool = true

## ローカルログの保存先パス
## user:// はユーザーごとの書き込み可能領域
@export var local_log_path: String = "user://crash_reports.log"

## 同一内容のエラーを何回まで送るか(スパム防止)
## 0 以下なら無制限
@export var max_reports_per_signature: int = 3

## デバッグビルドでも送信するかどうか
## オンにすると、エディタ実行時にもサーバーへ飛ぶので注意
@export var send_in_debug: bool = false

## Godot の標準出力にもエラーレポートを表示するか
@export var print_report_to_console: bool = true

## 送信時にユーザー確認ダイアログを出すかどうか
## オンにすると、クラッシュ時に「エラーレポートを送信しますか?」と確認できます
@export var ask_user_before_sending: bool = false

## 簡易的な匿名ユーザー ID(OS.get_unique_id() などと組み合わせてもよい)
@export var user_id: String = ""

## 任意の追加メタデータを JSON 文字列で設定
## 例: {"channel": "steam", "region": "JP"}
@export_multiline var extra_metadata_json: String = "{}"

## ==========================
## 内部用メンバ
## ==========================

var _http: HTTPRequest
var _report_counts: Dictionary = {}  # signature => count

## Godot 4 では project_settings のシグナルでエラーを拾う仕組みはないので、
## 例として「手動で呼び出す API + push_error フック」を用意します。
## 実運用では、ゲームの「致命的エラー発生箇所」から
## `CrashReporter.report_exception(...)` を呼ぶ想定です。

func _ready() -> void:
	# HTTPRequest ノードを内部で生成して使い回す
	_http = HTTPRequest.new()
	_http.timeout = request_timeout_sec
	add_child(_http)

	# HTTP 完了シグナルを受け取る
	_http.request_completed.connect(_on_request_completed)

	# user_id が空なら、簡易的に OS 情報から生成
	if user_id.is_empty():
		user_id = _generate_anonymous_user_id()

	_update_error_connections()

	# 一応、起動時に環境情報をログに出しておくとデバッグしやすい
	if print_report_to_console:
		print("[CrashReporter] Initialized. version=%s env=%s user_id=%s" % [game_version, environment, user_id])

func _update_error_connections() -> void:
	# 今回は「手動呼び出し前提」なので、Godot のグローバルエラーシグナルには
	# 特に接続していませんが、将来的に EditorPlugin 等でフックするならここで。
	pass


## ==========================
## 公開 API
## ==========================

## 任意の場所から呼び出せる「エラーレポート送信」のエントリポイント。
## 例:
##   CrashReporter.report_exception("NullReference", "player.gd:42", get_stack())
##
## @param error_type   エラー種別(例: "NullReference", "AssertionFailed")
## @param message      エラーメッセージ
## @param stack_trace  スタックトレース(省略可)。省略時は OS.get_stack() を使う。
## @param extra        任意の追加情報(Dictionary)
func report_exception(
	error_type: String,
	message: String,
	stack_trace: Array = [],
	extra: Dictionary = {}
) -> void:
	if not enabled:
		return

	# デバッグビルドでは送らない設定ならここで終了
	if not send_in_debug and OS.is_debug_build():
		return

	if endpoint_url.is_empty():
		push_warning("[CrashReporter] endpoint_url が設定されていないため、レポートを送信できません。")
		return

	# スタックトレースが渡されていない場合は OS.get_stack() を使う
	if stack_trace.is_empty():
		stack_trace = OS.get_stack()

	var report := _build_report_payload(error_type, message, stack_trace, extra)

	# スパム防止チェック
	var signature := _make_signature(report)
	if max_reports_per_signature > 0:
		var count := _report_counts.get(signature, 0)
		if count >= max_reports_per_signature:
			if print_report_to_console:
				print("[CrashReporter] Report for signature '%s' reached max count (%d). Skipping." % [signature, max_reports_per_signature])
			return
		_report_counts[signature] = count + 1

	# ローカルログにも書き出しておく
	if write_local_log:
		_write_local_log(report)

	# コンソールにも表示
	if print_report_to_console:
		print("[CrashReporter] Sending crash report:\n", JSON.stringify(report, "\t"))

	# ユーザー確認が必要なら、ここでダイアログを出す(今回は簡易実装で自動許可)
	if ask_user_before_sending:
		# 実際には ConfirmationDialog などを使って確認 UI を出す。
		# ここではサンプルとして即送信。
		print("[CrashReporter] ask_user_before_sending is true, but confirmation UI is not implemented. Auto-sending.")

	_send_http_report(report)


## 「致命的エラーが起きた」ときにまとめて呼び出すためのヘルパー。
## 例:
##   func _on_fatal_error(msg: String) -> void:
##       CrashReporter.report_fatal("FatalError", msg)
##
func report_fatal(error_type: String, message: String, extra: Dictionary = {}) -> void:
	report_exception(error_type, message, [], extra)


## ==========================
## 内部処理
## ==========================

func _build_report_payload(
	error_type: String,
	message: String,
	stack_trace: Array,
	extra: Dictionary
) -> Dictionary:
	var metadata: Dictionary = {}
	# extra_metadata_json をパースして Dictionary にマージ
	if not extra_metadata_json.is_empty():
		var parse_result := JSON.new().parse(extra_metadata_json)
		if parse_result == OK:
			var parsed := JSON.new().get_data()
			if typeof(parsed) == TYPE_DICTIONARY:
				metadata = parsed
		else:
			push_warning("[CrashReporter] extra_metadata_json のパースに失敗しました。")

	# 呼び出し側から渡された extra を上書き優先でマージ
	for k in extra.keys():
		metadata[k] = extra[k]

	var report: Dictionary = {
		"timestamp": Time.get_datetime_string_from_system(true),
		"game_version": game_version,
		"environment": environment,
		"user_id": user_id,
		"error_type": error_type,
		"message": message,
		"stack_trace": stack_trace,
		"os_name": OS.get_name(),
		"os_version": OS.get_version(),
		"locale": OS.get_locale(),
		"screen_size": DisplayServer.window_get_size(),
		"engine_version": Engine.get_version_info(),
		"metadata": metadata
	}
	return report


func _make_signature(report: Dictionary) -> String:
	# 簡易的に「エラー種別 + メッセージ + 最初のスタックフレーム」をハッシュにする
	var base := "%s|%s" % [report.get("error_type", ""), report.get("message", "")]
	var stack := report.get("stack_trace", [])
	if stack.size() > 0:
		base += "|%s" % str(stack[0])
	return base.sha256_text()


func _send_http_report(report: Dictionary) -> void:
	# すでにリクエスト中なら一旦キャンセル(簡易実装)
	if _http.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
		_http.cancel_request()

	var headers := [
		"Content-Type: application/json",
		"Accept: application/json"
	]

	var body := JSON.stringify(report)
	var err := _http.request(
		endpoint_url,
		headers,
		HTTPClient.METHOD_POST,
		body
	)

	if err != OK:
		push_warning("[CrashReporter] HTTP request failed to start: %s" % error_string(err))


func _on_request_completed(
	result: int,
	response_code: int,
	headers: PackedStringArray,
	body: PackedByteArray
) -> void:
	if result != HTTPRequest.RESULT_SUCCESS:
		push_warning("[CrashReporter] HTTP request failed with result=%d" % result)
		return

	if response_code < 200 or response_code >= 300:
		push_warning("[CrashReporter] Server responded with HTTP %d" % response_code)
		return

	if print_report_to_console:
		print("[CrashReporter] Crash report successfully sent. HTTP %d" % response_code)


func _write_local_log(report: Dictionary) -> void:
	var file := FileAccess.open(local_log_path, FileAccess.WRITE_READ)
	if file == null:
		push_warning("[CrashReporter] Failed to open local log file: %s" % local_log_path)
		return

	# 既存内容の末尾に追記したいので、一度末尾にシーク
	file.seek_end()
	file.store_line(JSON.stringify(report))
	file.close()


func _generate_anonymous_user_id() -> String:
	# 完全な UUID ではないが、簡易的な匿名 ID として十分
	var base := "%s|%s|%s" % [
		OS.get_name(),
		OS.get_locale(),
		Time.get_unix_time_from_system()
	]
	return base.sha256_text()

使い方の手順

ここからは、実際にプロジェクトへ組み込む手順を見ていきましょう。

① スクリプトを保存して、グローバルクラスとして認識させる

  1. 上記コードを res://addons/crash_reporter/CrashReporter.gd などに保存します。
  2. class_name CrashReporter を定義しているので、エディタを再読み込みすると、スクリプトをアタッチする際に「CrashReporter」がクラス候補として出てくるようになります。

② 共通の「監視ノード」としてシーンに 1 個だけ置く

CrashReporter は基本的に「シーンに 1 つ」あれば十分です。よくある構成は、ゲーム全体を管理する Main シーンや GameRoot シーンに置くパターンですね。

Main (Node)
 ├── GameRoot (Node)
 │    ├── Player (CharacterBody2D)
 │    ├── EnemySpawner (Node)
 │    └── ...
 ├── UIRoot (CanvasLayer)
 │    └── ...
 └── CrashReporter (Node)   <-- このコンポーネントをアタッチ

手順:

  1. 任意のシーン(例: Main.tscn)を開く。
  2. 子ノードとして Node を 1 つ追加し、名前を CrashReporter にする。
  3. そのノードに、先ほどの CrashReporter.gd スクリプトをアタッチする。

③ エクスポート変数を設定する

CrashReporter ノードを選択すると、インスペクタに以下のパラメータが表示されます。

  • endpoint_url: あなたのサーバー側のクラッシュレポート受け取り API URL(例: https://example.com/api/crash-report
  • game_version: ゲームのバージョン文字列(例: 1.2.3
  • environment: dev / staging / production など
  • send_in_debug: エディタ実行時にも送信したいなら ON
  • write_local_log: リモート送信失敗時のためにローカルにも残したいなら ON
  • extra_metadata_json: 任意の JSON(例: {"channel":"steam","region":"JP"}

これで、あとはゲームコード側から CrashReporter.report_exception(...) を呼ぶだけで OK です。

④ 実際にエラー発生箇所から呼び出す(具体例)

コンポーネント志向の考え方として、プレイヤーや敵のロジック自体に「HTTP 通信の実装」を書きたくないですよね。CrashReporter は どこからでも呼べる共通サービス として使います。

例1: プレイヤーコンポーネントから致命的エラーを報告
Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PlayerController (Script)  <-- ゲームロジック

PlayerController.gd の一部:


extends CharacterBody2D

var crash_reporter: CrashReporter

func _ready() -> void:
	# シーンツリーから CrashReporter を探す(最初に見つかった一つを使う)
	crash_reporter = get_tree().get_first_node_in_group("crash_reporter") as CrashReporter
	if crash_reporter == null:
		# group を使わない場合は、ルートから直接パス指定してもよい
		crash_reporter = get_tree().root.get_node_or_null("Main/CrashReporter") as CrashReporter

func _physics_process(delta: float) -> void:
	# 例: 想定外の状態になったら致命的エラーとして報告する
	if is_nan(velocity.x) or is_nan(velocity.y):
		if crash_reporter:
			crash_reporter.report_fatal(
				"NaNVelocity",
				"Player velocity became NaN",
				{"position": global_position}
			)
		# 必要ならゲームを停止するなど
		get_tree().paused = true

CrashReporter を「crash_reporter グループ」に入れておくと、どのノードからでも簡単に取得できます。


# CrashReporter ノード側の _ready でグループ登録しておくと便利
func _ready() -> void:
	add_to_group("crash_reporter")
	# ...(前述の初期化処理)...
例2: 動く床コンポーネントから例外を報告
MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── MovingPlatformController (Script)

extends Node2D

var crash_reporter: CrashReporter

func _ready() -> void:
	crash_reporter = get_tree().get_first_node_in_group("crash_reporter") as CrashReporter

func _process(delta: float) -> void:
	# 例: 経路データが壊れていた場合
	if not _has_valid_path():
		if crash_reporter:
			crash_reporter.report_exception(
				"InvalidPlatformPath",
				"MovingPlatform path data is invalid",
				[],
				{"platform_name": name}
			)
		queue_free()

func _has_valid_path() -> bool:
	# ここではダミー
	return true

このように、「各コンポーネントは自分の責務だけを持ち、エラー報告は CrashReporter に丸投げ」という構造にすると、ノード階層もスクリプトもかなりスッキリしますね。

メリットと応用

CrashReporter コンポーネントを導入するメリットを整理してみましょう。

  • シーン構造がシンプル
    監視・ログ送信ロジックを、各キャラクターや UI ノードに継承させる必要がなく、ルートに 1 つ置くだけで済みます。
  • 再利用性が高い
    別プロジェクトでも CrashReporter.gd をコピペしてシーンにアタッチするだけで、同じ仕組みをすぐに再利用できます。
  • 責務分離が明確
    プレイヤーはプレイヤーの動きにだけ集中し、CrashReporter は「エラーを集めて送る」ことだけを担当します。継承ベースだと混ざりがちな責務が、コンポーネント化で綺麗に分離できます。
  • レベルデザインへの影響が少ない
    各ステージシーンにいちいち「ログ送信ロジック」を仕込む必要がなく、ルートシーン(Main など)に 1 つ置いておけば、どのレベルからでも利用できます。

さらに、CrashReporter 自体もコンポーネントなので、プロジェクトの成長に合わせて少しずつ機能を足していくのも簡単です。

改造案:エディタ上でテスト送信できるボタンを追加する

本番前に「ちゃんとサーバーまで届くか?」を確認したくなりますよね。エディタ上からワンクリックでテストレポートを送れるようにする改造例です。


@tool
extends Node
class_name CrashReporter
# ...(前述のコード)...

@export_category("Debug")
@export var send_test_report: bool:
	set(value):
		if value:
			_send_test_report()
		send_test_report = false  # ボタン風に戻す

func _send_test_report() -> void:
	if not Engine.is_editor_hint():
		return
	var dummy_stack := [
		{"function": "_ready", "file": "res://dummy.gd", "line": 42}
	]
	report_exception(
		"TestReport",
		"This is a test crash report from editor.",
		dummy_stack,
		{"note": "editor test"}
	)

@tool を付けることで、エディタ上でもスクリプトが動作し、インスペクタで send_test_report のチェックを入れるとテストレポートが飛ぶようになります。こういった「デバッグ用の機能」も、CrashReporter という 1 つのコンポーネントに閉じ込めておけば、他のノードに余計なデバッグコードを書かずに済みますね。

継承ベースで巨大な「GodGameManager.gd」を作ってしまう前に、こうしたコンポーネントを小さく積み重ねていくスタイルをぜひ試してみてください。