Added telemetry system to get information about issues.
This commit is contained in:
713
ptlinux/cli.py
713
ptlinux/cli.py
@@ -45,6 +45,7 @@ from .importer import GameImportResult, discover_existing_game, import_existing_
|
||||
from .import_state import game_import_requires_verification, mark_game_import_verified
|
||||
from .launcher import (
|
||||
LauncherError,
|
||||
WineTools,
|
||||
configure_launcher_prefix,
|
||||
find_wine_tools,
|
||||
launch_official_updater,
|
||||
@@ -75,6 +76,14 @@ from .self_update import (
|
||||
update_check_to_dict,
|
||||
verify_launcher_update_channel,
|
||||
)
|
||||
from .telemetry import (
|
||||
TelemetryService,
|
||||
classify_network_failure,
|
||||
current_telemetry,
|
||||
initialize_shared_telemetry,
|
||||
network_http_status,
|
||||
shutdown_shared_telemetry,
|
||||
)
|
||||
from .umu import (
|
||||
UmuBootstrapResult,
|
||||
find_umu_launcher,
|
||||
@@ -266,114 +275,55 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
_manage_telemetry: bool | None = None,
|
||||
) -> int:
|
||||
parser = build_parser()
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command or "gui"
|
||||
paths = AppPaths.from_environment()
|
||||
manage_telemetry = argv is None if _manage_telemetry is None else _manage_telemetry
|
||||
telemetry: TelemetryService | None = None
|
||||
telemetry_owner = False
|
||||
if manage_telemetry:
|
||||
interface = "gui" if command == "gui" else "cli"
|
||||
initialized_telemetry, telemetry_owner = initialize_shared_telemetry(
|
||||
paths,
|
||||
session_metadata={"interface": interface, "command": command},
|
||||
)
|
||||
telemetry = initialized_telemetry
|
||||
if command != "gui":
|
||||
initialized_telemetry.mark_ready(
|
||||
{"interface": interface, "command": command}
|
||||
)
|
||||
telemetry_events = _command_telemetry_events(command, arguments, paths)
|
||||
operation_started = time.monotonic()
|
||||
if telemetry is not None and telemetry_events is not None:
|
||||
operation, started_event, _success_event, _failed_event = telemetry_events
|
||||
if operation == "repair":
|
||||
telemetry.send_event(
|
||||
"repair_requested",
|
||||
metadata={"command": command, "reason": "user_requested"},
|
||||
context={"operation": operation, "stage": "requested"},
|
||||
)
|
||||
telemetry.send_event(
|
||||
started_event,
|
||||
metadata={"command": command},
|
||||
context={"operation": operation, "stage": "started"},
|
||||
)
|
||||
try:
|
||||
if command == "gui":
|
||||
provision_umu_for_startup(paths)
|
||||
_container_host_startup_migration(paths)
|
||||
from .ui import run_dashboard
|
||||
|
||||
run_dashboard(paths)
|
||||
return 0
|
||||
if command == "doctor":
|
||||
return _doctor(arguments.json)
|
||||
if command == "container-host":
|
||||
return _container_host(arguments.repair, arguments.json)
|
||||
if command == "runtime-bootstrap":
|
||||
return _runtime_bootstrap(paths, arguments.json)
|
||||
if command == "login-diagnostics":
|
||||
return _login_diagnostics(
|
||||
paths,
|
||||
arguments.timeout,
|
||||
arguments.runtime,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "fetch":
|
||||
return _fetch(paths, arguments.dry_run, arguments.json)
|
||||
if command == "setup":
|
||||
return _setup(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.force_runtime,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "update":
|
||||
return _native_update(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.repair,
|
||||
arguments.seed_from,
|
||||
arguments.no_import,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "sync":
|
||||
return _native_update(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.repair,
|
||||
None,
|
||||
True,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "wine-update":
|
||||
return _wine_update(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.force_runtime,
|
||||
arguments.seed_from,
|
||||
arguments.no_import,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "import-game":
|
||||
return _import_game(
|
||||
paths,
|
||||
arguments.source,
|
||||
arguments.dry_run,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "cache-compatibility":
|
||||
result = cache_compatibility_binaries(paths, arguments.source)
|
||||
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
|
||||
return 0
|
||||
if command == "prepare":
|
||||
return _prepare(paths, arguments.game_dir, arguments.json)
|
||||
if command == "play":
|
||||
return _play(
|
||||
paths,
|
||||
arguments.game_dir,
|
||||
arguments.no_box,
|
||||
arguments.dry_run,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.json,
|
||||
realm=arguments.realm,
|
||||
)
|
||||
if command == "login-trace":
|
||||
return _login_trace(
|
||||
paths,
|
||||
arguments.game_dir,
|
||||
arguments.no_box,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.timeout,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "settings":
|
||||
return _settings(paths, arguments.init, arguments.reset, arguments.json)
|
||||
if command == "launcher-update":
|
||||
return _launcher_update(
|
||||
paths,
|
||||
arguments.check,
|
||||
arguments.verify,
|
||||
arguments.rollback,
|
||||
arguments.manifest_url,
|
||||
arguments.public_key,
|
||||
arguments.json,
|
||||
)
|
||||
parser.error(f"unknown command: {command}")
|
||||
returncode = _dispatch_command(parser, arguments, command, paths)
|
||||
except KeyboardInterrupt:
|
||||
_report_command_failure(
|
||||
telemetry,
|
||||
telemetry_events,
|
||||
command,
|
||||
operation_started,
|
||||
"Operation interrupted",
|
||||
stage="interrupted",
|
||||
)
|
||||
message = "Interrupted; PristonTale and its compatibility runtime were stopped."
|
||||
if arguments.json:
|
||||
_print_json({"ok": False, "error": message, "interrupted": True})
|
||||
@@ -381,6 +331,22 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
print(f"\n{message}", file=sys.stderr)
|
||||
return 130
|
||||
except (ValueError, OSError, RuntimeError) as exc:
|
||||
_report_command_failure(
|
||||
telemetry,
|
||||
telemetry_events,
|
||||
command,
|
||||
operation_started,
|
||||
str(exc),
|
||||
stage=_failure_stage(command, exc),
|
||||
)
|
||||
if telemetry is not None and isinstance(exc, ValueError):
|
||||
telemetry.send_event(
|
||||
"configuration_error",
|
||||
severity="error",
|
||||
message=str(exc),
|
||||
metadata={"setting": "unknown", "reason": "invalid_value"},
|
||||
context={"operation": command, "stage": "configuration"},
|
||||
)
|
||||
if arguments.json:
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
@@ -391,6 +357,269 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if exc.outcome.stderr:
|
||||
print(exc.outcome.stderr.rstrip(), file=sys.stderr)
|
||||
return 1
|
||||
except BaseException as exc:
|
||||
if telemetry is not None:
|
||||
telemetry.report_exception(
|
||||
"launcher_exception",
|
||||
exc,
|
||||
metadata={"command": command},
|
||||
context={"operation": command, "stage": "dispatch"},
|
||||
severity="critical",
|
||||
)
|
||||
_report_command_failure(
|
||||
telemetry,
|
||||
telemetry_events,
|
||||
command,
|
||||
operation_started,
|
||||
str(exc),
|
||||
stage="dispatch",
|
||||
)
|
||||
raise
|
||||
else:
|
||||
_report_command_result(
|
||||
telemetry,
|
||||
telemetry_events,
|
||||
command,
|
||||
operation_started,
|
||||
returncode,
|
||||
)
|
||||
return returncode
|
||||
finally:
|
||||
if telemetry is not None and telemetry_owner:
|
||||
shutdown_shared_telemetry(telemetry)
|
||||
|
||||
|
||||
def _dispatch_command(
|
||||
parser: argparse.ArgumentParser,
|
||||
arguments: argparse.Namespace,
|
||||
command: str,
|
||||
paths: AppPaths,
|
||||
) -> int:
|
||||
if command == "gui":
|
||||
provision_umu_for_startup(paths)
|
||||
_container_host_startup_migration(paths)
|
||||
from .ui import run_dashboard
|
||||
|
||||
run_dashboard(paths)
|
||||
return 0
|
||||
if command == "doctor":
|
||||
return _doctor(arguments.json)
|
||||
if command == "container-host":
|
||||
return _container_host(arguments.repair, arguments.json)
|
||||
if command == "runtime-bootstrap":
|
||||
return _runtime_bootstrap(paths, arguments.json)
|
||||
if command == "login-diagnostics":
|
||||
return _login_diagnostics(
|
||||
paths,
|
||||
arguments.timeout,
|
||||
arguments.runtime,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "fetch":
|
||||
return _fetch(paths, arguments.dry_run, arguments.json)
|
||||
if command == "setup":
|
||||
return _setup(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.force_runtime,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "update":
|
||||
return _native_update(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.repair,
|
||||
arguments.seed_from,
|
||||
arguments.no_import,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "sync":
|
||||
return _native_update(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.repair,
|
||||
None,
|
||||
True,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "wine-update":
|
||||
return _wine_update(
|
||||
paths,
|
||||
arguments.dry_run,
|
||||
arguments.force_runtime,
|
||||
arguments.seed_from,
|
||||
arguments.no_import,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "import-game":
|
||||
return _import_game(
|
||||
paths,
|
||||
arguments.source,
|
||||
arguments.dry_run,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "cache-compatibility":
|
||||
result = cache_compatibility_binaries(paths, arguments.source)
|
||||
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
|
||||
return 0
|
||||
if command == "prepare":
|
||||
return _prepare(paths, arguments.game_dir, arguments.json)
|
||||
if command == "play":
|
||||
return _play(
|
||||
paths,
|
||||
arguments.game_dir,
|
||||
arguments.no_box,
|
||||
arguments.dry_run,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.json,
|
||||
realm=arguments.realm,
|
||||
)
|
||||
if command == "login-trace":
|
||||
return _login_trace(
|
||||
paths,
|
||||
arguments.game_dir,
|
||||
arguments.no_box,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.timeout,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "settings":
|
||||
return _settings(paths, arguments.init, arguments.reset, arguments.json)
|
||||
if command == "launcher-update":
|
||||
return _launcher_update(
|
||||
paths,
|
||||
arguments.check,
|
||||
arguments.verify,
|
||||
arguments.rollback,
|
||||
arguments.manifest_url,
|
||||
arguments.public_key,
|
||||
arguments.json,
|
||||
)
|
||||
parser.error(f"unknown command: {command}")
|
||||
|
||||
|
||||
def _command_telemetry_events(
|
||||
command: str,
|
||||
arguments: argparse.Namespace,
|
||||
paths: AppPaths,
|
||||
) -> tuple[str, str, str, str] | None:
|
||||
if bool(getattr(arguments, "dry_run", False)):
|
||||
return None
|
||||
if command in {"update", "sync", "wine-update"}:
|
||||
if bool(getattr(arguments, "repair", False)):
|
||||
return ("repair", "repair_started", "repair_success", "repair_failed")
|
||||
if not paths.source_game.exists():
|
||||
return ("install", "install_started", "install_success", "install_failed")
|
||||
return ("update", "update_started", "update_success", "update_failed")
|
||||
if command == "launcher-update" and not (
|
||||
bool(getattr(arguments, "check", False))
|
||||
or bool(getattr(arguments, "verify", False))
|
||||
or bool(getattr(arguments, "rollback", False))
|
||||
):
|
||||
return (
|
||||
"launcher_update",
|
||||
"update_started",
|
||||
"update_success",
|
||||
"update_failed",
|
||||
)
|
||||
if command in {"setup", "runtime-bootstrap"}:
|
||||
return (
|
||||
"runtime_install",
|
||||
"runtime_install_started",
|
||||
"runtime_install_success",
|
||||
"runtime_install_failed",
|
||||
)
|
||||
if command == "doctor":
|
||||
return (
|
||||
"compatibility_check",
|
||||
"compatibility_check_started",
|
||||
"compatibility_check_completed",
|
||||
"compatibility_check_failed",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _report_command_result(
|
||||
telemetry: TelemetryService | None,
|
||||
events: tuple[str, str, str, str] | None,
|
||||
command: str,
|
||||
started_at: float,
|
||||
returncode: int,
|
||||
) -> None:
|
||||
if telemetry is None or events is None:
|
||||
return
|
||||
operation, _started_event, success_event, failed_event = events
|
||||
duration_ms = round((time.monotonic() - started_at) * 1000)
|
||||
telemetry.send_event(
|
||||
success_event if returncode == 0 else failed_event,
|
||||
severity="info" if returncode == 0 else "error",
|
||||
message=None if returncode == 0 else f"Command exited with status {returncode}",
|
||||
metadata={
|
||||
"command": command,
|
||||
"duration_ms": duration_ms,
|
||||
"exit_code": returncode,
|
||||
},
|
||||
context={
|
||||
"operation": operation,
|
||||
"stage": "completed" if returncode == 0 else "command_exit",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _report_command_failure(
|
||||
telemetry: TelemetryService | None,
|
||||
events: tuple[str, str, str, str] | None,
|
||||
command: str,
|
||||
started_at: float,
|
||||
message: str,
|
||||
*,
|
||||
stage: str = "failed",
|
||||
) -> None:
|
||||
if telemetry is None:
|
||||
return
|
||||
if command == "play":
|
||||
telemetry.send_event(
|
||||
"game_launch_failed",
|
||||
severity="error",
|
||||
message=message,
|
||||
metadata={
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000),
|
||||
},
|
||||
context={"operation": "launch_game", "stage": "preflight"},
|
||||
)
|
||||
return
|
||||
if events is None:
|
||||
return
|
||||
operation, _started_event, _success_event, failed_event = events
|
||||
telemetry.send_event(
|
||||
failed_event,
|
||||
severity="error",
|
||||
message=message,
|
||||
metadata={
|
||||
"command": command,
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000),
|
||||
},
|
||||
context={"operation": operation, "stage": stage},
|
||||
)
|
||||
|
||||
|
||||
def _failure_stage(command: str, exception: BaseException) -> str:
|
||||
if isinstance(exception, ValueError):
|
||||
return "configuration"
|
||||
if isinstance(exception, CommandError):
|
||||
return "subprocess"
|
||||
message = str(exception).casefold()
|
||||
if any(value in message for value in ("manifest", "cdn", "request failed")):
|
||||
return "manifest"
|
||||
if any(value in message for value in ("download", "incomplete response")):
|
||||
return "download"
|
||||
if any(value in message for value in ("sha-1", "checksum", "hash mismatch")):
|
||||
return "validation"
|
||||
if any(value in message for value in ("permission", "read-only", "cannot write")):
|
||||
return "filesystem"
|
||||
if any(value in message for value in ("prefix", "wine", "proton", "umu")):
|
||||
return "runtime_check"
|
||||
return f"{command}_operation"
|
||||
|
||||
|
||||
def _doctor(as_json: bool) -> int:
|
||||
@@ -568,14 +797,84 @@ def _fetch(paths: AppPaths, dry_run: bool, as_json: bool) -> int:
|
||||
"prepared": str(paths.launcher_patched),
|
||||
}
|
||||
else:
|
||||
download = fetch_official_launcher(
|
||||
paths.launcher_pristine,
|
||||
paths.launcher_metadata,
|
||||
)
|
||||
patch = prepare_launcher_binary(
|
||||
paths.launcher_pristine,
|
||||
paths.launcher_patched,
|
||||
)
|
||||
telemetry = current_telemetry()
|
||||
download_started_at = time.monotonic()
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
"download_started",
|
||||
metadata={
|
||||
"resource_type": "launcher",
|
||||
"mirror_id": "pristontale.eu",
|
||||
},
|
||||
context={"operation": "download", "stage": "launcher_download"},
|
||||
)
|
||||
try:
|
||||
download = fetch_official_launcher(
|
||||
paths.launcher_pristine,
|
||||
paths.launcher_metadata,
|
||||
)
|
||||
except BaseException as exc:
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
"download_failed",
|
||||
severity="error",
|
||||
message=str(exc),
|
||||
metadata={
|
||||
"resource_type": "launcher",
|
||||
"duration_ms": round(
|
||||
(time.monotonic() - download_started_at) * 1000
|
||||
),
|
||||
"failure_type": classify_network_failure(exc),
|
||||
"http_status": network_http_status(exc),
|
||||
"mirror_id": "pristontale.eu",
|
||||
},
|
||||
context={"operation": "download", "stage": "launcher_download"},
|
||||
)
|
||||
raise
|
||||
download_duration = max(0.0, time.monotonic() - download_started_at)
|
||||
download_bytes = download.size if download.changed else 0
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
"download_completed",
|
||||
metadata={
|
||||
"resource_type": "launcher",
|
||||
"download_bytes": download_bytes,
|
||||
"content_bytes": download.size,
|
||||
"duration_ms": round(download_duration * 1000),
|
||||
"average_mbps": (
|
||||
round((download_bytes * 8) / download_duration / 1_000_000, 3)
|
||||
if download_bytes and download_duration > 0
|
||||
else None
|
||||
),
|
||||
"retry_count": 0,
|
||||
"resume_used": False,
|
||||
"http_status": 200 if download.changed else 304,
|
||||
"changed": download.changed,
|
||||
"mirror_id": "pristontale.eu",
|
||||
},
|
||||
context={"operation": "download", "stage": "launcher_download"},
|
||||
)
|
||||
validation_started_at = time.monotonic()
|
||||
try:
|
||||
patch = prepare_launcher_binary(
|
||||
paths.launcher_pristine,
|
||||
paths.launcher_patched,
|
||||
)
|
||||
except BaseException as exc:
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
"file_validation_failed",
|
||||
severity="error",
|
||||
message=str(exc),
|
||||
metadata={
|
||||
"file_category": "launcher",
|
||||
"duration_ms": round(
|
||||
(time.monotonic() - validation_started_at) * 1000
|
||||
),
|
||||
},
|
||||
context={"operation": "download", "stage": "validation"},
|
||||
)
|
||||
raise
|
||||
value = {
|
||||
"ok": True,
|
||||
"download": asdict(download),
|
||||
@@ -600,7 +899,7 @@ def _setup(
|
||||
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
|
||||
runner = _runner(paths, "setup", dry_run)
|
||||
with _operation_guard(paths, "setup", dry_run):
|
||||
results = configure_launcher_prefix(
|
||||
results = _configure_launcher_runtime(
|
||||
paths,
|
||||
tools,
|
||||
runner,
|
||||
@@ -696,7 +995,7 @@ def _wine_update(
|
||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||
download_options = _configured_download_options(settings)
|
||||
with _operation_guard(paths, "update", dry_run):
|
||||
configure_launcher_prefix(
|
||||
_configure_launcher_runtime(
|
||||
paths,
|
||||
tools,
|
||||
runner,
|
||||
@@ -751,6 +1050,69 @@ def _wine_update(
|
||||
return 0
|
||||
|
||||
|
||||
def _configure_launcher_runtime(
|
||||
paths: AppPaths,
|
||||
tools: WineTools,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
force_runtime: bool,
|
||||
) -> list[CommandOutcome]:
|
||||
if not force_runtime or runner.dry_run:
|
||||
return configure_launcher_prefix(
|
||||
paths,
|
||||
tools,
|
||||
runner,
|
||||
force_runtime=force_runtime,
|
||||
)
|
||||
telemetry = current_telemetry()
|
||||
started_at = time.monotonic()
|
||||
metadata = {
|
||||
"runtime_type": "dotnet_desktop",
|
||||
"runtime_version": "6",
|
||||
}
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
"runtime_reinstall_requested",
|
||||
metadata={**metadata, "reason": "force_runtime"},
|
||||
context={"operation": "runtime_repair", "stage": "requested"},
|
||||
)
|
||||
telemetry.send_event(
|
||||
"runtime_repair_started",
|
||||
metadata=metadata,
|
||||
context={"operation": "runtime_repair", "stage": "started"},
|
||||
)
|
||||
try:
|
||||
results = configure_launcher_prefix(
|
||||
paths,
|
||||
tools,
|
||||
runner,
|
||||
force_runtime=True,
|
||||
)
|
||||
except BaseException as exc:
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
"runtime_repair_failed",
|
||||
severity="error",
|
||||
message=str(exc),
|
||||
metadata={
|
||||
**metadata,
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000),
|
||||
},
|
||||
context={"operation": "runtime_repair", "stage": "runtime_install"},
|
||||
)
|
||||
raise
|
||||
if telemetry is not None:
|
||||
telemetry.send_event(
|
||||
"runtime_repair_success",
|
||||
metadata={
|
||||
**metadata,
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000),
|
||||
},
|
||||
context={"operation": "runtime_repair", "stage": "completed"},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _print_sync_progress(progress: SyncProgress) -> None:
|
||||
"""Emit line-delimited progress that the native Tk dashboard can consume."""
|
||||
|
||||
@@ -796,7 +1158,7 @@ def _import_game(
|
||||
if not as_json:
|
||||
print(f"Importing existing game from {source.expanduser()}…", flush=True)
|
||||
with _operation_guard(paths, "import-game", dry_run):
|
||||
result = import_existing_game(paths, source, runner)
|
||||
result = _import_with_telemetry(paths, source, runner)
|
||||
_emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json)
|
||||
return 0
|
||||
|
||||
@@ -822,7 +1184,46 @@ def _maybe_import_game(
|
||||
return None
|
||||
if announce:
|
||||
print(f"Bootstrapping from existing game: {candidate}", flush=True)
|
||||
return import_existing_game(paths, candidate, runner)
|
||||
return _import_with_telemetry(paths, candidate, runner)
|
||||
|
||||
|
||||
def _import_with_telemetry(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
runner: CommandRunner,
|
||||
) -> GameImportResult:
|
||||
telemetry = current_telemetry()
|
||||
started_at = time.monotonic()
|
||||
if telemetry is not None and not runner.dry_run:
|
||||
telemetry.send_event(
|
||||
"import_started",
|
||||
context={"operation": "import", "stage": "copy"},
|
||||
)
|
||||
try:
|
||||
result = import_existing_game(paths, source, runner)
|
||||
except BaseException as exc:
|
||||
if telemetry is not None and not runner.dry_run:
|
||||
telemetry.send_event(
|
||||
"import_failed",
|
||||
severity="error",
|
||||
message=str(exc),
|
||||
metadata={
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000),
|
||||
},
|
||||
context={"operation": "import", "stage": "copy"},
|
||||
)
|
||||
raise
|
||||
if telemetry is not None and not runner.dry_run:
|
||||
telemetry.send_event(
|
||||
"import_success",
|
||||
metadata={
|
||||
"files_imported": result.files,
|
||||
"bytes_imported": result.bytes,
|
||||
"duration_ms": round((time.monotonic() - started_at) * 1000),
|
||||
},
|
||||
context={"operation": "import", "stage": "completed"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
|
||||
@@ -877,6 +1278,13 @@ def _play_locked(
|
||||
before_game: Callable[[], None] | None = None,
|
||||
game_lease_fds: Sequence[int] = (),
|
||||
) -> int:
|
||||
telemetry = current_telemetry()
|
||||
launch_requested_at = time.monotonic()
|
||||
if telemetry is not None and not dry_run:
|
||||
telemetry.send_event(
|
||||
"game_launch_requested",
|
||||
context={"operation": "launch_game", "stage": "preflight"},
|
||||
)
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
"the imported game has not completed its full publisher verification; "
|
||||
@@ -970,6 +1378,20 @@ def _play_locked(
|
||||
patterns=("*.dat", "*.dmp"),
|
||||
)
|
||||
game_started_ns = time.time_ns()
|
||||
game_started_at = time.monotonic()
|
||||
if telemetry is not None and not dry_run:
|
||||
telemetry.send_event(
|
||||
"game_launch_started",
|
||||
metadata={
|
||||
"launch_duration_ms": round(
|
||||
(game_started_at - launch_requested_at) * 1000
|
||||
),
|
||||
"launch_stage": "game_start",
|
||||
"runtime": selection.kind,
|
||||
"realm": start_option.name,
|
||||
},
|
||||
context={"operation": "launch_game", "stage": "game_start"},
|
||||
)
|
||||
if before_game is not None:
|
||||
before_game()
|
||||
managed_asset_paths = (
|
||||
@@ -990,6 +1412,20 @@ def _play_locked(
|
||||
pass_fds=game_lease_fds,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
if telemetry is not None and not dry_run:
|
||||
telemetry.send_event(
|
||||
"game_exited",
|
||||
severity="warning",
|
||||
message="Game launch was interrupted",
|
||||
metadata={
|
||||
"runtime_seconds": round(time.monotonic() - game_started_at),
|
||||
"clean_exit": False,
|
||||
"session_duration_class": _session_duration_class(
|
||||
time.monotonic() - game_started_at
|
||||
),
|
||||
},
|
||||
context={"operation": "launch_game", "stage": "running"},
|
||||
)
|
||||
current_game_log = _current_game_log(
|
||||
native_log_directory,
|
||||
native_log_baseline,
|
||||
@@ -1044,6 +1480,41 @@ def _play_locked(
|
||||
"current_game_crash": current_game_crash,
|
||||
"runtime_issues": runtime_issues,
|
||||
}
|
||||
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
|
||||
clean_exit = outcome.returncode == 0 and current_game_crash is None
|
||||
if telemetry is not None and not dry_run:
|
||||
common_game_metadata = {
|
||||
"runtime_seconds": round(game_runtime_seconds),
|
||||
"exit_code": outcome.returncode,
|
||||
"clean_exit": clean_exit,
|
||||
"session_duration_class": _session_duration_class(game_runtime_seconds),
|
||||
"runtime": selection.kind,
|
||||
"realm": start_option.name,
|
||||
}
|
||||
if clean_exit:
|
||||
telemetry.send_event(
|
||||
"game_launch_success",
|
||||
metadata=common_game_metadata,
|
||||
context={"operation": "launch_game", "stage": "completed"},
|
||||
)
|
||||
else:
|
||||
telemetry.send_event(
|
||||
"game_launch_failed",
|
||||
severity="error",
|
||||
message=(
|
||||
"Game client wrote a crash dump"
|
||||
if current_game_crash is not None
|
||||
else f"Game client exited with status {outcome.returncode}"
|
||||
),
|
||||
metadata={**common_game_metadata, "runtime_issues": runtime_issues},
|
||||
context={"operation": "launch_game", "stage": "running"},
|
||||
)
|
||||
telemetry.send_event(
|
||||
"game_exited",
|
||||
severity="info" if clean_exit else "warning",
|
||||
metadata=common_game_metadata,
|
||||
context={"operation": "launch_game", "stage": "exited"},
|
||||
)
|
||||
if as_json or dry_run:
|
||||
_emit(report, as_json)
|
||||
else:
|
||||
@@ -1056,6 +1527,14 @@ def _play_locked(
|
||||
return 0 if outcome.returncode == 0 and current_game_crash is None else 1
|
||||
|
||||
|
||||
def _session_duration_class(duration_seconds: float) -> str:
|
||||
if duration_seconds < 30:
|
||||
return "under_30_seconds"
|
||||
if duration_seconds < 5 * 60:
|
||||
return "under_5_minutes"
|
||||
return "normal"
|
||||
|
||||
|
||||
def _ensure_portable_umu_for_play(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
@@ -1504,7 +1983,11 @@ def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
|
||||
|
||||
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
|
||||
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f")
|
||||
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
|
||||
return CommandRunner(
|
||||
paths.logs / f"{timestamp}-{operation}.jsonl",
|
||||
dry_run=dry_run,
|
||||
operation=operation,
|
||||
)
|
||||
|
||||
|
||||
def _play_guard(
|
||||
|
||||
Reference in New Issue
Block a user