"""Anonymous, best-effort launcher telemetry with bounded asynchronous delivery.""" from __future__ import annotations from collections.abc import Callable, Mapping from datetime import datetime, timezone import json import math import os from pathlib import Path import platform import re import resource import socket import sys import threading import time from urllib.parse import urlsplit from urllib.request import Request, urlopen import uuid from ..paths import AppPaths from ..system_identity import SystemIdentity, detect_system_identity, read_os_release from . import environment as _environment_helpers from . import errors as _error_helpers from .extended import collect_extended_environment from . import identity as _identity_helpers from . import payload as _payload_helpers from . import privacy as _privacy_helpers from . import transport as _transport_helpers from .service import ( DEFAULT_TELEMETRY_ENDPOINT, DELIVERY_TIMEOUT_SECONDS, ERROR_SUPPRESSION_WINDOW_SECONDS, MAX_CONTAINER_ITEMS, MAX_CONTEXT_BYTES, MAX_ERROR_FINGERPRINTS, MAX_MESSAGE_BYTES, MAX_METADATA_BYTES, MAX_REQUEST_BYTES, MAX_STDERR_CHARACTERS, MAX_STDOUT_CHARACTERS, MAX_TRACEBACK_CHARACTERS, MAX_VALUE_DEPTH, QUEUE_CAPACITY, SHUTDOWN_WAIT_SECONDS, TELEMETRY_DEBUG_VARIABLE, TELEMETRY_ENDPOINT_VARIABLE, TELEMETRY_KEY_VARIABLE, TELEMETRY_OPERATION_PROCESS_ROLE, TELEMETRY_PROCESS_ROLE_VARIABLE, TelemetryService as _TelemetryService, TelemetryTransport, VALID_EVENTS, VALID_SEVERITIES, _RepeatedErrorState, _RESOURCE_USAGE_EVENTS, _is_error_event, _string_field, ) _environment_current_game_version = _environment_helpers.current_game_version _environment_display_info = _environment_helpers.display_info _environment_execution_info = _environment_helpers.execution_environment_info _environment_filesystem_info = _environment_helpers.filesystem_info _environment_install_is_external = _environment_helpers.install_is_external _environment_operating_system_info = _environment_helpers.operating_system_info _environment_read_mountinfo = _environment_helpers.read_mountinfo _environment_read_os_release = _environment_helpers.read_os_release _environment_resource_usage = _environment_helpers.resource_usage _environment_settings_schema_version = _environment_helpers.settings_schema_version _environment_unescape_mount_field = _environment_helpers.unescape_mount_field _error_classify_network_failure = _error_helpers.classify_network_failure _error_fingerprint = _error_helpers.error_fingerprint _error_network_http_status = _error_helpers.network_http_status _error_normalized_message = _error_helpers.normalized_error_message _identity_fsync_directory = _identity_helpers.fsync_directory _identity_load_or_create = _identity_helpers.load_or_create_installation_id _identity_read = _identity_helpers.read_installation_id _payload_bound_mapping = _payload_helpers.bound_mapping _payload_encode = _payload_helpers.encode_payload _payload_json_bytes = _payload_helpers.json_bytes _AUTHORIZATION_PATTERN = _privacy_helpers.AUTHORIZATION_PATTERN _EMAIL_PATTERN = _privacy_helpers.EMAIL_PATTERN _HOME_PATTERN = _privacy_helpers.HOME_PATTERN _SECRET_ASSIGNMENT_PATTERN = _privacy_helpers.SECRET_ASSIGNMENT_PATTERN _SECRET_KEYS = _privacy_helpers.SECRET_KEYS _SECRET_QUERY_KEYS = _privacy_helpers.SECRET_QUERY_KEYS _SSH_PATH_PATTERN = _privacy_helpers.SSH_PATH_PATTERN _URL_PATTERN = _privacy_helpers.URL_PATTERN _WINDOWS_USER_PATTERN = _privacy_helpers.WINDOWS_USER_PATTERN _PrivacyPolicy = _privacy_helpers.PrivacyPolicy _privacy_is_secret_key = _privacy_helpers.is_secret_key _privacy_keep_string_tail = _privacy_helpers.keep_string_tail _privacy_sanitize_text = _privacy_helpers.sanitize_text _privacy_sanitize_url_match = _privacy_helpers.sanitize_url_match _privacy_sanitize_value = _privacy_helpers.sanitize_value _privacy_string_limit_for_key = _privacy_helpers.string_limit_for_key _privacy_truncate_characters = _privacy_helpers.truncate_characters _privacy_truncate_utf8 = _privacy_helpers.truncate_utf8 _transport_post_json = _transport_helpers.post_json _transport_validate_endpoint = _transport_helpers.validate_endpoint _NETWORK_FILESYSTEMS = _environment_helpers.NETWORK_FILESYSTEMS _UUID_PATTERN = _error_helpers.UUID_PATTERN _MEMORY_ADDRESS_PATTERN = _error_helpers.MEMORY_ADDRESS_PATTERN _HTTP_STATUS_PATTERN = _error_helpers.HTTP_STATUS_PATTERN _INSTALL_ID_FILE_MODE = _identity_helpers.INSTALL_ID_FILE_MODE class TelemetryService(_TelemetryService): """Stable facade for the extracted telemetry service implementation.""" def __init__( self, paths: AppPaths, *, enabled: bool = True, endpoint: str | None = None, launcher_key: str | None = None, environment: Mapping[str, str] | None = None, transport: TelemetryTransport | None = None, session_metadata: Mapping[str, object] | None = None, report_launcher_lifecycle: bool | None = None, monotonic: Callable[[], float] = time.monotonic, ) -> None: super().__init__( paths, _facade=sys.modules[__name__], enabled=enabled, endpoint=endpoint, launcher_key=launcher_key, environment=environment, transport=transport, session_metadata=session_metadata, report_launcher_lifecycle=report_launcher_lifecycle, monotonic=monotonic, ) _shared_service: TelemetryService | None = None _shared_service_lock = threading.Lock() def initialize_shared_telemetry( paths: AppPaths, *, session_metadata: Mapping[str, object] | None = None, report_launcher_lifecycle: bool | None = None, ) -> tuple[TelemetryService, bool]: """Return the process-wide service and whether this caller owns its shutdown.""" global _shared_service with _shared_service_lock: if _shared_service is not None: return _shared_service, False service = TelemetryService( paths, enabled=_telemetry_enabled_from_settings(paths), session_metadata=session_metadata, report_launcher_lifecycle=report_launcher_lifecycle, ) _shared_service = service service.initialize() return service, True def current_telemetry() -> TelemetryService | None: return _shared_service def set_shared_telemetry_enabled(enabled: bool) -> None: service = _shared_service if service is not None: service.set_enabled(enabled) def shutdown_shared_telemetry( service: TelemetryService, *, timeout_seconds: float = SHUTDOWN_WAIT_SECONDS, ) -> None: global _shared_service service.shutdown(timeout_seconds=timeout_seconds) with _shared_service_lock: if _shared_service is service: _shared_service = None def _telemetry_enabled_from_settings(paths: AppPaths) -> bool: try: from ..settings import Settings, load_settings if not paths.settings_file.is_file(): return Settings().telemetry_enabled return load_settings(paths).telemetry_enabled except (OSError, ValueError): return False def _post_json( endpoint: str, body: bytes, headers: Mapping[str, str], timeout_seconds: float, ) -> None: _transport_post_json( endpoint, body, headers, timeout_seconds, request_factory=Request, opener=urlopen, ) def _validate_endpoint(endpoint: str) -> None: _transport_validate_endpoint(endpoint, split_url=urlsplit) def _load_or_create_installation_id(path: Path) -> tuple[str, bool, str]: return _identity_load_or_create( path, read_identity=_read_installation_id, make_uuid=uuid.uuid4, process_id=os.getpid, thread_identity=threading.get_ident, sync_directory=_fsync_directory, first_seen_from_timestamp=lambda timestamp: datetime.fromtimestamp( timestamp, timezone.utc, ).isoformat(), file_mode=_INSTALL_ID_FILE_MODE, os_module=os, ) def _read_installation_id(path: Path) -> str: return _identity_read(path, parse_uuid=uuid.UUID) def _fsync_directory(path: Path) -> None: _identity_fsync_directory(path, os_module=os) def _operating_system_info( identity: SystemIdentity | None = None, ) -> dict[str, object]: return _environment_operating_system_info( identity, detect_identity=detect_system_identity, system_name=platform.system, kernel_release=platform.release, machine_name=platform.machine, libc_version=platform.libc_ver, ) def _read_os_release(path: Path = Path("/etc/os-release")) -> dict[str, str]: return _environment_read_os_release(path, reader=read_os_release) def _execution_environment_info( identity: SystemIdentity, ) -> dict[str, object] | None: return _environment_execution_info(identity, libc_version=platform.libc_ver) def _display_info(environment: Mapping[str, str]) -> dict[str, object]: return _environment_display_info(environment) def _filesystem_info(target: Path) -> dict[str, object]: return _environment_filesystem_info( target, read_mounts=_read_mountinfo, access=os.access, writable_mode=os.W_OK, network_filesystems=_NETWORK_FILESYSTEMS, ) def _read_mountinfo(path: Path = Path("/proc/self/mountinfo")) -> list[tuple[Path, str, set[str]]]: return _environment_read_mountinfo(path, unescape=_unescape_mount_field) def _unescape_mount_field(value: str) -> str: return _environment_unescape_mount_field(value) def _install_is_external(paths: AppPaths, environment: Mapping[str, str]) -> bool: return _environment_install_is_external(paths, environment) def _current_game_version(path: Path) -> str | None: return _environment_current_game_version(path, json_loader=json.loads) def _settings_schema_version() -> int: return _environment_settings_schema_version() def _privacy_policy() -> _PrivacyPolicy: return _PrivacyPolicy( max_stderr_characters=MAX_STDERR_CHARACTERS, max_stdout_characters=MAX_STDOUT_CHARACTERS, max_traceback_characters=MAX_TRACEBACK_CHARACTERS, max_container_items=MAX_CONTAINER_ITEMS, max_value_depth=MAX_VALUE_DEPTH, secret_keys=_SECRET_KEYS, secret_query_keys=_SECRET_QUERY_KEYS, url_pattern=_URL_PATTERN, email_pattern=_EMAIL_PATTERN, home_pattern=_HOME_PATTERN, windows_user_pattern=_WINDOWS_USER_PATTERN, ssh_path_pattern=_SSH_PATH_PATTERN, authorization_pattern=_AUTHORIZATION_PATTERN, secret_assignment_pattern=_SECRET_ASSIGNMENT_PATTERN, ) def _is_secret_key(key: str) -> bool: return _privacy_is_secret_key(key, policy=_privacy_policy()) def _string_limit_for_key(key: str | None) -> int: return _privacy_string_limit_for_key(key, policy=_privacy_policy()) def _keep_string_tail(key: str | None) -> bool: return _privacy_keep_string_tail(key) def _sanitize_url_match(match: re.Match[str]) -> str: return _privacy_sanitize_url_match(match, policy=_privacy_policy()) def _truncate_characters(value: str, limit: int, *, tail: bool = False) -> str: return _privacy_truncate_characters(value, limit, tail=tail) def _truncate_utf8(value: str, limit: int, *, tail: bool = False) -> str: return _privacy_truncate_utf8(value, limit, tail=tail) def _bound_mapping(value: dict[str, object], limit: int) -> dict[str, object]: return _payload_bound_mapping( value, limit, encode_json=_json_bytes, truncate=_truncate_utf8, keep_tail=_keep_string_tail, ) def _encode_payload(payload: dict[str, object]) -> bytes | None: return _payload_encode( payload, maximum_request_bytes=MAX_REQUEST_BYTES, encode_json=_json_bytes, truncate=_truncate_utf8, ) def _json_bytes(value: object) -> bytes: return _payload_json_bytes(value) def normalized_error_message(message: str) -> str: """Normalize dynamic values for stable server-compatible fingerprints.""" return _error_normalized_message( message, uuid_pattern=_UUID_PATTERN, memory_address_pattern=_MEMORY_ADDRESS_PATTERN, timestamp_pattern=_error_helpers.TIMESTAMP_PATTERN, ) def _resource_usage() -> dict[str, object]: """Report launcher-lifetime CPU time and peak RSS, excluding the game.""" snapshot = _environment_resource_usage( get_usage=resource.getrusage, usage_target=resource.RUSAGE_SELF, platform_name=sys.platform, is_finite=math.isfinite, ) if snapshot: # Keep the existing numeric fields for telemetry consumers, but make # their scope explicit even when attached to a game_exited event. snapshot["resource_usage_scope"] = "launcher_process" return snapshot def error_fingerprint( event: str, message: str, *, exception_type: str = "", source_module: str = "", source_function: str = "", ) -> str: """Create a privacy-safe deterministic fingerprint for tests and server parity.""" return _error_fingerprint( event, message, exception_type=exception_type, source_module=source_module, source_function=source_function, normalizer=normalized_error_message, ) def classify_network_failure(error: object) -> str: """Return one stable low-cardinality class for download failures.""" return _error_classify_network_failure(error) def network_http_status(error: object) -> int | None: """Extract an HTTP status without exposing a failed request URL.""" return _error_network_http_status( error, http_status_pattern=_HTTP_STATUS_PATTERN, )