""" Identity management for PostHog analytics tracking. Handles anonymous/authenticated identity tracking with PostHog aliasing, supporting pre-login anonymous tracking, post-login identity stitching, and logout identity rotation. """ import fcntl import json import os import tempfile import uuid from typing import Any import httpx import yaml from arcade_cli.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH from arcade_cli.usage.constants import ( KEY_ANON_ID, KEY_LINKED_PRINCIPAL_ID, TIMEOUT_ARCADE_API, USAGE_FILE_NAME, ) class UsageIdentity: """Manages user identity for PostHog analytics tracking.""" def __init__(self) -> None: self.usage_file_path = os.path.join(ARCADE_CONFIG_PATH, USAGE_FILE_NAME) self._data: dict[str, Any] | None = None def load_or_create(self) -> dict[str, Any]: """Load or create usage.json file with atomic writes and file locking. Returns: dict: The usage data containing anon_id and optionally linked_email """ if self._data is not None: return self._data os.makedirs(ARCADE_CONFIG_PATH, exist_ok=True) if os.path.exists(self.usage_file_path): try: with open(self.usage_file_path) as f: # lock file if os.name != "nt": # Unix-like systems fcntl.flock(f.fileno(), fcntl.LOCK_SH) try: data = json.load(f) if isinstance(data, dict) and KEY_ANON_ID in data: self._data = data return self._data finally: # unlock file if os.name != "nt": fcntl.flock(f.fileno(), fcntl.LOCK_UN) except Exception: # noqa: S110 pass new_data = {KEY_ANON_ID: str(uuid.uuid4()), KEY_LINKED_PRINCIPAL_ID: None} self._write_atomic(new_data) self._data = new_data return self._data def _write_atomic(self, data: dict[str, Any]) -> None: """Write data atomically to usage.json file Args: data: The data to write to the usage file """ # Create temp file in same directory for atomic rename temp_fd, temp_path = tempfile.mkstemp( dir=ARCADE_CONFIG_PATH, prefix=".usage_", suffix=".tmp" ) try: with os.fdopen(temp_fd, "w") as f: # lock file if os.name != "nt": # Unix-like systems fcntl.flock(f.fileno(), fcntl.LOCK_EX) try: json.dump(data, f, indent=2) f.flush() os.fsync(f.fileno()) # ensure data is written to disk finally: if os.name != "nt": fcntl.flock(f.fileno(), fcntl.LOCK_UN) os.rename(temp_path, self.usage_file_path) except Exception: # clean up import contextlib with contextlib.suppress(OSError): os.unlink(temp_path) raise def get_distinct_id(self) -> str: """Get distinct_id based on authentication state. We use principal_id for authenticated users and anon_id for anonymous users. Returns: str: Principal ID if authenticated, otherwise anon_id """ data = self.load_or_create() # Check if we have a persisted principal_id first linked_principal_id = data.get(KEY_LINKED_PRINCIPAL_ID) if linked_principal_id: return str(linked_principal_id) # Try to fetch principal_id from API if not persisted principal_id = self.get_principal_id() if principal_id: return principal_id # Fall back to anon_id if not authenticated return str(data[KEY_ANON_ID]) def get_principal_id(self) -> str | None: """Fetch principal_id from Arcade Cloud API. Returns: str | None: Principal ID if authenticated and API call succeeds, None otherwise """ if not os.path.exists(CREDENTIALS_FILE_PATH): return None try: with open(CREDENTIALS_FILE_PATH) as f: config = yaml.safe_load(f) cloud_config = config.get("cloud", {}) api_key = cloud_config.get("api", {}).get("key") if not api_key: return None response = httpx.get( "https://cloud.arcade.dev/api/v1/auth/validate", headers={"accept": "application/json", "Authorization": f"Bearer {api_key}"}, timeout=TIMEOUT_ARCADE_API, ) if response.status_code == 200: data = response.json() principal_id = data.get("data", {}).get("principal_id") return str(principal_id) if principal_id else None except Exception: # noqa: S110 # Silent failure - don't disrupt CLI pass return None def should_alias(self) -> bool: """Check if PostHog alias is needed. Alias is needed when the user is authenticated, but the retrieved principal_id doesn't match the persisted linked_principal_id Returns: bool: True if user is authenticated but not yet aliased """ data = self.load_or_create() principal_id = self.get_principal_id() return principal_id is not None and principal_id != data.get(KEY_LINKED_PRINCIPAL_ID) def reset_to_anonymous(self) -> None: """Generate new anonymous ID and clear linked principal_id. Used after logout to prevent cross-contamination between multiple accounts on the same machine """ # Create fresh data with only anon_id new_data = {KEY_ANON_ID: str(uuid.uuid4()), KEY_LINKED_PRINCIPAL_ID: None} self._write_atomic(new_data) self._data = new_data def set_linked_principal_id(self, principal_id: str) -> None: """Update linked_principal_id in usage.json. Args: principal_id: The principal_id to link to the current anon_id """ data = self.load_or_create() data[KEY_LINKED_PRINCIPAL_ID] = principal_id self._write_atomic(data) self._data = data @property def anon_id(self) -> str: """Get the current anonymous ID. Returns: str: The anonymous ID """ data = self.load_or_create() return str(data[KEY_ANON_ID])