246 lines
6.4 KiB
Python
246 lines
6.4 KiB
Python
"""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))
|