Added telemetry system to get information about issues.

This commit is contained in:
2026-09-07 21:49:37 +02:00
parent bea9d6fda7
commit f06fc2e4e3
16 changed files with 4125 additions and 169 deletions

View File

@@ -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(