Files
pristontale-eu-linux/src/ptlinux/telemetry/__init__.py

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,
)