431 lines
13 KiB
Python
431 lines
13 KiB
Python
"""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,
|
|
)
|