From 94046b999d60b5f1be3246c53898db0f5563d717 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 07:46:42 -0400 Subject: [PATCH 01/24] Make core libraries editable for development --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2330fbe9..16704794 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ distribution = false [tool.pdm.dev-dependencies] dev = [ + "-e core @ file:///${PROJECT_ROOT}/libs/python/core", "-e agent @ file:///${PROJECT_ROOT}/libs/python/agent", "-e computer @ file:///${PROJECT_ROOT}/libs/python/computer", "-e computer-server @ file:///${PROJECT_ROOT}/libs/python/computer-server", From b13030f5e0938ac93001bbfe3c372f9fb75b0293 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 16:00:41 -0400 Subject: [PATCH 02/24] Delete unused files in core telemetry library --- libs/python/core/core/telemetry/client.py | 233 ---------------------- libs/python/core/core/telemetry/models.py | 37 ---- libs/python/core/core/telemetry/sender.py | 24 --- 3 files changed, 294 deletions(-) delete mode 100644 libs/python/core/core/telemetry/client.py delete mode 100644 libs/python/core/core/telemetry/models.py delete mode 100644 libs/python/core/core/telemetry/sender.py diff --git a/libs/python/core/core/telemetry/client.py b/libs/python/core/core/telemetry/client.py deleted file mode 100644 index d4eb9c70..00000000 --- a/libs/python/core/core/telemetry/client.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Telemetry client for collecting anonymous usage data.""" - -from __future__ import annotations - -import json -import logging -import os -import random -import time -import uuid -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional - -from core import __version__ -from core.telemetry.sender import send_telemetry - -logger = logging.getLogger("core.telemetry") - -# Controls how frequently telemetry will be sent (percentage) -TELEMETRY_SAMPLE_RATE = 5 # 5% sampling rate - - -@dataclass -class TelemetryConfig: - """Configuration for telemetry collection.""" - - enabled: bool = False # Default to opt-in - sample_rate: float = TELEMETRY_SAMPLE_RATE - project_root: Optional[Path] = None - - @classmethod - def from_env(cls, project_root: Optional[Path] = None) -> TelemetryConfig: - """Load config from environment variables.""" - # CUA_TELEMETRY should be set to "on" to enable telemetry (opt-in) - return cls( - enabled=os.environ.get("CUA_TELEMETRY", "").lower() == "on", - sample_rate=float(os.environ.get("CUA_TELEMETRY_SAMPLE_RATE", TELEMETRY_SAMPLE_RATE)), - project_root=project_root, - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert config to dictionary.""" - return { - "enabled": self.enabled, - "sample_rate": self.sample_rate, - } - - -class TelemetryClient: - """Collects and reports telemetry data with transparency and sampling.""" - - def __init__( - self, project_root: Optional[Path] = None, config: Optional[TelemetryConfig] = None - ): - """Initialize telemetry client. - - Args: - project_root: Root directory of the project - config: Telemetry configuration, or None to load from environment - """ - self.config = config or TelemetryConfig.from_env(project_root) - self.installation_id = self._get_or_create_installation_id() - self.counters: Dict[str, int] = {} - self.events: List[Dict[str, Any]] = [] - self.start_time = time.time() - - # Log telemetry status on startup - if self.config.enabled: - logger.info(f"Telemetry enabled (sampling at {self.config.sample_rate}%)") - else: - logger.info("Telemetry disabled") - - # Create .cua directory if it doesn't exist and config is provided - if self.config.project_root: - self._setup_local_storage() - - def _get_or_create_installation_id(self) -> str: - """Get or create a random installation ID. - - This ID is not tied to any personal information. - """ - if self.config.project_root: - id_file = self.config.project_root / ".cua" / "installation_id" - if id_file.exists(): - try: - return id_file.read_text().strip() - except Exception: - pass - - # Create new ID if not exists - new_id = str(uuid.uuid4()) - try: - id_file.parent.mkdir(parents=True, exist_ok=True) - id_file.write_text(new_id) - return new_id - except Exception: - pass - - # Fallback to in-memory ID if file operations fail - return str(uuid.uuid4()) - - def _setup_local_storage(self) -> None: - """Create local storage directories and files.""" - if not self.config.project_root: - return - - cua_dir = self.config.project_root / ".cua" - cua_dir.mkdir(parents=True, exist_ok=True) - - # Store telemetry config - config_path = cua_dir / "telemetry_config.json" - with open(config_path, "w") as f: - json.dump(self.config.to_dict(), f) - - def increment(self, counter_name: str, value: int = 1) -> None: - """Increment a named counter. - - Args: - counter_name: Name of the counter - value: Amount to increment by (default: 1) - """ - if not self.config.enabled: - return - - if counter_name not in self.counters: - self.counters[counter_name] = 0 - self.counters[counter_name] += value - - def record_event(self, event_name: str, properties: Optional[Dict[str, Any]] = None) -> None: - """Record an event with optional properties. - - Args: - event_name: Name of the event - properties: Event properties (must not contain sensitive data) - """ - if not self.config.enabled: - return - - # Increment counter for this event type - counter_key = f"event:{event_name}" - self.increment(counter_key) - - # Record event details for deeper analysis (if sampled) - if properties and random.random() * 100 <= self.config.sample_rate: - self.events.append( - {"name": event_name, "properties": properties, "timestamp": time.time()} - ) - - def flush(self) -> bool: - """Send collected telemetry if sampling criteria is met. - - Returns: - bool: True if telemetry was sent, False otherwise - """ - if not self.config.enabled or (not self.counters and not self.events): - return False - - # Apply sampling - only send data for a percentage of installations - if random.random() * 100 > self.config.sample_rate: - logger.debug("Telemetry sampled out") - self.counters.clear() - self.events.clear() - return False - - # Prepare telemetry payload - payload = { - "version": __version__, - "installation_id": self.installation_id, - "counters": self.counters.copy(), - "events": self.events.copy(), - "duration": time.time() - self.start_time, - "timestamp": time.time(), - } - - try: - # Send telemetry data - success = send_telemetry(payload) - if success: - logger.debug( - f"Telemetry sent: {len(self.counters)} counters, {len(self.events)} events" - ) - else: - logger.debug("Failed to send telemetry") - return success - except Exception as e: - logger.debug(f"Failed to send telemetry: {e}") - return False - finally: - # Clear data after sending - self.counters.clear() - self.events.clear() - - def enable(self) -> None: - """Enable telemetry collection.""" - self.config.enabled = True - logger.info("Telemetry enabled") - if self.config.project_root: - self._setup_local_storage() - - def disable(self) -> None: - """Disable telemetry collection.""" - self.config.enabled = False - logger.info("Telemetry disabled") - if self.config.project_root: - self._setup_local_storage() - - -# Global telemetry client instance -_client: Optional[TelemetryClient] = None - - -def get_telemetry_client(project_root: Optional[Path] = None) -> TelemetryClient: - """Get or initialize the global telemetry client. - - Args: - project_root: Root directory of the project - - Returns: - The global telemetry client instance - """ - global _client - - if _client is None: - _client = TelemetryClient(project_root) - - return _client - - -def disable_telemetry() -> None: - """Disable telemetry collection globally.""" - if _client is not None: - _client.disable() diff --git a/libs/python/core/core/telemetry/models.py b/libs/python/core/core/telemetry/models.py deleted file mode 100644 index d37e8685..00000000 --- a/libs/python/core/core/telemetry/models.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Models for telemetry data.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, Field - - -class TelemetryEvent(BaseModel): - """A telemetry event with properties.""" - - name: str - properties: Dict[str, Any] = Field(default_factory=dict) - timestamp: float = Field(default_factory=lambda: datetime.now().timestamp()) - - -class TelemetryPayload(BaseModel): - """Telemetry payload sent to the server.""" - - version: str - installation_id: str - counters: Dict[str, int] = Field(default_factory=dict) - events: List[TelemetryEvent] = Field(default_factory=list) - duration: float = 0 - timestamp: float = Field(default_factory=lambda: datetime.now().timestamp()) - - -class UserRecord(BaseModel): - """User record stored in the telemetry database.""" - - id: str - version: Optional[str] = None - created_at: Optional[datetime] = None - last_seen_at: Optional[datetime] = None - is_ci: bool = False diff --git a/libs/python/core/core/telemetry/sender.py b/libs/python/core/core/telemetry/sender.py deleted file mode 100644 index 8772868f..00000000 --- a/libs/python/core/core/telemetry/sender.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Telemetry sender module for sending anonymous usage data.""" - -import logging -from typing import Any, Dict - -logger = logging.getLogger("core.telemetry") - - -def send_telemetry(payload: Dict[str, Any]) -> bool: - """Send telemetry data to collection endpoint. - - Args: - payload: Telemetry data to send - - Returns: - bool: True if sending was successful, False otherwise - """ - try: - # For now, just log the payload and return success - logger.debug(f"Would send telemetry: {payload}") - return True - except Exception as e: - logger.debug(f"Error sending telemetry: {e}") - return False From c96e8c57d7c393041e9f7f7958d467284461f694 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 16:39:43 -0400 Subject: [PATCH 03/24] Remove set_dimension --- libs/python/agent/agent/__init__.py | 6 ------ libs/python/agent/agent/callbacks/telemetry.py | 7 ------- libs/python/agent/agent/telemetry.py | 6 ------ libs/python/computer/computer/telemetry.py | 13 +------------ 4 files changed, 1 insertion(+), 31 deletions(-) diff --git a/libs/python/agent/agent/__init__.py b/libs/python/agent/agent/__init__.py index 08d782d3..717b76af 100644 --- a/libs/python/agent/agent/__init__.py +++ b/libs/python/agent/agent/__init__.py @@ -32,9 +32,6 @@ try: record_event, ) - # Import set_dimension from our own telemetry module - from .telemetry import set_dimension - # Check if telemetry is enabled if is_telemetry_enabled(): logger.info("Telemetry is enabled") @@ -49,9 +46,6 @@ try: }, ) - # Set the package version as a dimension - set_dimension("agent_version", __version__) - # Flush events to ensure they're sent flush() else: diff --git a/libs/python/agent/agent/callbacks/telemetry.py b/libs/python/agent/agent/callbacks/telemetry.py index ac8855a2..00e49a05 100644 --- a/libs/python/agent/agent/callbacks/telemetry.py +++ b/libs/python/agent/agent/callbacks/telemetry.py @@ -10,7 +10,6 @@ from .base import AsyncCallbackHandler from ..telemetry import ( record_event, is_telemetry_enabled, - set_dimension, SYSTEM_INFO, ) @@ -65,11 +64,6 @@ class TelemetryCallback(AsyncCallbackHandler): **SYSTEM_INFO } - # Set session-level dimensions - set_dimension("session_id", self.session_id) - set_dimension("agent_type", agent_info["agent_type"]) - set_dimension("model", agent_info["model"]) - record_event("agent_session_start", agent_info) async def on_run_start(self, kwargs: Dict[str, Any], old_items: List[Dict[str, Any]]) -> None: @@ -98,7 +92,6 @@ class TelemetryCallback(AsyncCallbackHandler): if trajectory: run_data["uploaded_trajectory"] = trajectory - set_dimension("run_id", self.run_id) record_event("agent_run_start", run_data) async def on_run_end(self, kwargs: Dict[str, Any], old_items: List[Dict[str, Any]], new_items: List[Dict[str, Any]]) -> None: diff --git a/libs/python/agent/agent/telemetry.py b/libs/python/agent/agent/telemetry.py index d3e33a25..ec8428d3 100644 --- a/libs/python/agent/agent/telemetry.py +++ b/libs/python/agent/agent/telemetry.py @@ -19,7 +19,6 @@ def _noop(*args: Any, **kwargs: Any) -> None: # Define default functions with unique names to avoid shadowing _default_record_event = _noop _default_increment_counter = _noop -_default_set_dimension = _noop _default_get_telemetry_client = lambda: None _default_flush = _noop _default_is_telemetry_enabled = lambda: False @@ -28,7 +27,6 @@ _default_is_telemetry_globally_disabled = lambda: True # Set the actual functions to the defaults initially record_event = _default_record_event increment_counter = _default_increment_counter -set_dimension = _default_set_dimension get_telemetry_client = _default_get_telemetry_client flush = _default_flush is_telemetry_enabled = _default_is_telemetry_enabled @@ -59,10 +57,6 @@ try: if is_telemetry_enabled(): core_increment(counter_name, value) - def set_dimension(name: str, value: Any) -> None: - """Set a dimension that will be attached to all events.""" - logger.debug(f"Setting dimension {name}={value}") - TELEMETRY_AVAILABLE = True logger.info("Successfully imported telemetry") except ImportError as e: diff --git a/libs/python/computer/computer/telemetry.py b/libs/python/computer/computer/telemetry.py index 69d064f8..d431cedc 100644 --- a/libs/python/computer/computer/telemetry.py +++ b/libs/python/computer/computer/telemetry.py @@ -20,11 +20,6 @@ try: if is_telemetry_enabled(): increment(counter_name, value) - def set_dimension(name: str, value: Any) -> None: - """Set a dimension that will be attached to all events.""" - logger = logging.getLogger("computer.telemetry") - logger.debug(f"Setting dimension {name}={value}") - TELEMETRY_AVAILABLE = True logger = logging.getLogger("computer.telemetry") logger.info("Successfully imported telemetry") @@ -47,7 +42,6 @@ if not TELEMETRY_AVAILABLE: logger.debug("Telemetry not available, using no-op functions") record_event = _noop # type: ignore increment_counter = _noop # type: ignore - set_dimension = _noop # type: ignore get_telemetry_client = lambda: None # type: ignore flush = _noop # type: ignore is_telemetry_enabled = lambda: False # type: ignore @@ -108,9 +102,4 @@ def is_telemetry_enabled() -> bool: def record_computer_initialization() -> None: """Record when a computer instance is initialized.""" if TELEMETRY_AVAILABLE and is_telemetry_enabled(): - record_event("computer_initialized", SYSTEM_INFO) - - # Set dimensions that will be attached to all events - set_dimension("os", SYSTEM_INFO["os"]) - set_dimension("os_version", SYSTEM_INFO["os_version"]) - set_dimension("python_version", SYSTEM_INFO["python_version"]) + record_event("computer_initialized", SYSTEM_INFO) \ No newline at end of file From 1077d4c25e772b04f5cff613bcbd85e46d80c870 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 17:40:13 -0400 Subject: [PATCH 04/24] Remove unused function --- libs/python/agent/agent/telemetry.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/libs/python/agent/agent/telemetry.py b/libs/python/agent/agent/telemetry.py index ec8428d3..032fdc32 100644 --- a/libs/python/agent/agent/telemetry.py +++ b/libs/python/agent/agent/telemetry.py @@ -122,15 +122,4 @@ def is_telemetry_enabled() -> bool: from core.telemetry import is_telemetry_enabled as core_is_enabled return core_is_enabled() - return False - - -def record_agent_initialization() -> None: - """Record when an agent instance is initialized.""" - if TELEMETRY_AVAILABLE and is_telemetry_enabled(): - record_event("agent_initialized", SYSTEM_INFO) - - # Set dimensions that will be attached to all events - set_dimension("os", SYSTEM_INFO["os"]) - set_dimension("os_version", SYSTEM_INFO["os_version"]) - set_dimension("python_version", SYSTEM_INFO["python_version"]) + return False \ No newline at end of file From 9dd9d959706f279af29e07660955c8403cb3ee08 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 16:40:12 -0400 Subject: [PATCH 05/24] Remove shim --- libs/python/computer/computer/telemetry.py | 102 ++------------------- 1 file changed, 10 insertions(+), 92 deletions(-) diff --git a/libs/python/computer/computer/telemetry.py b/libs/python/computer/computer/telemetry.py index d431cedc..5c7f67a1 100644 --- a/libs/python/computer/computer/telemetry.py +++ b/libs/python/computer/computer/telemetry.py @@ -1,105 +1,23 @@ """Computer telemetry for tracking anonymous usage and feature usage.""" -import logging import platform -from typing import Any +from core.telemetry import increment, is_telemetry_enabled, record_event -# Import the core telemetry module -TELEMETRY_AVAILABLE = False - -try: - from core.telemetry import ( - increment, - is_telemetry_enabled, - is_telemetry_globally_disabled, - record_event, - ) - - def increment_counter(counter_name: str, value: int = 1) -> None: - """Wrapper for increment to maintain backward compatibility.""" - if is_telemetry_enabled(): - increment(counter_name, value) - - TELEMETRY_AVAILABLE = True - logger = logging.getLogger("computer.telemetry") - logger.info("Successfully imported telemetry") -except ImportError as e: - logger = logging.getLogger("computer.telemetry") - logger.warning(f"Could not import telemetry: {e}") - TELEMETRY_AVAILABLE = False - - -# Local fallbacks in case core telemetry isn't available -def _noop(*args: Any, **kwargs: Any) -> None: - """No-op function for when telemetry is not available.""" - pass - - -logger = logging.getLogger("computer.telemetry") - -# If telemetry isn't available, use no-op functions -if not TELEMETRY_AVAILABLE: - logger.debug("Telemetry not available, using no-op functions") - record_event = _noop # type: ignore - increment_counter = _noop # type: ignore - get_telemetry_client = lambda: None # type: ignore - flush = _noop # type: ignore - is_telemetry_enabled = lambda: False # type: ignore - is_telemetry_globally_disabled = lambda: True # type: ignore - -# Get system info once to use in telemetry SYSTEM_INFO = { "os": platform.system().lower(), "os_version": platform.release(), "python_version": platform.python_version(), } - -def enable_telemetry() -> bool: - """Enable telemetry if available. - - Returns: - bool: True if telemetry was successfully enabled, False otherwise - """ - global TELEMETRY_AVAILABLE - - # Check if globally disabled using core function - if TELEMETRY_AVAILABLE and is_telemetry_globally_disabled(): - logger.info("Telemetry is globally disabled via environment variable - cannot enable") - return False - - # Already enabled - if TELEMETRY_AVAILABLE: - return True - - # Try to import and enable - try: - # Verify we can import core telemetry - from core.telemetry import record_event # type: ignore - - TELEMETRY_AVAILABLE = True - logger.info("Telemetry successfully enabled") - return True - except ImportError as e: - logger.warning(f"Could not enable telemetry: {e}") - return False - - -def is_telemetry_enabled() -> bool: - """Check if telemetry is enabled. - - Returns: - bool: True if telemetry is enabled, False otherwise - """ - # Use the core function if available, otherwise use our local flag - if TELEMETRY_AVAILABLE: - from core.telemetry import is_telemetry_enabled as core_is_enabled - - return core_is_enabled() - return False +__all__ = [ + "increment", + "is_telemetry_enabled", + "record_event", + "record_computer_initialization", +] def record_computer_initialization() -> None: - """Record when a computer instance is initialized.""" - if TELEMETRY_AVAILABLE and is_telemetry_enabled(): - record_event("computer_initialized", SYSTEM_INFO) \ No newline at end of file + if not is_telemetry_enabled(): + return + record_event("computer_initialized", SYSTEM_INFO) \ No newline at end of file From b706da08411714fe56b6b712c4e9d8c2c8b76a03 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 17:43:47 -0400 Subject: [PATCH 06/24] Remove shim --- libs/python/agent/agent/telemetry.py | 133 ++++----------------------- 1 file changed, 18 insertions(+), 115 deletions(-) diff --git a/libs/python/agent/agent/telemetry.py b/libs/python/agent/agent/telemetry.py index 032fdc32..6769ebb5 100644 --- a/libs/python/agent/agent/telemetry.py +++ b/libs/python/agent/agent/telemetry.py @@ -1,125 +1,28 @@ """Agent telemetry for tracking anonymous usage and feature usage.""" -import logging -import os +from core.telemetry import ( + record_event, + increment as increment_counter, + get_telemetry_client, + flush, + is_telemetry_enabled, + is_telemetry_globally_disabled, +) + import platform -import sys -from typing import Dict, Any, Callable -# Import the core telemetry module -TELEMETRY_AVAILABLE = False - - -# Local fallbacks in case core telemetry isn't available -def _noop(*args: Any, **kwargs: Any) -> None: - """No-op function for when telemetry is not available.""" - pass - - -# Define default functions with unique names to avoid shadowing -_default_record_event = _noop -_default_increment_counter = _noop -_default_get_telemetry_client = lambda: None -_default_flush = _noop -_default_is_telemetry_enabled = lambda: False -_default_is_telemetry_globally_disabled = lambda: True - -# Set the actual functions to the defaults initially -record_event = _default_record_event -increment_counter = _default_increment_counter -get_telemetry_client = _default_get_telemetry_client -flush = _default_flush -is_telemetry_enabled = _default_is_telemetry_enabled -is_telemetry_globally_disabled = _default_is_telemetry_globally_disabled - -logger = logging.getLogger("agent.telemetry") - -try: - # Import from core telemetry - from core.telemetry import ( - record_event as core_record_event, - increment as core_increment, - get_telemetry_client as core_get_telemetry_client, - flush as core_flush, - is_telemetry_enabled as core_is_telemetry_enabled, - is_telemetry_globally_disabled as core_is_telemetry_globally_disabled, - ) - - # Override the default functions with actual implementations - record_event = core_record_event - get_telemetry_client = core_get_telemetry_client - flush = core_flush - is_telemetry_enabled = core_is_telemetry_enabled - is_telemetry_globally_disabled = core_is_telemetry_globally_disabled - - def increment_counter(counter_name: str, value: int = 1) -> None: - """Wrapper for increment to maintain backward compatibility.""" - if is_telemetry_enabled(): - core_increment(counter_name, value) - - TELEMETRY_AVAILABLE = True - logger.info("Successfully imported telemetry") -except ImportError as e: - logger.warning(f"Could not import telemetry: {e}") - logger.debug("Telemetry not available, using no-op functions") - -# Get system info once to use in telemetry SYSTEM_INFO = { "os": platform.system().lower(), "os_version": platform.release(), "python_version": platform.python_version(), } - -def enable_telemetry() -> bool: - """Enable telemetry if available. - - Returns: - bool: True if telemetry was successfully enabled, False otherwise - """ - global TELEMETRY_AVAILABLE, record_event, increment_counter, get_telemetry_client, flush, is_telemetry_enabled, is_telemetry_globally_disabled - - # Check if globally disabled using core function - if TELEMETRY_AVAILABLE and is_telemetry_globally_disabled(): - logger.info("Telemetry is globally disabled via environment variable - cannot enable") - return False - - # Already enabled - if TELEMETRY_AVAILABLE: - return True - - # Try to import and enable - try: - from core.telemetry import ( - record_event, - increment, - get_telemetry_client, - flush, - is_telemetry_globally_disabled, - ) - - # Check again after import - if is_telemetry_globally_disabled(): - logger.info("Telemetry is globally disabled via environment variable - cannot enable") - return False - - TELEMETRY_AVAILABLE = True - logger.info("Telemetry successfully enabled") - return True - except ImportError as e: - logger.warning(f"Could not enable telemetry: {e}") - return False - - -def is_telemetry_enabled() -> bool: - """Check if telemetry is enabled. - - Returns: - bool: True if telemetry is enabled, False otherwise - """ - # Use the core function if available, otherwise use our local flag - if TELEMETRY_AVAILABLE: - from core.telemetry import is_telemetry_enabled as core_is_enabled - - return core_is_enabled() - return False \ No newline at end of file +__all__ = [ + "record_event", + "increment_counter", + "get_telemetry_client", + "flush", + "is_telemetry_enabled", + "is_telemetry_globally_disabled", + "SYSTEM_INFO", +] \ No newline at end of file From 9618c102615a37642ea85f3ac072c5ee15bccba1 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 18:04:56 -0400 Subject: [PATCH 07/24] Remove telemetry submodules within SDKs --- .../python/agent/agent/callbacks/telemetry.py | 10 +++++-- libs/python/agent/agent/telemetry.py | 28 ------------------- libs/python/computer/computer/computer.py | 14 ++++++++-- libs/python/computer/computer/telemetry.py | 23 --------------- 4 files changed, 19 insertions(+), 56 deletions(-) delete mode 100644 libs/python/agent/agent/telemetry.py delete mode 100644 libs/python/computer/computer/telemetry.py diff --git a/libs/python/agent/agent/callbacks/telemetry.py b/libs/python/agent/agent/callbacks/telemetry.py index 00e49a05..bdb3fd4c 100644 --- a/libs/python/agent/agent/callbacks/telemetry.py +++ b/libs/python/agent/agent/callbacks/telemetry.py @@ -7,12 +7,18 @@ import uuid from typing import List, Dict, Any, Optional, Union from .base import AsyncCallbackHandler -from ..telemetry import ( +from core.telemetry import ( record_event, is_telemetry_enabled, - SYSTEM_INFO, ) +import platform + +SYSTEM_INFO = { + "os": platform.system().lower(), + "os_version": platform.release(), + "python_version": platform.python_version(), +} class TelemetryCallback(AsyncCallbackHandler): """ diff --git a/libs/python/agent/agent/telemetry.py b/libs/python/agent/agent/telemetry.py deleted file mode 100644 index 6769ebb5..00000000 --- a/libs/python/agent/agent/telemetry.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Agent telemetry for tracking anonymous usage and feature usage.""" - -from core.telemetry import ( - record_event, - increment as increment_counter, - get_telemetry_client, - flush, - is_telemetry_enabled, - is_telemetry_globally_disabled, -) - -import platform - -SYSTEM_INFO = { - "os": platform.system().lower(), - "os_version": platform.release(), - "python_version": platform.python_version(), -} - -__all__ = [ - "record_event", - "increment_counter", - "get_telemetry_client", - "flush", - "is_telemetry_enabled", - "is_telemetry_globally_disabled", - "SYSTEM_INFO", -] \ No newline at end of file diff --git a/libs/python/computer/computer/computer.py b/libs/python/computer/computer/computer.py index 854a6cce..b7bd9466 100644 --- a/libs/python/computer/computer/computer.py +++ b/libs/python/computer/computer/computer.py @@ -9,10 +9,18 @@ import re from .logger import Logger, LogLevel import json import logging -from .telemetry import record_computer_initialization +from core.telemetry import is_telemetry_enabled, record_event import os from . import helpers +import platform + +SYSTEM_INFO = { + "os": platform.system().lower(), + "os_version": platform.release(), + "python_version": platform.python_version(), +} + # Import provider related modules from .providers.base import VMProviderType from .providers.factory import VMProviderFactory @@ -183,8 +191,8 @@ class Computer: self.use_host_computer_server = use_host_computer_server # Record initialization in telemetry (if enabled) - if telemetry_enabled: - record_computer_initialization() + if telemetry_enabled and is_telemetry_enabled(): + record_event("computer_initialized", SYSTEM_INFO) else: self.logger.debug("Telemetry disabled - skipping initialization tracking") diff --git a/libs/python/computer/computer/telemetry.py b/libs/python/computer/computer/telemetry.py deleted file mode 100644 index 5c7f67a1..00000000 --- a/libs/python/computer/computer/telemetry.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Computer telemetry for tracking anonymous usage and feature usage.""" - -import platform -from core.telemetry import increment, is_telemetry_enabled, record_event - -SYSTEM_INFO = { - "os": platform.system().lower(), - "os_version": platform.release(), - "python_version": platform.python_version(), -} - -__all__ = [ - "increment", - "is_telemetry_enabled", - "record_event", - "record_computer_initialization", -] - - -def record_computer_initialization() -> None: - if not is_telemetry_enabled(): - return - record_event("computer_initialized", SYSTEM_INFO) \ No newline at end of file From 2a014decb6dce4a98e993089b8db0fa1d54e0565 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 19:05:02 -0400 Subject: [PATCH 08/24] Rename test files for Pytest --- tests/{files.py => test_files.py} | 0 tests/{shell_bash.py => test_shell_bash.py} | 0 tests/{venv.py => test_venv.py} | 0 tests/{watchdog.py => test_watchdog.py} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/{files.py => test_files.py} (100%) rename tests/{shell_bash.py => test_shell_bash.py} (100%) rename tests/{venv.py => test_venv.py} (100%) rename tests/{watchdog.py => test_watchdog.py} (100%) diff --git a/tests/files.py b/tests/test_files.py similarity index 100% rename from tests/files.py rename to tests/test_files.py diff --git a/tests/shell_bash.py b/tests/test_shell_bash.py similarity index 100% rename from tests/shell_bash.py rename to tests/test_shell_bash.py diff --git a/tests/venv.py b/tests/test_venv.py similarity index 100% rename from tests/venv.py rename to tests/test_venv.py diff --git a/tests/watchdog.py b/tests/test_watchdog.py similarity index 100% rename from tests/watchdog.py rename to tests/test_watchdog.py From 33cd9f13753b581101d1127e8bdd3012bf5744aa Mon Sep 17 00:00:00 2001 From: James Murdza Date: Thu, 7 Aug 2025 19:05:33 -0400 Subject: [PATCH 09/24] Add telemetry test --- tests/test_telemetry.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_telemetry.py diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 00000000..3ad66ccf --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,34 @@ +""" +Required environment variables: +- CUA_API_KEY: API key for Cua cloud provider +""" + +import os +import pytest +from pathlib import Path +import sys + +# Load environment variables from .env file +project_root = Path(__file__).parent.parent +env_file = project_root / ".env" +print(f"Loading environment from: {env_file}") +from dotenv import load_dotenv + +load_dotenv(env_file) + +# Add paths to sys.path if needed +pythonpath = os.environ.get("PYTHONPATH", "") +for path in pythonpath.split(":"): + if path and path not in sys.path: + sys.path.insert(0, path) # Insert at beginning to prioritize + print(f"Added to sys.path: {path}") + +from core.telemetry import record_event + +@pytest.mark.asyncio(loop_scope="session") +async def test_telemetry(): + record_event("test_telemetry", {"message": "Hello, world!"}) + +if __name__ == "__main__": + # Run tests directly + pytest.main([__file__, "-v"]) From 4fa4595838ea1d2b66787f35edbec3af9fa6e9e8 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 09:38:16 -0400 Subject: [PATCH 10/24] Add tests for disabling telemetry --- tests/test_telemetry.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 3ad66ccf..0de0af6c 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -23,11 +23,33 @@ for path in pythonpath.split(":"): sys.path.insert(0, path) # Insert at beginning to prioritize print(f"Added to sys.path: {path}") -from core.telemetry import record_event +from core.telemetry import record_event, is_telemetry_enabled -@pytest.mark.asyncio(loop_scope="session") -async def test_telemetry(): - record_event("test_telemetry", {"message": "Hello, world!"}) + +class TestTelemetry: + def setup_method(self): + """Reset environment variables before each test""" + os.environ.pop('CUA_TELEMETRY', None) + os.environ.pop('CUA_TELEMETRY_ENABLED', None) + + def test_telemetry_disabled_when_cua_telemetry_is_off(self): + """Should return false when CUA_TELEMETRY is off""" + os.environ['CUA_TELEMETRY'] = 'off' + assert is_telemetry_enabled() is False + + def test_telemetry_enabled_when_cua_telemetry_not_set(self): + """Should return true when CUA_TELEMETRY is not set""" + assert is_telemetry_enabled() is True + + def test_telemetry_disabled_when_cua_telemetry_enabled_is_0(self): + """Should return false if CUA_TELEMETRY_ENABLED is 0""" + os.environ['CUA_TELEMETRY_ENABLED'] = '0' + assert is_telemetry_enabled() is False + + def test_send_test_event_to_posthog(self): + """Should send a test event to PostHog""" + # This should not raise an exception + record_event('test_telemetry', {'message': 'Hello, world!'}) if __name__ == "__main__": # Run tests directly From 34d160eabd78b05a92ee7151b5b0addd19e6a4bb Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 09:38:26 -0400 Subject: [PATCH 11/24] Reset telemetry client between tests --- libs/python/core/core/telemetry/__init__.py | 2 ++ libs/python/core/core/telemetry/telemetry.py | 4 ++++ tests/test_telemetry.py | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/libs/python/core/core/telemetry/__init__.py b/libs/python/core/core/telemetry/__init__.py index 04f3f057..fe55bb8b 100644 --- a/libs/python/core/core/telemetry/__init__.py +++ b/libs/python/core/core/telemetry/__init__.py @@ -13,6 +13,7 @@ from core.telemetry.telemetry import ( record_event, is_telemetry_enabled, is_telemetry_globally_disabled, + destroy_telemetry_client, ) @@ -26,4 +27,5 @@ __all__ = [ "record_event", "is_telemetry_enabled", "is_telemetry_globally_disabled", + "destroy_telemetry_client", ] diff --git a/libs/python/core/core/telemetry/telemetry.py b/libs/python/core/core/telemetry/telemetry.py index f01421cd..c04423f8 100644 --- a/libs/python/core/core/telemetry/telemetry.py +++ b/libs/python/core/core/telemetry/telemetry.py @@ -190,6 +190,10 @@ def get_telemetry_client( return _universal_client +def destroy_telemetry_client() -> None: + """Destroy the global telemetry client.""" + global _universal_client + _universal_client = None def increment(counter_name: str, value: int = 1) -> None: """Increment a named counter using the global telemetry client. diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 0de0af6c..80a78779 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -23,7 +23,7 @@ for path in pythonpath.split(":"): sys.path.insert(0, path) # Insert at beginning to prioritize print(f"Added to sys.path: {path}") -from core.telemetry import record_event, is_telemetry_enabled +from core.telemetry import record_event, is_telemetry_enabled, destroy_telemetry_client class TestTelemetry: @@ -31,6 +31,7 @@ class TestTelemetry: """Reset environment variables before each test""" os.environ.pop('CUA_TELEMETRY', None) os.environ.pop('CUA_TELEMETRY_ENABLED', None) + destroy_telemetry_client() def test_telemetry_disabled_when_cua_telemetry_is_off(self): """Should return false when CUA_TELEMETRY is off""" From 53c53e8df4836e715d76018cf2240a12bf1f498d Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 08:29:13 -0400 Subject: [PATCH 12/24] Add check for legacy environment variable --- libs/python/core/core/telemetry/telemetry.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/python/core/core/telemetry/telemetry.py b/libs/python/core/core/telemetry/telemetry.py index c04423f8..c92f49b8 100644 --- a/libs/python/core/core/telemetry/telemetry.py +++ b/libs/python/core/core/telemetry/telemetry.py @@ -60,6 +60,9 @@ def is_telemetry_globally_disabled() -> bool: Returns: bool: True if telemetry is globally disabled, False otherwise """ + # Check legacy environment variable for telemetry opt-out + if os.environ.get("CUA_TELEMETRY", "").lower() == "off": + return True # Only check for CUA_TELEMETRY_ENABLED - telemetry is enabled only if explicitly set to a truthy value telemetry_enabled = os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() return telemetry_enabled not in ("1", "true", "yes", "on") From a1020161bd83a223a49caa539d336f55d78ad733 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 09:41:31 -0400 Subject: [PATCH 13/24] Remove extra modularity in telemetry client --- libs/python/core/core/telemetry/__init__.py | 10 - libs/python/core/core/telemetry/telemetry.py | 397 +++++-------------- 2 files changed, 107 insertions(+), 300 deletions(-) diff --git a/libs/python/core/core/telemetry/__init__.py b/libs/python/core/core/telemetry/__init__.py index fe55bb8b..47470437 100644 --- a/libs/python/core/core/telemetry/__init__.py +++ b/libs/python/core/core/telemetry/__init__.py @@ -4,28 +4,18 @@ It provides a low-overhead way to collect anonymous usage data. """ from core.telemetry.telemetry import ( - UniversalTelemetryClient, - enable_telemetry, - disable_telemetry, flush, - get_telemetry_client, increment, record_event, is_telemetry_enabled, - is_telemetry_globally_disabled, destroy_telemetry_client, ) __all__ = [ - "UniversalTelemetryClient", - "enable_telemetry", - "disable_telemetry", "flush", - "get_telemetry_client", "increment", "record_event", "is_telemetry_enabled", - "is_telemetry_globally_disabled", "destroy_telemetry_client", ] diff --git a/libs/python/core/core/telemetry/telemetry.py b/libs/python/core/core/telemetry/telemetry.py index c92f49b8..b8608caf 100644 --- a/libs/python/core/core/telemetry/telemetry.py +++ b/libs/python/core/core/telemetry/telemetry.py @@ -1,317 +1,134 @@ -"""Universal telemetry module for collecting anonymous usage data. -This module provides a unified interface for telemetry collection, -using PostHog as the backend. +"""Lightweight telemetry wrapper for PostHog. All helpers are thin wrappers around +a single, lazily-initialised PostHog client. + +Usage: + from core.telemetry import record_event, increment + + record_event("my_event", {"foo": "bar"}) + increment("my_counter") + +Configuration: + • Disable telemetry globally by setting the environment variable + CUA_TELEMETRY=off OR CUA_TELEMETRY_ENABLED=false/0. + • Control log verbosity with CUA_TELEMETRY_LOG_LEVEL (DEBUG|INFO|WARNING|ERROR). + +If the `posthog` package (and the accompanying `PostHogTelemetryClient` helper) +are not available, every public function becomes a no-op. """ from __future__ import annotations import logging import os -import sys -from enum import Enum -from pathlib import Path -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional +# --------------------------------------------------------------------------- +# Logging configuration +# --------------------------------------------------------------------------- -# Configure telemetry logging before importing anything else -# By default, set telemetry loggers to WARNING level to hide INFO messages -# This can be overridden with CUA_TELEMETRY_LOG_LEVEL environment variable -def _configure_telemetry_logging() -> None: - """Set up initial logging configuration for telemetry.""" - # Determine log level from environment variable or use WARNING by default - env_level = os.environ.get("CUA_TELEMETRY_LOG_LEVEL", "WARNING").upper() - level = logging.WARNING # Default to WARNING to hide INFO messages +_DEFAULT_LOG_LEVEL = os.environ.get("CUA_TELEMETRY_LOG_LEVEL", "WARNING").upper() +logging.basicConfig(level=getattr(logging, _DEFAULT_LOG_LEVEL, logging.WARNING)) +_LOGGER = logging.getLogger("core.telemetry") - if env_level == "DEBUG": - level = logging.DEBUG - elif env_level == "INFO": - level = logging.INFO - elif env_level == "ERROR": - level = logging.ERROR +# --------------------------------------------------------------------------- +# Attempt to import the PostHog client helper. If unavailable, telemetry is +# silently disabled. +# --------------------------------------------------------------------------- - # Configure the main telemetry logger - telemetry_logger = logging.getLogger("core.telemetry") - telemetry_logger.setLevel(level) - - -# Configure logging immediately -_configure_telemetry_logging() - -# Import telemetry backend try: - from core.telemetry.posthog_client import ( - PostHogTelemetryClient, - get_posthog_telemetry_client, + from core.telemetry.posthog_client import get_posthog_telemetry_client # type: ignore +except ImportError: # pragma: no cover + get_posthog_telemetry_client = None # type: ignore[misc, assignment] + +# --------------------------------------------------------------------------- +# Internal helpers & primitives +# --------------------------------------------------------------------------- + +def _telemetry_disabled() -> bool: + """Return True if the user has disabled telemetry via environment vars.""" + return ( + os.environ.get("CUA_TELEMETRY", "").lower() == "off" # legacy flag + or os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() # new flag + not in {"1", "true", "yes", "on"} ) - POSTHOG_AVAILABLE = True -except ImportError: - logger = logging.getLogger("core.telemetry") - logger.info("PostHog not available. Install with: pdm add posthog") - POSTHOG_AVAILABLE = False -logger = logging.getLogger("core.telemetry") +_CLIENT = None # Lazily instantiated PostHog client instance +_ENABLED = False # Guard to avoid making calls when telemetry disabled +def _ensure_client() -> None: + """Initialise the PostHog client once and cache it globally.""" + global _CLIENT, _ENABLED -# Check environment variables for global telemetry opt-out -def is_telemetry_globally_disabled() -> bool: - """Check if telemetry is globally disabled via environment variables. + # Bail early if telemetry is disabled or already initialised + if _CLIENT is not None or _telemetry_disabled(): + return - Returns: - bool: True if telemetry is globally disabled, False otherwise - """ - # Check legacy environment variable for telemetry opt-out - if os.environ.get("CUA_TELEMETRY", "").lower() == "off": - return True - # Only check for CUA_TELEMETRY_ENABLED - telemetry is enabled only if explicitly set to a truthy value - telemetry_enabled = os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() - return telemetry_enabled not in ("1", "true", "yes", "on") + if get_posthog_telemetry_client is None: + _LOGGER.debug("posthog package not found – telemetry disabled") + return + try: + _CLIENT = get_posthog_telemetry_client() + _ENABLED = True + except Exception as exc: # pragma: no cover + _LOGGER.debug("Failed to initialise PostHog client: %s", exc) + _CLIENT = None + _ENABLED = False -class TelemetryBackend(str, Enum): - """Available telemetry backend types.""" - - POSTHOG = "posthog" - NONE = "none" - - -class UniversalTelemetryClient: - """Universal telemetry client that delegates to the PostHog backend.""" - - def __init__( - self, - backend: Optional[str] = None, - ): - """Initialize the universal telemetry client. - - Args: - backend: Backend to use ("posthog" or "none") - If not specified, will try PostHog - """ - # Check for global opt-out first - if is_telemetry_globally_disabled(): - self.backend_type = TelemetryBackend.NONE - logger.info("Telemetry globally disabled via environment variable") - # Determine which backend to use - elif backend and backend.lower() == "none": - self.backend_type = TelemetryBackend.NONE - else: - # Auto-detect based on environment variables and available backends - if POSTHOG_AVAILABLE: - self.backend_type = TelemetryBackend.POSTHOG - else: - self.backend_type = TelemetryBackend.NONE - logger.warning("PostHog is not available, telemetry will be disabled") - - # Initialize the appropriate client - self._client = self._initialize_client() - self._enabled = self.backend_type != TelemetryBackend.NONE - - def _initialize_client(self) -> Any: - """Initialize the appropriate telemetry client based on the selected backend.""" - if self.backend_type == TelemetryBackend.POSTHOG and POSTHOG_AVAILABLE: - logger.debug("Initializing PostHog telemetry client") - return get_posthog_telemetry_client() - else: - logger.debug("No telemetry client initialized") - return None - - def increment(self, counter_name: str, value: int = 1) -> None: - """Increment a named counter. - - Args: - counter_name: Name of the counter - value: Amount to increment by (default: 1) - """ - if self._client and self._enabled: - self._client.increment(counter_name, value) - - def record_event(self, event_name: str, properties: Optional[Dict[str, Any]] = None) -> None: - """Record an event with optional properties. - - Args: - event_name: Name of the event - properties: Event properties (must not contain sensitive data) - """ - if self._client and self._enabled: - self._client.record_event(event_name, properties) - - def flush(self) -> bool: - """Flush any pending events to the backend. - - Returns: - bool: True if successful, False otherwise - """ - if self._client and self._enabled: - return self._client.flush() - return False - - def enable(self) -> None: - """Enable telemetry collection.""" - if self._client and not is_telemetry_globally_disabled(): - self._client.enable() - self._enabled = True - else: - if is_telemetry_globally_disabled(): - logger.info("Cannot enable telemetry: globally disabled via environment variable") - self._enabled = False - - def disable(self) -> None: - """Disable telemetry collection.""" - if self._client: - self._client.disable() - self._enabled = False - - def is_enabled(self) -> bool: - """Check if telemetry is enabled. - - Returns: - bool: True if telemetry is enabled, False otherwise - """ - return self._enabled and not is_telemetry_globally_disabled() - - -# Global telemetry client instance -_universal_client: Optional[UniversalTelemetryClient] = None - - -def get_telemetry_client( - backend: Optional[str] = None, -) -> UniversalTelemetryClient: - """Get or initialize the global telemetry client. - - Args: - backend: Backend to use ("posthog" or "none") - - Returns: - The global telemetry client instance - """ - global _universal_client - - if _universal_client is None: - _universal_client = UniversalTelemetryClient(backend) - - return _universal_client +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- def destroy_telemetry_client() -> None: """Destroy the global telemetry client.""" - global _universal_client - _universal_client = None + global _CLIENT + _CLIENT = None + +def is_telemetry_enabled() -> bool: + """Return True if telemetry is currently active.""" + _ensure_client() + return _ENABLED and not _telemetry_disabled() + + +def enable() -> None: + """Enable telemetry collection for this process (unless globally disabled).""" + global _ENABLED + + if _telemetry_disabled(): + _LOGGER.info("Telemetry has been disabled via environment variable.") + return + + _ensure_client() + if _CLIENT: + _CLIENT.enable() + _ENABLED = True + + +def disable() -> None: + """Disable telemetry for this process.""" + global _ENABLED + + if _CLIENT: + _CLIENT.disable() + _ENABLED = False + + +def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = None) -> None: + """Send an arbitrary PostHog event.""" + _ensure_client() + if _CLIENT and _ENABLED: + _CLIENT.record_event(event_name, properties or {}) + def increment(counter_name: str, value: int = 1) -> None: - """Increment a named counter using the global telemetry client. - - Args: - counter_name: Name of the counter - value: Amount to increment by (default: 1) - """ - client = get_telemetry_client() - client.increment(counter_name, value) - - -def record_event(event_name: str, properties: Optional[Dict[str, Any]] = None) -> None: - """Record an event with optional properties using the global telemetry client. - - Args: - event_name: Name of the event - properties: Event properties (must not contain sensitive data) - """ - client = get_telemetry_client() - client.record_event(event_name, properties) + """Increment a named counter.""" + _ensure_client() + if _CLIENT and _ENABLED: + _CLIENT.increment(counter_name, value) def flush() -> bool: - """Flush any pending events using the global telemetry client. - - Returns: - bool: True if successful, False otherwise - """ - client = get_telemetry_client() - return client.flush() - - -def enable_telemetry() -> bool: - """Enable telemetry collection globally. - - Returns: - bool: True if successfully enabled, False if globally disabled - """ - if is_telemetry_globally_disabled(): - logger.info("Cannot enable telemetry: globally disabled via environment variable") - return False - - client = get_telemetry_client() - client.enable() - return True - - -def disable_telemetry() -> None: - """Disable telemetry collection globally.""" - client = get_telemetry_client() - client.disable() - - -def is_telemetry_enabled() -> bool: - """Check if telemetry is enabled. - - Returns: - bool: True if telemetry is enabled, False otherwise - """ - # First check for global disable - if is_telemetry_globally_disabled(): - return False - - # Get the global client and check - client = get_telemetry_client() - return client.is_enabled() - - -def set_telemetry_log_level(level: Optional[int] = None) -> None: - """Set the logging level for telemetry loggers to reduce console output. - - By default, checks the CUA_TELEMETRY_LOG_LEVEL environment variable: - - If set to "DEBUG", sets level to logging.DEBUG - - If set to "INFO", sets level to logging.INFO - - If set to "WARNING", sets level to logging.WARNING - - If set to "ERROR", sets level to logging.ERROR - - If not set, defaults to logging.WARNING - - This means telemetry logs will only show up when explicitly requested via - the environment variable, not during normal operation. - - Args: - level: The logging level to set (overrides environment variable if provided) - """ - # Determine the level from environment variable if not explicitly provided - if level is None: - env_level = os.environ.get("CUA_TELEMETRY_LOG_LEVEL", "WARNING").upper() - if env_level == "DEBUG": - level = logging.DEBUG - elif env_level == "INFO": - level = logging.INFO - elif env_level == "WARNING": - level = logging.WARNING - elif env_level == "ERROR": - level = logging.ERROR - else: - # Default to WARNING if environment variable is not recognized - level = logging.WARNING - - # Set the level for all telemetry-related loggers - telemetry_loggers = [ - "core.telemetry", - "agent.telemetry", - "computer.telemetry", - "posthog", - ] - - for logger_name in telemetry_loggers: - try: - logging.getLogger(logger_name).setLevel(level) - except Exception: - pass - - -# Set telemetry loggers to appropriate level based on environment variable -# This is called at module import time to ensure proper configuration before any logging happens -set_telemetry_log_level() + """Flush any queued analytics events to PostHog.""" + _ensure_client() + return bool(_CLIENT and _ENABLED and _CLIENT.flush()) From 1dc2d69f1671072e325cbe9cda13e99ddf719620 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 08:50:57 -0400 Subject: [PATCH 14/24] Remove unused functions --- .../core/core/telemetry/posthog_client.py | 58 +------------------ libs/python/core/core/telemetry/telemetry.py | 40 +------------ 2 files changed, 2 insertions(+), 96 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog_client.py b/libs/python/core/core/telemetry/posthog_client.py index 788f59b9..0643de9a 100644 --- a/libs/python/core/core/telemetry/posthog_client.py +++ b/libs/python/core/core/telemetry/posthog_client.py @@ -213,41 +213,6 @@ class PostHogTelemetryClient: logger.warning("Using random installation ID (will not persist across runs)") return str(uuid.uuid4()) - def increment(self, counter_name: str, value: int = 1) -> None: - """Increment a named counter. - - Args: - counter_name: Name of the counter - value: Amount to increment by (default: 1) - """ - if not self.config.enabled: - return - - # Apply sampling to reduce number of events - if random.random() * 100 > self.config.sample_rate: - return - - properties = { - "value": value, - "counter_name": counter_name, - "version": __version__, - } - - if self.initialized: - try: - posthog.capture( - distinct_id=self.installation_id, - event="counter_increment", - properties=properties, - ) - except Exception as e: - logger.debug(f"Failed to send counter event to PostHog: {e}") - else: - # Queue the event for later - self.queued_events.append({"event": "counter_increment", "properties": properties}) - # Try to initialize now if not already - self._initialize_posthog() - def record_event(self, event_name: str, properties: Optional[Dict[str, Any]] = None) -> None: """Record an event with optional properties. @@ -307,21 +272,6 @@ class PostHogTelemetryClient: logger.debug(f"Failed to flush PostHog events: {e}") return False - def enable(self) -> None: - """Enable telemetry collection.""" - self.config.enabled = True - if posthog: - posthog.disabled = False - logger.info("Telemetry enabled") - self._initialize_posthog() - - def disable(self) -> None: - """Disable telemetry collection.""" - self.config.enabled = False - if posthog: - posthog.disabled = True - logger.info("Telemetry disabled") - # Global telemetry client instance _client: Optional[PostHogTelemetryClient] = None @@ -338,10 +288,4 @@ def get_posthog_telemetry_client() -> PostHogTelemetryClient: if _client is None: _client = PostHogTelemetryClient() - return _client - - -def disable_telemetry() -> None: - """Disable telemetry collection globally.""" - if _client is not None: - _client.disable() + return _client \ No newline at end of file diff --git a/libs/python/core/core/telemetry/telemetry.py b/libs/python/core/core/telemetry/telemetry.py index b8608caf..300e036d 100644 --- a/libs/python/core/core/telemetry/telemetry.py +++ b/libs/python/core/core/telemetry/telemetry.py @@ -5,7 +5,6 @@ Usage: from core.telemetry import record_event, increment record_event("my_event", {"foo": "bar"}) - increment("my_counter") Configuration: • Disable telemetry globally by setting the environment variable @@ -90,45 +89,8 @@ def is_telemetry_enabled() -> bool: _ensure_client() return _ENABLED and not _telemetry_disabled() - -def enable() -> None: - """Enable telemetry collection for this process (unless globally disabled).""" - global _ENABLED - - if _telemetry_disabled(): - _LOGGER.info("Telemetry has been disabled via environment variable.") - return - - _ensure_client() - if _CLIENT: - _CLIENT.enable() - _ENABLED = True - - -def disable() -> None: - """Disable telemetry for this process.""" - global _ENABLED - - if _CLIENT: - _CLIENT.disable() - _ENABLED = False - - def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = None) -> None: """Send an arbitrary PostHog event.""" _ensure_client() if _CLIENT and _ENABLED: - _CLIENT.record_event(event_name, properties or {}) - - -def increment(counter_name: str, value: int = 1) -> None: - """Increment a named counter.""" - _ensure_client() - if _CLIENT and _ENABLED: - _CLIENT.increment(counter_name, value) - - -def flush() -> bool: - """Flush any queued analytics events to PostHog.""" - _ensure_client() - return bool(_CLIENT and _ENABLED and _CLIENT.flush()) + _CLIENT.record_event(event_name, properties or {}) \ No newline at end of file From ad9765ec7ad2b5c9903e469693e341d9b0279720 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 08:54:35 -0400 Subject: [PATCH 15/24] Remove sample rate --- libs/python/core/core/telemetry/posthog_client.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog_client.py b/libs/python/core/core/telemetry/posthog_client.py index 0643de9a..10e53277 100644 --- a/libs/python/core/core/telemetry/posthog_client.py +++ b/libs/python/core/core/telemetry/posthog_client.py @@ -18,9 +18,6 @@ from core import __version__ logger = logging.getLogger("core.telemetry") -# Controls how frequently telemetry will be sent (percentage) -TELEMETRY_SAMPLE_RATE = 100 # 100% sampling rate (was 5%) - # Public PostHog config for anonymous telemetry # These values are intentionally public and meant for anonymous telemetry only # https://posthog.com/docs/product-analytics/troubleshooting#is-it-ok-for-my-api-key-to-be-exposed-and-public @@ -33,7 +30,6 @@ class TelemetryConfig: """Configuration for telemetry collection.""" enabled: bool = True # Default to enabled (opt-out) - sample_rate: float = TELEMETRY_SAMPLE_RATE @classmethod def from_env(cls) -> TelemetryConfig: @@ -47,14 +43,12 @@ class TelemetryConfig: return cls( enabled=not telemetry_disabled, - sample_rate=float(os.environ.get("CUA_TELEMETRY_SAMPLE_RATE", TELEMETRY_SAMPLE_RATE)), ) def to_dict(self) -> Dict[str, Any]: """Convert config to dictionary.""" return { "enabled": self.enabled, - "sample_rate": self.sample_rate, } @@ -85,7 +79,7 @@ class PostHogTelemetryClient: # Log telemetry status on startup if self.config.enabled: - logger.info(f"Telemetry enabled (sampling at {self.config.sample_rate}%)") + logger.info(f"Telemetry enabled") # Initialize PostHog client if config is available self._initialize_posthog() else: @@ -224,13 +218,6 @@ class PostHogTelemetryClient: logger.debug(f"Telemetry disabled, skipping event: {event_name}") return - # Apply sampling to reduce number of events - if random.random() * 100 > self.config.sample_rate: - logger.debug( - f"Event sampled out due to sampling rate {self.config.sample_rate}%: {event_name}" - ) - return - event_properties = {"version": __version__, **(properties or {})} logger.info(f"Recording event: {event_name} with properties: {event_properties}") From 9c22cfa9e2c3f006f28fcc92534ab739b4a26543 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 09:23:10 -0400 Subject: [PATCH 16/24] Remove unused functions --- libs/python/core/core/telemetry/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/libs/python/core/core/telemetry/__init__.py b/libs/python/core/core/telemetry/__init__.py index 47470437..ee07ecb4 100644 --- a/libs/python/core/core/telemetry/__init__.py +++ b/libs/python/core/core/telemetry/__init__.py @@ -4,8 +4,6 @@ It provides a low-overhead way to collect anonymous usage data. """ from core.telemetry.telemetry import ( - flush, - increment, record_event, is_telemetry_enabled, destroy_telemetry_client, @@ -13,8 +11,6 @@ from core.telemetry.telemetry import ( __all__ = [ - "flush", - "increment", "record_event", "is_telemetry_enabled", "destroy_telemetry_client", From c124f04ca03f7d256c5133a10e77cc7d2cb8325d Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 09:32:00 -0400 Subject: [PATCH 17/24] Consolidate telemetry logic into one file --- libs/python/core/core/telemetry/__init__.py | 2 +- .../{posthog_client.py => posthog.py} | 35 ++++++- libs/python/core/core/telemetry/telemetry.py | 96 ------------------- 3 files changed, 35 insertions(+), 98 deletions(-) rename libs/python/core/core/telemetry/{posthog_client.py => posthog.py} (89%) delete mode 100644 libs/python/core/core/telemetry/telemetry.py diff --git a/libs/python/core/core/telemetry/__init__.py b/libs/python/core/core/telemetry/__init__.py index ee07ecb4..b5846715 100644 --- a/libs/python/core/core/telemetry/__init__.py +++ b/libs/python/core/core/telemetry/__init__.py @@ -3,7 +3,7 @@ It provides a low-overhead way to collect anonymous usage data. """ -from core.telemetry.telemetry import ( +from core.telemetry.posthog import ( record_event, is_telemetry_enabled, destroy_telemetry_client, diff --git a/libs/python/core/core/telemetry/posthog_client.py b/libs/python/core/core/telemetry/posthog.py similarity index 89% rename from libs/python/core/core/telemetry/posthog_client.py rename to libs/python/core/core/telemetry/posthog.py index 10e53277..652a899a 100644 --- a/libs/python/core/core/telemetry/posthog_client.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -275,4 +275,37 @@ def get_posthog_telemetry_client() -> PostHogTelemetryClient: if _client is None: _client = PostHogTelemetryClient() - return _client \ No newline at end of file + return _client + +# --------------------------------------------------------------------------- +# Lightweight wrapper functions (migrated from telemetry.py) +# --------------------------------------------------------------------------- + +def _telemetry_disabled() -> bool: + return ( + os.environ.get("CUA_TELEMETRY", "").lower() == "off" + or os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() not in {"1", "true", "yes", "on"} + ) + + +def destroy_telemetry_client() -> None: + """Destroy the global telemetry client instance.""" + global _client + _client = None + + +def is_telemetry_enabled() -> bool: + """Return True if telemetry is currently active.""" + if _telemetry_disabled(): + return False + client = get_posthog_telemetry_client() + return client.config.enabled if client else False + + +def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = None) -> None: + """Record an arbitrary PostHog event.""" + if _telemetry_disabled(): + return + client = get_posthog_telemetry_client() + if client and client.config.enabled: + client.record_event(event_name, properties or {}) \ No newline at end of file diff --git a/libs/python/core/core/telemetry/telemetry.py b/libs/python/core/core/telemetry/telemetry.py deleted file mode 100644 index 300e036d..00000000 --- a/libs/python/core/core/telemetry/telemetry.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Lightweight telemetry wrapper for PostHog. All helpers are thin wrappers around -a single, lazily-initialised PostHog client. - -Usage: - from core.telemetry import record_event, increment - - record_event("my_event", {"foo": "bar"}) - -Configuration: - • Disable telemetry globally by setting the environment variable - CUA_TELEMETRY=off OR CUA_TELEMETRY_ENABLED=false/0. - • Control log verbosity with CUA_TELEMETRY_LOG_LEVEL (DEBUG|INFO|WARNING|ERROR). - -If the `posthog` package (and the accompanying `PostHogTelemetryClient` helper) -are not available, every public function becomes a no-op. -""" - -from __future__ import annotations - -import logging -import os -from typing import Any, Dict, Optional - -# --------------------------------------------------------------------------- -# Logging configuration -# --------------------------------------------------------------------------- - -_DEFAULT_LOG_LEVEL = os.environ.get("CUA_TELEMETRY_LOG_LEVEL", "WARNING").upper() -logging.basicConfig(level=getattr(logging, _DEFAULT_LOG_LEVEL, logging.WARNING)) -_LOGGER = logging.getLogger("core.telemetry") - -# --------------------------------------------------------------------------- -# Attempt to import the PostHog client helper. If unavailable, telemetry is -# silently disabled. -# --------------------------------------------------------------------------- - -try: - from core.telemetry.posthog_client import get_posthog_telemetry_client # type: ignore -except ImportError: # pragma: no cover - get_posthog_telemetry_client = None # type: ignore[misc, assignment] - -# --------------------------------------------------------------------------- -# Internal helpers & primitives -# --------------------------------------------------------------------------- - -def _telemetry_disabled() -> bool: - """Return True if the user has disabled telemetry via environment vars.""" - return ( - os.environ.get("CUA_TELEMETRY", "").lower() == "off" # legacy flag - or os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() # new flag - not in {"1", "true", "yes", "on"} - ) - - -_CLIENT = None # Lazily instantiated PostHog client instance -_ENABLED = False # Guard to avoid making calls when telemetry disabled - -def _ensure_client() -> None: - """Initialise the PostHog client once and cache it globally.""" - global _CLIENT, _ENABLED - - # Bail early if telemetry is disabled or already initialised - if _CLIENT is not None or _telemetry_disabled(): - return - - if get_posthog_telemetry_client is None: - _LOGGER.debug("posthog package not found – telemetry disabled") - return - - try: - _CLIENT = get_posthog_telemetry_client() - _ENABLED = True - except Exception as exc: # pragma: no cover - _LOGGER.debug("Failed to initialise PostHog client: %s", exc) - _CLIENT = None - _ENABLED = False - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - -def destroy_telemetry_client() -> None: - """Destroy the global telemetry client.""" - global _CLIENT - _CLIENT = None - -def is_telemetry_enabled() -> bool: - """Return True if telemetry is currently active.""" - _ensure_client() - return _ENABLED and not _telemetry_disabled() - -def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = None) -> None: - """Send an arbitrary PostHog event.""" - _ensure_client() - if _CLIENT and _ENABLED: - _CLIENT.record_event(event_name, properties or {}) \ No newline at end of file From 2b29878d28ad4a5c1dde22b5c0bf881c69040540 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 09:50:33 -0400 Subject: [PATCH 18/24] Remove unused method --- libs/python/core/core/telemetry/posthog.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog.py b/libs/python/core/core/telemetry/posthog.py index 652a899a..5a173b50 100644 --- a/libs/python/core/core/telemetry/posthog.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -45,12 +45,6 @@ class TelemetryConfig: enabled=not telemetry_disabled, ) - def to_dict(self) -> Dict[str, Any]: - """Convert config to dictionary.""" - return { - "enabled": self.enabled, - } - def get_posthog_config() -> dict: """Get PostHog configuration for anonymous telemetry. From 0ff8e7986123c276840b6c41c093bc5428dd7d5c Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 09:50:37 -0400 Subject: [PATCH 19/24] Remove unused code --- libs/python/core/core/telemetry/posthog.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog.py b/libs/python/core/core/telemetry/posthog.py index 5a173b50..32a5affe 100644 --- a/libs/python/core/core/telemetry/posthog.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -2,11 +2,8 @@ from __future__ import annotations -import json import logging import os -import random -import time import uuid import sys from dataclasses import dataclass @@ -69,7 +66,6 @@ class PostHogTelemetryClient: self.installation_id = self._get_or_create_installation_id() self.initialized = False self.queued_events: List[Dict[str, Any]] = [] - self.start_time = time.time() # Log telemetry status on startup if self.config.enabled: From 1832f530756a05f68e7ef76baa6e9f882f066319 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 10:13:03 -0400 Subject: [PATCH 20/24] Remove old logic for disabling telemetry --- libs/python/core/core/telemetry/posthog.py | 55 ++++------------------ 1 file changed, 9 insertions(+), 46 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog.py b/libs/python/core/core/telemetry/posthog.py index 32a5affe..01e28ac5 100644 --- a/libs/python/core/core/telemetry/posthog.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -6,7 +6,6 @@ import logging import os import uuid import sys -from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional @@ -22,27 +21,6 @@ PUBLIC_POSTHOG_API_KEY = "phc_eSkLnbLxsnYFaXksif1ksbrNzYlJShr35miFLDppF14" PUBLIC_POSTHOG_HOST = "https://eu.i.posthog.com" -@dataclass -class TelemetryConfig: - """Configuration for telemetry collection.""" - - enabled: bool = True # Default to enabled (opt-out) - - @classmethod - def from_env(cls) -> TelemetryConfig: - """Load config from environment variables.""" - # Check for multiple environment variables that can disable telemetry: - # CUA_TELEMETRY=off to disable telemetry (legacy way) - # CUA_TELEMETRY_DISABLED=1 to disable telemetry (new, more explicit way) - telemetry_disabled = os.environ.get("CUA_TELEMETRY", "").lower() == "off" or os.environ.get( - "CUA_TELEMETRY_DISABLED", "" - ).lower() in ("1", "true", "yes", "on") - - return cls( - enabled=not telemetry_disabled, - ) - - def get_posthog_config() -> dict: """Get PostHog configuration for anonymous telemetry. @@ -62,13 +40,12 @@ class PostHogTelemetryClient: def __init__(self): """Initialize PostHog telemetry client.""" - self.config = TelemetryConfig.from_env() self.installation_id = self._get_or_create_installation_id() self.initialized = False self.queued_events: List[Dict[str, Any]] = [] # Log telemetry status on startup - if self.config.enabled: + if not _telemetry_disabled(): logger.info(f"Telemetry enabled") # Initialize PostHog client if config is available self._initialize_posthog() @@ -93,18 +70,14 @@ class PostHogTelemetryClient: # Configure the client posthog.debug = os.environ.get("CUA_TELEMETRY_DEBUG", "").lower() == "on" - posthog.disabled = not self.config.enabled # Log telemetry status - if not posthog.disabled: - logger.info( - f"Initializing PostHog telemetry with installation ID: {self.installation_id}" - ) - if posthog.debug: - logger.debug(f"PostHog API Key: {posthog.api_key}") - logger.debug(f"PostHog Host: {posthog.host}") - else: - logger.info("PostHog telemetry is disabled") + logger.info( + f"Initializing PostHog telemetry with installation ID: {self.installation_id}" + ) + if posthog.debug: + logger.debug(f"PostHog API Key: {posthog.api_key}") + logger.debug(f"PostHog Host: {posthog.host}") # Identify this installation self._identify() @@ -204,10 +177,6 @@ class PostHogTelemetryClient: event_name: Name of the event properties: Event properties (must not contain sensitive data) """ - if not self.config.enabled: - logger.debug(f"Telemetry disabled, skipping event: {event_name}") - return - event_properties = {"version": __version__, **(properties or {})} logger.info(f"Recording event: {event_name} with properties: {event_properties}") @@ -236,9 +205,6 @@ class PostHogTelemetryClient: Returns: bool: True if successful, False otherwise """ - if not self.config.enabled: - return False - if not self.initialized and not self._initialize_posthog(): return False @@ -286,10 +252,7 @@ def destroy_telemetry_client() -> None: def is_telemetry_enabled() -> bool: """Return True if telemetry is currently active.""" - if _telemetry_disabled(): - return False - client = get_posthog_telemetry_client() - return client.config.enabled if client else False + return not _telemetry_disabled() def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = None) -> None: @@ -297,5 +260,5 @@ def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = if _telemetry_disabled(): return client = get_posthog_telemetry_client() - if client and client.config.enabled: + if client: client.record_event(event_name, properties or {}) \ No newline at end of file From c14895e93a212b7056e8c96d52c398d01d160f6b Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 10:25:37 -0400 Subject: [PATCH 21/24] Consolidate telemetry logic --- libs/python/core/core/telemetry/posthog.py | 73 +++++++++++----------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog.py b/libs/python/core/core/telemetry/posthog.py index 01e28ac5..3ba91292 100644 --- a/libs/python/core/core/telemetry/posthog.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -20,21 +20,6 @@ logger = logging.getLogger("core.telemetry") PUBLIC_POSTHOG_API_KEY = "phc_eSkLnbLxsnYFaXksif1ksbrNzYlJShr35miFLDppF14" PUBLIC_POSTHOG_HOST = "https://eu.i.posthog.com" - -def get_posthog_config() -> dict: - """Get PostHog configuration for anonymous telemetry. - - Uses the public API key that's specifically intended for anonymous telemetry collection. - No private keys are used or required from users. - - Returns: - Dict with PostHog configuration - """ - # Return the public config - logger.debug("Using public PostHog configuration") - return {"api_key": PUBLIC_POSTHOG_API_KEY, "host": PUBLIC_POSTHOG_HOST} - - class PostHogTelemetryClient: """Collects and reports telemetry data via PostHog.""" @@ -45,13 +30,40 @@ class PostHogTelemetryClient: self.queued_events: List[Dict[str, Any]] = [] # Log telemetry status on startup - if not _telemetry_disabled(): + if not self._telemetry_disabled(): logger.info(f"Telemetry enabled") # Initialize PostHog client if config is available self._initialize_posthog() else: logger.info("Telemetry disabled") + @staticmethod + def _telemetry_disabled() -> bool: + """Return ``True`` when telemetry is explicitly disabled via environment variables.""" + return ( + os.environ.get("CUA_TELEMETRY", "").lower() == "off" + or os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() + not in {"1", "true", "yes", "on"} + ) + + @staticmethod + def _get_posthog_config() -> dict: + """Return PostHog configuration for anonymous telemetry (public credentials).""" + logger.debug("Using public PostHog configuration (from class method)") + return { + "api_key": PUBLIC_POSTHOG_API_KEY, + "host": PUBLIC_POSTHOG_HOST, + } + + # ------------------------------------------------------------------ + # Public helpers + # ------------------------------------------------------------------ + + @classmethod + def is_telemetry_enabled(cls) -> bool: + """True if telemetry is currently active for this process.""" + return not cls._telemetry_disabled() + def _initialize_posthog(self) -> bool: """Initialize the PostHog client with configuration. @@ -61,7 +73,7 @@ class PostHogTelemetryClient: if self.initialized: return True - posthog_config = get_posthog_config() + posthog_config = self._get_posthog_config() try: # Initialize the PostHog client @@ -177,6 +189,11 @@ class PostHogTelemetryClient: event_name: Name of the event properties: Event properties (must not contain sensitive data) """ + # Respect runtime telemetry opt-out. + if self._telemetry_disabled(): + logger.debug("Telemetry disabled; event not recorded.") + return + event_properties = {"version": __version__, **(properties or {})} logger.info(f"Recording event: {event_name} with properties: {event_properties}") @@ -233,32 +250,14 @@ def get_posthog_telemetry_client() -> PostHogTelemetryClient: return _client -# --------------------------------------------------------------------------- -# Lightweight wrapper functions (migrated from telemetry.py) -# --------------------------------------------------------------------------- - -def _telemetry_disabled() -> bool: - return ( - os.environ.get("CUA_TELEMETRY", "").lower() == "off" - or os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() not in {"1", "true", "yes", "on"} - ) - - def destroy_telemetry_client() -> None: """Destroy the global telemetry client instance.""" global _client _client = None - def is_telemetry_enabled() -> bool: - """Return True if telemetry is currently active.""" - return not _telemetry_disabled() - + return PostHogTelemetryClient.is_telemetry_enabled() def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = None) -> None: """Record an arbitrary PostHog event.""" - if _telemetry_disabled(): - return - client = get_posthog_telemetry_client() - if client: - client.record_event(event_name, properties or {}) \ No newline at end of file + get_posthog_telemetry_client().record_event(event_name, properties or {}) \ No newline at end of file From a22b3bbb4a4e2644033c4a6e968c58a4d698bc21 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 10:34:16 -0400 Subject: [PATCH 22/24] Simplify configuration code --- libs/python/core/core/telemetry/posthog.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog.py b/libs/python/core/core/telemetry/posthog.py index 3ba91292..bbc3c137 100644 --- a/libs/python/core/core/telemetry/posthog.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -46,14 +46,6 @@ class PostHogTelemetryClient: not in {"1", "true", "yes", "on"} ) - @staticmethod - def _get_posthog_config() -> dict: - """Return PostHog configuration for anonymous telemetry (public credentials).""" - logger.debug("Using public PostHog configuration (from class method)") - return { - "api_key": PUBLIC_POSTHOG_API_KEY, - "host": PUBLIC_POSTHOG_HOST, - } # ------------------------------------------------------------------ # Public helpers @@ -73,12 +65,10 @@ class PostHogTelemetryClient: if self.initialized: return True - posthog_config = self._get_posthog_config() - try: - # Initialize the PostHog client - posthog.api_key = posthog_config["api_key"] - posthog.host = posthog_config["host"] + # Allow overrides from environment for testing/region control + posthog.api_key = PUBLIC_POSTHOG_API_KEY + posthog.host = PUBLIC_POSTHOG_HOST # Configure the client posthog.debug = os.environ.get("CUA_TELEMETRY_DEBUG", "").lower() == "on" From 69960294f705d34a2a92a32a7c5453800460772e Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 10:44:05 -0400 Subject: [PATCH 23/24] Move singleton code into client class --- libs/python/core/core/telemetry/posthog.py | 33 +++++++++------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog.py b/libs/python/core/core/telemetry/posthog.py index bbc3c137..7343bd6f 100644 --- a/libs/python/core/core/telemetry/posthog.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -222,32 +222,27 @@ class PostHogTelemetryClient: logger.debug(f"Failed to flush PostHog events: {e}") return False + _singleton: Optional["PostHogTelemetryClient"] = None -# Global telemetry client instance -_client: Optional[PostHogTelemetryClient] = None + @classmethod + def get_client(cls) -> "PostHogTelemetryClient": + """Return the global PostHogTelemetryClient instance, creating it if needed.""" + if cls._singleton is None: + cls._singleton = cls() + return cls._singleton - -def get_posthog_telemetry_client() -> PostHogTelemetryClient: - """Get or initialize the global PostHog telemetry client. - - Returns: - The global telemetry client instance - """ - global _client - - if _client is None: - _client = PostHogTelemetryClient() - - return _client + @classmethod + def destroy_client(cls) -> None: + """Destroy the global PostHogTelemetryClient instance.""" + cls._singleton = None def destroy_telemetry_client() -> None: - """Destroy the global telemetry client instance.""" - global _client - _client = None + """Destroy the global PostHogTelemetryClient instance (class-managed).""" + PostHogTelemetryClient.destroy_client() def is_telemetry_enabled() -> bool: return PostHogTelemetryClient.is_telemetry_enabled() def record_event(event_name: str, properties: Optional[Dict[str, Any]] | None = None) -> None: """Record an arbitrary PostHog event.""" - get_posthog_telemetry_client().record_event(event_name, properties or {}) \ No newline at end of file + PostHogTelemetryClient.get_client().record_event(event_name, properties or {}) \ No newline at end of file From 8dc084a32d537ddc96eb19428baaaee777efead2 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 11 Aug 2025 10:58:11 -0400 Subject: [PATCH 24/24] Re-organize PostHog client methods --- libs/python/core/core/telemetry/posthog.py | 172 ++++++++++----------- 1 file changed, 80 insertions(+), 92 deletions(-) diff --git a/libs/python/core/core/telemetry/posthog.py b/libs/python/core/core/telemetry/posthog.py index 7343bd6f..a6c361e5 100644 --- a/libs/python/core/core/telemetry/posthog.py +++ b/libs/python/core/core/telemetry/posthog.py @@ -23,6 +23,9 @@ PUBLIC_POSTHOG_HOST = "https://eu.i.posthog.com" class PostHogTelemetryClient: """Collects and reports telemetry data via PostHog.""" + # Global singleton (class-managed) + _singleton: Optional["PostHogTelemetryClient"] = None + def __init__(self): """Initialize PostHog telemetry client.""" self.installation_id = self._get_or_create_installation_id() @@ -30,103 +33,22 @@ class PostHogTelemetryClient: self.queued_events: List[Dict[str, Any]] = [] # Log telemetry status on startup - if not self._telemetry_disabled(): - logger.info(f"Telemetry enabled") + if self.is_telemetry_enabled(): + logger.info("Telemetry enabled") # Initialize PostHog client if config is available self._initialize_posthog() else: logger.info("Telemetry disabled") - @staticmethod - def _telemetry_disabled() -> bool: - """Return ``True`` when telemetry is explicitly disabled via environment variables.""" - return ( - os.environ.get("CUA_TELEMETRY", "").lower() == "off" - or os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() - not in {"1", "true", "yes", "on"} - ) - - - # ------------------------------------------------------------------ - # Public helpers - # ------------------------------------------------------------------ - @classmethod def is_telemetry_enabled(cls) -> bool: """True if telemetry is currently active for this process.""" - return not cls._telemetry_disabled() - - def _initialize_posthog(self) -> bool: - """Initialize the PostHog client with configuration. - - Returns: - bool: True if initialized successfully, False otherwise - """ - if self.initialized: - return True - - try: - # Allow overrides from environment for testing/region control - posthog.api_key = PUBLIC_POSTHOG_API_KEY - posthog.host = PUBLIC_POSTHOG_HOST - - # Configure the client - posthog.debug = os.environ.get("CUA_TELEMETRY_DEBUG", "").lower() == "on" - - # Log telemetry status - logger.info( - f"Initializing PostHog telemetry with installation ID: {self.installation_id}" - ) - if posthog.debug: - logger.debug(f"PostHog API Key: {posthog.api_key}") - logger.debug(f"PostHog Host: {posthog.host}") - - # Identify this installation - self._identify() - - # Process any queued events - for event in self.queued_events: - posthog.capture( - distinct_id=self.installation_id, - event=event["event"], - properties=event["properties"], - ) - self.queued_events = [] - - self.initialized = True - return True - except Exception as e: - logger.warning(f"Failed to initialize PostHog: {e}") - return False - - def _identify(self) -> None: - """Set up user properties for the current installation with PostHog. - - Note: The Python PostHog SDK doesn't have an identify() method like the web SDK. - Instead, we capture an identification event with user properties. - """ - try: - properties = { - "version": __version__, - "is_ci": "CI" in os.environ, - "os": os.name, - "python_version": sys.version.split()[0], - } - - logger.debug( - f"Setting up PostHog user properties for: {self.installation_id} with properties: {properties}" - ) - - # In the Python SDK, we capture an identification event instead of calling identify() - posthog.capture( - distinct_id=self.installation_id, - event="$identify", - properties={"$set": properties} - ) - - logger.info(f"Set up PostHog user properties for installation: {self.installation_id}") - except Exception as e: - logger.warning(f"Failed to set up PostHog user properties: {e}") + return ( + # Legacy opt-out flag + os.environ.get("CUA_TELEMETRY", "").lower() != "off" + # Opt-in flag (defaults to enabled) + and os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() in { "1", "true", "yes", "on" } + ) def _get_or_create_installation_id(self) -> str: """Get or create a unique installation ID that persists across runs. @@ -172,6 +94,74 @@ class PostHogTelemetryClient: logger.warning("Using random installation ID (will not persist across runs)") return str(uuid.uuid4()) + def _initialize_posthog(self) -> bool: + """Initialize the PostHog client with configuration. + + Returns: + bool: True if initialized successfully, False otherwise + """ + if self.initialized: + return True + + try: + # Allow overrides from environment for testing/region control + posthog.api_key = PUBLIC_POSTHOG_API_KEY + posthog.host = PUBLIC_POSTHOG_HOST + + # Configure the client + posthog.debug = os.environ.get("CUA_TELEMETRY_DEBUG", "").lower() == "on" + + # Log telemetry status + logger.info( + f"Initializing PostHog telemetry with installation ID: {self.installation_id}" + ) + if posthog.debug: + logger.debug(f"PostHog API Key: {posthog.api_key}") + logger.debug(f"PostHog Host: {posthog.host}") + + # Identify this installation + self._identify() + + # Process any queued events + for event in self.queued_events: + posthog.capture( + distinct_id=self.installation_id, + event=event["event"], + properties=event["properties"], + ) + self.queued_events = [] + + self.initialized = True + return True + except Exception as e: + logger.warning(f"Failed to initialize PostHog: {e}") + return False + + def _identify(self) -> None: + """Set up user properties for the current installation with PostHog.""" + try: + properties = { + "version": __version__, + "is_ci": "CI" in os.environ, + "os": os.name, + "python_version": sys.version.split()[0], + } + + logger.debug( + f"Setting up PostHog user properties for: {self.installation_id} with properties: {properties}" + ) + + # In the Python SDK, we capture an identification event instead of calling identify() + posthog.capture( + distinct_id=self.installation_id, + event="$identify", + properties={"$set": properties} + ) + + logger.info(f"Set up PostHog user properties for installation: {self.installation_id}") + except Exception as e: + logger.warning(f"Failed to set up PostHog user properties: {e}") + def record_event(self, event_name: str, properties: Optional[Dict[str, Any]] = None) -> None: """Record an event with optional properties. @@ -180,7 +170,7 @@ class PostHogTelemetryClient: properties: Event properties (must not contain sensitive data) """ # Respect runtime telemetry opt-out. - if self._telemetry_disabled(): + if not self.is_telemetry_enabled(): logger.debug("Telemetry disabled; event not recorded.") return @@ -222,8 +212,6 @@ class PostHogTelemetryClient: logger.debug(f"Failed to flush PostHog events: {e}") return False - _singleton: Optional["PostHogTelemetryClient"] = None - @classmethod def get_client(cls) -> "PostHogTelemetryClient": """Return the global PostHogTelemetryClient instance, creating it if needed."""