Updated telemetry to support game dumps.

This commit is contained in:
2026-09-15 21:15:39 +02:00
parent c278efa339
commit 67d89b40d5
17 changed files with 805 additions and 99 deletions

View File

@@ -15,6 +15,10 @@ from typing import Any
from uuid import uuid4
from ...command import CommandOutcome
from ...diagnostics.crash_dumps import (
inspect_minidump,
prepare_minidump_attachment,
)
from ...diagnostics.native_logs import LogFileState
from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm
from ...launcher import LauncherError
@@ -383,15 +387,12 @@ def play_locked(
runtime_issues["game_window_ready"] = game_window_ready
if game_readiness_signal is not None:
runtime_issues["game_readiness_signal"] = game_readiness_signal
if (
selection.kind == "proton7-container"
and runtime_issues["host_futex_fatal_count"]
):
runtime_issues["recommended_one_variable_comparison"] = "proton7"
elif selection.kind == "proton7" and runtime_issues["gstreamer_missing_libraries"]:
runtime_issues["recommended_one_variable_comparison"] = "proton7-container"
elif runtime_issues["dbghelp_symdeferred_assertion_count"]:
runtime_issues["recommended_one_variable_comparison"] = "umu"
recommended_comparison = recommended_runtime_comparison(
selection.kind,
runtime_issues,
)
if recommended_comparison is not None:
runtime_issues["recommended_one_variable_comparison"] = recommended_comparison
clean_exit = outcome.returncode == 0 and current_game_crash is None
launch_evidence = game_readiness_signal
if launch_evidence is None and current_game_log is not None:
@@ -439,6 +440,38 @@ def play_locked(
"realm": start_option.name,
**api._game_exit_metadata(outcome.returncode, selection.kind),
}
if not clean_exit:
common_game_metadata["failure_reason"] = game_failure_reason(
outcome,
current_game_log,
current_game_crash,
common_game_metadata,
)
failure_evidence: dict[str, object] = {
"native_game_log_present": current_game_log is not None,
}
if current_game_log is not None:
failure_evidence["native_game_log"] = game_log_telemetry_summary(
current_game_log
)
if current_game_crash is not None:
failure_evidence["crash_dump_info"] = crash_dump_telemetry_summary(
current_game_crash
)
crash_dump_attachment: dict[str, object] | None = None
if (
current_game_crash is not None
and settings.game_crash_dump_upload_enabled
and telemetry.enabled
):
crash_path = current_game_crash.get("path")
if isinstance(crash_path, str):
upload_status, crash_dump_attachment = prepare_minidump_attachment(
Path(crash_path)
)
else:
upload_status = "path_unavailable"
failure_evidence["crash_dump_upload_status"] = upload_status
if launch_succeeded:
if not launch_success_reported:
assert launch_evidence is not None
@@ -461,7 +494,7 @@ def play_locked(
"game_launch_failed",
severity="error",
message=(
"Game client wrote a crash dump"
game_crash_message(current_game_log)
if current_game_crash is not None
else "Game session timed out"
if outcome.timed_out
@@ -469,23 +502,28 @@ def play_locked(
),
metadata={
**common_game_metadata,
**failure_evidence,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
},
context={"operation": "launch_game", "stage": "game_start"},
attachment=crash_dump_attachment,
)
if launch_succeeded:
exit_message = None
if current_game_crash is not None:
exit_message = "Game client wrote a crash dump"
exit_message = game_crash_message(current_game_log)
elif outcome.timed_out:
exit_message = "Game session timed out"
elif common_game_metadata.get("signal_inferred") is True:
signal_wrapper = (
"Proton" if selection.kind == "proton7" else "UMU"
)
exit_message = (
"Game session ended with status "
f"{common_game_metadata['raw_exit_code']}, consistent with "
f"{common_game_metadata['signal_name']} wrapped by UMU"
f"{common_game_metadata['signal_name']} wrapped by {signal_wrapper}"
)
elif "signal" in common_game_metadata:
exit_message = (
@@ -507,12 +545,14 @@ def play_locked(
if clean_exit
else {
**common_game_metadata,
**failure_evidence,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
}
),
context={"operation": "launch_game", "stage": "exited"},
attachment=crash_dump_attachment,
)
if as_json or dry_run:
api._emit(report, as_json)
@@ -553,10 +593,15 @@ def game_exit_metadata(
)
return metadata
# UMU returns Popen's negative child status from a Python main function.
# SystemExit encodes -SIGKILL as 247 before our Python gamebox sees it.
# UMU and the retained Proton 7 Python entry point return a negative child
# status from subprocess. SystemExit encodes -SIGKILL as 247 before our
# Python gamebox sees it.
wrapped_sigkill = 256 - int(signal.SIGKILL)
if runtime_kind in {"umu", "proton7-container"} and returncode == wrapped_sigkill:
if (
runtime_kind in {"umu", "proton7", "proton7-container"}
and returncode == wrapped_sigkill
):
wrapper = "proton" if runtime_kind == "proton7" else "umu"
metadata.update(
{
"exit_code": 128 + int(signal.SIGKILL),
@@ -564,12 +609,65 @@ def game_exit_metadata(
"signal": int(signal.SIGKILL),
"signal_name": signal.SIGKILL.name,
"signal_inferred": True,
"signal_inference": "umu_python_system_exit",
"signal_inference": f"{wrapper}_python_system_exit",
}
)
return metadata
def recommended_runtime_comparison(
runtime_kind: str,
runtime_issues: dict[str, object],
) -> str | None:
"""Choose a useful one-variable comparison from launch-relevant evidence."""
if (
runtime_kind == "proton7-container"
and runtime_issues.get("host_futex_fatal_count")
):
return "proton7"
if (
runtime_kind == "proton7"
and runtime_issues.get("gstreamer_missing_libraries")
and (
runtime_issues.get("gstreamer_stream_error") is True
or runtime_issues.get("game_window_ready") is not True
)
):
return "proton7-container"
if runtime_issues.get("dbghelp_symdeferred_assertion_count"):
return "umu"
return None
def game_failure_reason(
outcome: CommandOutcome,
current_game_log: dict[str, object] | None,
current_game_crash: dict[str, object] | None,
exit_metadata: dict[str, object],
) -> str:
"""Classify the strongest evidence recorded for a failed game session."""
if current_game_log and current_game_log.get("runtime_out_of_memory") is True:
return "direct3d_resource_exhaustion"
if current_game_crash is not None:
return "client_crash_dump"
if outcome.timed_out:
return "session_timeout"
if "signal" in exit_metadata:
return "process_signal"
return "nonzero_exit"
def game_crash_message(current_game_log: dict[str, object] | None) -> str:
if (
current_game_log is not None
and current_game_log.get("runtime_out_of_memory") is True
):
return "Game client logged Direct3D resource exhaustion and wrote a crash dump"
return "Game client wrote a crash dump"
def signal_name(signal_number: int) -> str:
try:
return signal.Signals(signal_number).name
@@ -654,6 +752,7 @@ def current_game_log(
],
"failed_import_scenes": analysis.failed_import_scenes,
"stage_without_new_stage": analysis.stage_without_new_stage,
"tex_reclaim_count": analysis.tex_reclaim_count,
"module_code_sync_error": analysis.module_code_sync_error,
"module_code_sync_left": analysis.module_code_sync_left,
"module_code_sync_right": analysis.module_code_sync_right,
@@ -702,13 +801,59 @@ def current_game_crash(
)
if state is None:
return None
return {
report: dict[str, object] = {
"path": str(state.path),
"modified_at": datetime.fromtimestamp(
state.mtime_ns / 1_000_000_000
).astimezone().isoformat(),
"size": state.size,
}
minidump = inspect_minidump(state.path)
if minidump is not None:
report["minidump"] = minidump
return report
def game_log_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
"""Keep the native-log evidence useful while bounding telemetry cardinality."""
keys = (
"modified_at",
"size",
"load_texture_to_material_failed",
"import_scene_threw",
"asset_pack_health_failed",
"managed_asset_integrity_failed",
"runtime_out_of_memory",
"out_of_memory_count",
"out_of_memory_effect_paths",
"failed_import_scenes",
"stage_without_new_stage",
"tex_reclaim_count",
"module_code_sync_error",
"module_code_sync_left",
"module_code_sync_right",
"module_code_sync_delta",
"banned_code_1",
"failed_file_find_count",
"failed_file_load_count",
"failed_file_read_count",
"failed_asset_file_count",
"failed_texture_file_count",
"failed_effect_file_count",
"missing_texture_reference_count",
"missing_effect_reference_count",
"missing_image_reference_count",
"missing_pack_image_reference_count",
)
return {key: report[key] for key in keys if key in report}
def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
"""Exclude the local dump path while retaining its bounded signature."""
keys = ("modified_at", "size", "minidump")
return {key: report[key] for key in keys if key in report}
def runtime_issue_summary(stderr: str) -> dict[str, object]: