"""Shared CLI telemetry, result reporting, and output helpers.""" from __future__ import annotations import argparse import json import signal import time from collections.abc import Callable from dataclasses import dataclass from typing import Any from ..command import CommandError from ..paths import AppPaths from ..telemetry import TelemetryService CommandTelemetryEvents = tuple[str, str, str, str] @dataclass(frozen=True, slots=True) class CommandTelemetryLifecycle: telemetry: TelemetryService | None events: CommandTelemetryEvents | None started_at: float owner: bool def start_command_telemetry( api: Any, paths: AppPaths, command: str, arguments: argparse.Namespace, manage_telemetry: bool, ) -> CommandTelemetryLifecycle: telemetry: TelemetryService | None = None telemetry_owner = False if manage_telemetry: interface = "gui" if command == "gui" else "cli" telemetry, telemetry_owner = api.initialize_shared_telemetry( paths, session_metadata={"interface": interface, "command": command}, report_launcher_lifecycle=command == "gui", ) events = api._command_telemetry_events(command, arguments, paths) started_at = time.monotonic() if telemetry is not None and events is not None: operation, started_event, _success_event, _failed_event = 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"}, ) return CommandTelemetryLifecycle( telemetry=telemetry, events=events, started_at=started_at, owner=telemetry_owner, ) def shutdown_command_telemetry( api: Any, lifecycle: CommandTelemetryLifecycle, ) -> None: if lifecycle.telemetry is not None and lifecycle.owner: api.shutdown_shared_telemetry(lifecycle.telemetry) def command_telemetry_events( command: str, arguments: argparse.Namespace, paths: AppPaths, ) -> CommandTelemetryEvents | 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 getattr(arguments, "rollback", None) is not None ): 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: CommandTelemetryEvents | 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: CommandTelemetryEvents | 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": stage}, ) 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 report_game_launch_cancelled( telemetry: TelemetryService | None, started_at: float, *, stage: str, metadata: dict[str, object] | None = None, ) -> None: if telemetry is None: return event_metadata: dict[str, object] = { "duration_ms": round((time.monotonic() - started_at) * 1000), "interrupted": True, "launcher_exit_code": 130, "signal": int(signal.SIGINT), "signal_name": signal.SIGINT.name, } if metadata is not None: event_metadata.update(metadata) telemetry.send_event( "game_launch_cancelled", severity="info", message="Game launch was cancelled", metadata=event_metadata, context={"operation": "launch_game", "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", "writable") ): return "filesystem" if any(value in message for value in ("prefix", "wine", "proton", "umu")): return "runtime_check" return f"{command}_operation" def emit( value: object, as_json: bool, *, json_printer: Callable[[object], None], ) -> None: if as_json: json_printer(value) else: if isinstance(value, dict) and value.get("ok") is True: print("Done.") print(json.dumps(value, indent=2, sort_keys=True)) def print_json(value: object) -> None: print(json.dumps(value, indent=2, sort_keys=True))