chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
[bumpversion]
|
||||
current_version = 0.3.1
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = core-v{new_version}
|
||||
message = Bump cua-core to v{new_version}
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
@@ -0,0 +1,29 @@
|
||||
<div align="center">
|
||||
<h1>
|
||||
<div class="image-wrapper" style="display: inline-block;">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" alt="logo" height="150" srcset="https://raw.githubusercontent.com/trycua/cua/main/img/logo_white.svg" style="display: block; margin: auto;">
|
||||
<source media="(prefers-color-scheme: light)" alt="logo" height="150" srcset="https://raw.githubusercontent.com/trycua/cua/main/img/logo_black.svg" style="display: block; margin: auto;">
|
||||
<img alt="Shows my svg">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://discord.com/invite/mVnXXpdE85)
|
||||
[](https://pypi.org/project/cua-core/)
|
||||
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
**Cua Core** provides essential shared functionality and utilities used across the Cua ecosystem:
|
||||
|
||||
- Privacy-focused telemetry system for transparent usage analytics
|
||||
- Common helper functions and utilities used by other Cua packages
|
||||
- Core infrastructure components shared between modules
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install cua-core
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Core functionality shared across Cua components."""
|
||||
|
||||
__version__ = "0.1.8"
|
||||
|
||||
from cua_core.http import CUA_CLIENT_VERSION_HEADER, cua_version_headers
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared HTTP utilities for CUA SDK requests."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
CUA_CLIENT_VERSION_HEADER = "X-Cua-Client-Version"
|
||||
|
||||
# CUA packages whose versions are included in the header value.
|
||||
_CUA_PACKAGES = ("cua-agent", "cua-computer", "cua-core")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _build_version_string() -> str:
|
||||
"""Return a composite version string like ``agent:0.4.0 computer:0.1.0 core:0.1.8``."""
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
parts: list[str] = []
|
||||
for pkg in _CUA_PACKAGES:
|
||||
try:
|
||||
short = pkg.removeprefix("cua-")
|
||||
parts.append(f"{short}:{version(pkg)}")
|
||||
except PackageNotFoundError:
|
||||
continue
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def cua_version_headers() -> dict[str, str]:
|
||||
"""Return headers dict containing the CUA client version header.
|
||||
|
||||
Only installed CUA packages are included. If none are found the dict is
|
||||
empty so it is always safe to unpack with ``**cua_version_headers()``.
|
||||
"""
|
||||
value = _build_version_string()
|
||||
if not value:
|
||||
return {}
|
||||
return {CUA_CLIENT_VERSION_HEADER: value}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""This module provides the core telemetry functionality for Cua libraries.
|
||||
|
||||
It provides a low-overhead way to collect anonymous usage data via PostHog
|
||||
and operational metrics via OpenTelemetry.
|
||||
"""
|
||||
|
||||
# OpenTelemetry instrumentation for Four Golden Signals
|
||||
from cua_core.telemetry.otel import (
|
||||
create_span,
|
||||
instrument_async,
|
||||
instrument_sync,
|
||||
is_otel_enabled,
|
||||
record_error,
|
||||
record_operation,
|
||||
record_tokens,
|
||||
track_concurrent,
|
||||
)
|
||||
from cua_core.telemetry.posthog import (
|
||||
destroy_telemetry_client,
|
||||
is_telemetry_enabled,
|
||||
record_event,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# PostHog (product analytics)
|
||||
"record_event",
|
||||
"is_telemetry_enabled",
|
||||
"destroy_telemetry_client",
|
||||
# OpenTelemetry (operational metrics)
|
||||
"is_otel_enabled",
|
||||
"record_operation",
|
||||
"record_error",
|
||||
"record_tokens",
|
||||
"track_concurrent",
|
||||
"create_span",
|
||||
"instrument_async",
|
||||
"instrument_sync",
|
||||
]
|
||||
@@ -0,0 +1,537 @@
|
||||
"""OpenTelemetry instrumentation for CUA SDK.
|
||||
|
||||
Provides metrics and tracing for the Four Golden Signals:
|
||||
- Latency: Operation duration histograms
|
||||
- Traffic: Operation counters
|
||||
- Errors: Error counters
|
||||
- Saturation: Concurrent operation gauges
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from threading import Lock
|
||||
from typing import Any, Callable, Dict, Generator, Optional, TypeVar, Union
|
||||
|
||||
logger = logging.getLogger("core.telemetry.otel")
|
||||
|
||||
# Type vars for decorator
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
# Default OTEL endpoint
|
||||
DEFAULT_OTEL_ENDPOINT = "https://otel.cua.ai"
|
||||
|
||||
# Lazy initialization state
|
||||
_initialized = False
|
||||
_init_failed = False
|
||||
_init_lock = Lock()
|
||||
|
||||
# OTEL components (lazily initialized)
|
||||
_meter: Optional[Any] = None
|
||||
_tracer: Optional[Any] = None
|
||||
_meter_provider: Optional[Any] = None
|
||||
_tracer_provider: Optional[Any] = None
|
||||
|
||||
# Metrics (lazily initialized)
|
||||
_operation_duration: Optional[Any] = None # Histogram
|
||||
_operations_total: Optional[Any] = None # Counter
|
||||
_errors_total: Optional[Any] = None # Counter
|
||||
_concurrent_operations: Optional[Any] = None # UpDownCounter
|
||||
_tokens_total: Optional[Any] = None # Counter
|
||||
|
||||
|
||||
def is_otel_enabled() -> bool:
|
||||
"""Check if OpenTelemetry is enabled.
|
||||
|
||||
Canonical opt-out: ``CUA_TELEMETRY_ENABLED=false``.
|
||||
``CUA_TELEMETRY_DISABLED`` is deprecated — a warning is emitted on first
|
||||
use and the value is honoured for backwards compatibility.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
disabled_val = os.environ.get("CUA_TELEMETRY_DISABLED", "")
|
||||
if disabled_val:
|
||||
warnings.warn(
|
||||
"CUA_TELEMETRY_DISABLED is deprecated. " "Use CUA_TELEMETRY_ENABLED=false instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if disabled_val.lower() in {"1", "true", "yes", "on"}:
|
||||
return False
|
||||
|
||||
return os.environ.get("CUA_TELEMETRY_ENABLED", "true").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def _get_otel_endpoint() -> str:
|
||||
"""Get the OTLP endpoint URL."""
|
||||
return os.environ.get("CUA_OTEL_ENDPOINT", DEFAULT_OTEL_ENDPOINT)
|
||||
|
||||
|
||||
def _get_service_name() -> str:
|
||||
"""Get the service name for OTEL."""
|
||||
return os.environ.get("CUA_OTEL_SERVICE_NAME", "cua-sdk")
|
||||
|
||||
|
||||
def _initialize_otel() -> bool:
|
||||
"""Initialize OpenTelemetry components.
|
||||
|
||||
Returns True if initialization succeeded, False otherwise.
|
||||
Thread-safe via lock.
|
||||
"""
|
||||
global _initialized, _init_failed, _meter, _tracer, _meter_provider, _tracer_provider
|
||||
global _operation_duration, _operations_total, _errors_total
|
||||
global _concurrent_operations, _tokens_total
|
||||
|
||||
if _initialized:
|
||||
return True
|
||||
if _init_failed:
|
||||
return False
|
||||
|
||||
with _init_lock:
|
||||
# Double-check after acquiring lock
|
||||
if _initialized:
|
||||
return True
|
||||
if _init_failed:
|
||||
return False
|
||||
|
||||
if not is_otel_enabled():
|
||||
logger.debug("OpenTelemetry disabled via CUA_TELEMETRY_DISABLED")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Import OTEL packages lazily
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
||||
OTLPMetricExporter,
|
||||
)
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||
OTLPSpanExporter,
|
||||
)
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
|
||||
# Create resource with service info
|
||||
resource = Resource.create(
|
||||
{
|
||||
"service.name": _get_service_name(),
|
||||
"service.version": _get_sdk_version(),
|
||||
}
|
||||
)
|
||||
|
||||
endpoint = _get_otel_endpoint()
|
||||
|
||||
# Set up metrics
|
||||
metric_exporter = OTLPMetricExporter(
|
||||
endpoint=f"{endpoint}/v1/metrics",
|
||||
)
|
||||
metric_reader = PeriodicExportingMetricReader(
|
||||
metric_exporter,
|
||||
export_interval_millis=60000, # Export every 60 seconds
|
||||
)
|
||||
_meter_provider = MeterProvider(
|
||||
resource=resource,
|
||||
metric_readers=[metric_reader],
|
||||
)
|
||||
metrics.set_meter_provider(_meter_provider)
|
||||
_meter = metrics.get_meter("cua-sdk", _get_sdk_version())
|
||||
|
||||
# Set up tracing
|
||||
trace_exporter = OTLPSpanExporter(
|
||||
endpoint=f"{endpoint}/v1/traces",
|
||||
)
|
||||
_tracer_provider = TracerProvider(resource=resource)
|
||||
_tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter))
|
||||
trace.set_tracer_provider(_tracer_provider)
|
||||
_tracer = trace.get_tracer("cua-sdk", _get_sdk_version())
|
||||
|
||||
# Create metrics instruments
|
||||
_operation_duration = _meter.create_histogram(
|
||||
name="cua_sdk_operation_duration_seconds",
|
||||
description="Duration of SDK operations in seconds",
|
||||
unit="s",
|
||||
)
|
||||
|
||||
_operations_total = _meter.create_counter(
|
||||
name="cua_sdk_operations_total",
|
||||
description="Total number of SDK operations",
|
||||
unit="1",
|
||||
)
|
||||
|
||||
_errors_total = _meter.create_counter(
|
||||
name="cua_sdk_errors_total",
|
||||
description="Total number of SDK errors",
|
||||
unit="1",
|
||||
)
|
||||
|
||||
_concurrent_operations = _meter.create_up_down_counter(
|
||||
name="cua_sdk_concurrent_operations",
|
||||
description="Number of concurrent SDK operations",
|
||||
unit="1",
|
||||
)
|
||||
|
||||
_tokens_total = _meter.create_counter(
|
||||
name="cua_sdk_tokens_total",
|
||||
description="Total tokens consumed",
|
||||
unit="1",
|
||||
)
|
||||
|
||||
# Register shutdown handler
|
||||
atexit.register(_shutdown_otel)
|
||||
|
||||
_initialized = True
|
||||
logger.info(f"OpenTelemetry initialized with endpoint: {endpoint}")
|
||||
return True
|
||||
|
||||
except ImportError as e:
|
||||
_init_failed = True
|
||||
logger.debug(
|
||||
f"OpenTelemetry packages not installed: {e}. "
|
||||
"Install with: pip install opentelemetry-api opentelemetry-sdk "
|
||||
"opentelemetry-exporter-otlp-proto-http"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
_init_failed = True
|
||||
logger.warning(f"Failed to initialize OpenTelemetry: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _shutdown_otel() -> None:
|
||||
"""Shutdown OpenTelemetry providers gracefully."""
|
||||
global _meter_provider, _tracer_provider
|
||||
|
||||
try:
|
||||
if _meter_provider is not None:
|
||||
_meter_provider.shutdown()
|
||||
if _tracer_provider is not None:
|
||||
_tracer_provider.shutdown()
|
||||
logger.debug("OpenTelemetry shutdown complete")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error during OpenTelemetry shutdown: {e}")
|
||||
|
||||
|
||||
def _get_sdk_version() -> str:
|
||||
"""Get the CUA SDK version."""
|
||||
try:
|
||||
from cua_core import __version__
|
||||
|
||||
return __version__
|
||||
except ImportError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# --- Public API ---
|
||||
|
||||
|
||||
def record_operation(
|
||||
operation: str,
|
||||
duration_seconds: float,
|
||||
status: str = "success",
|
||||
model: Optional[str] = None,
|
||||
os_type: Optional[str] = None,
|
||||
**extra_attributes: Any,
|
||||
) -> None:
|
||||
"""Record an operation metric (latency + traffic).
|
||||
|
||||
Args:
|
||||
operation: Operation name (e.g., "agent.run", "computer.action.click")
|
||||
duration_seconds: Duration of the operation in seconds
|
||||
status: Operation status ("success" or "error")
|
||||
model: Model name if applicable
|
||||
os_type: OS type if applicable
|
||||
**extra_attributes: Additional attributes to record
|
||||
"""
|
||||
if not _initialize_otel():
|
||||
return
|
||||
|
||||
attributes: Dict[str, str] = {
|
||||
"operation": operation,
|
||||
"status": status,
|
||||
}
|
||||
if model:
|
||||
attributes["model"] = model
|
||||
if os_type:
|
||||
attributes["os_type"] = os_type
|
||||
for key, value in extra_attributes.items():
|
||||
if value is not None:
|
||||
attributes[key] = str(value)
|
||||
|
||||
try:
|
||||
if _operation_duration is not None:
|
||||
_operation_duration.record(duration_seconds, attributes)
|
||||
if _operations_total is not None:
|
||||
_operations_total.add(1, attributes)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record operation metric: {e}")
|
||||
|
||||
|
||||
def record_error(
|
||||
error_type: str,
|
||||
operation: str,
|
||||
model: Optional[str] = None,
|
||||
**extra_attributes: Any,
|
||||
) -> None:
|
||||
"""Record an error metric.
|
||||
|
||||
Args:
|
||||
error_type: Type of error (e.g., "api_error", "timeout", "computer_error")
|
||||
operation: Operation that failed
|
||||
model: Model name if applicable
|
||||
**extra_attributes: Additional attributes to record
|
||||
"""
|
||||
if not _initialize_otel():
|
||||
return
|
||||
|
||||
attributes: Dict[str, str] = {
|
||||
"error_type": error_type,
|
||||
"operation": operation,
|
||||
}
|
||||
if model:
|
||||
attributes["model"] = model
|
||||
for key, value in extra_attributes.items():
|
||||
if value is not None:
|
||||
attributes[key] = str(value)
|
||||
|
||||
try:
|
||||
if _errors_total is not None:
|
||||
_errors_total.add(1, attributes)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record error metric: {e}")
|
||||
|
||||
|
||||
def record_tokens(
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
model: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record token usage metrics.
|
||||
|
||||
Args:
|
||||
prompt_tokens: Number of prompt tokens
|
||||
completion_tokens: Number of completion tokens
|
||||
model: Model name
|
||||
"""
|
||||
if not _initialize_otel():
|
||||
return
|
||||
|
||||
try:
|
||||
if _tokens_total is not None:
|
||||
if prompt_tokens > 0:
|
||||
_tokens_total.add(
|
||||
prompt_tokens,
|
||||
{"token_type": "prompt", "model": model or "unknown"},
|
||||
)
|
||||
if completion_tokens > 0:
|
||||
_tokens_total.add(
|
||||
completion_tokens,
|
||||
{"token_type": "completion", "model": model or "unknown"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record token metric: {e}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def track_concurrent(operation_type: str) -> Generator[None, None, None]:
|
||||
"""Context manager to track concurrent operations.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (e.g., "sessions", "runs")
|
||||
|
||||
Example:
|
||||
with track_concurrent("agent_sessions"):
|
||||
# session is active
|
||||
pass
|
||||
"""
|
||||
if not _initialize_otel():
|
||||
yield
|
||||
return
|
||||
|
||||
attributes = {"operation_type": operation_type}
|
||||
|
||||
try:
|
||||
if _concurrent_operations is not None:
|
||||
_concurrent_operations.add(1, attributes)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to increment concurrent counter: {e}")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
if _concurrent_operations is not None:
|
||||
_concurrent_operations.add(-1, attributes)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to decrement concurrent counter: {e}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def create_span(
|
||||
name: str,
|
||||
attributes: Optional[Dict[str, Any]] = None,
|
||||
) -> Generator[Any, None, None]:
|
||||
"""Create a trace span context.
|
||||
|
||||
Args:
|
||||
name: Span name
|
||||
attributes: Span attributes
|
||||
|
||||
Yields:
|
||||
The span object (or None if tracing disabled)
|
||||
|
||||
Example:
|
||||
with create_span("agent.run", {"model": "claude-3"}) as span:
|
||||
# do work
|
||||
if span:
|
||||
span.set_attribute("steps", 5)
|
||||
"""
|
||||
if not _initialize_otel() or _tracer is None:
|
||||
yield None
|
||||
return
|
||||
|
||||
_yielded = False
|
||||
try:
|
||||
with _tracer.start_as_current_span(name, attributes=attributes) as span:
|
||||
_yielded = True
|
||||
yield span
|
||||
except Exception as e:
|
||||
if _yielded:
|
||||
raise
|
||||
logger.debug(f"Failed to create span: {e}")
|
||||
yield None
|
||||
|
||||
|
||||
def instrument_async(
|
||||
operation: str,
|
||||
model_attr: Optional[str] = None,
|
||||
os_type_attr: Optional[str] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""Decorator to instrument an async function.
|
||||
|
||||
Records duration, success/error status, and creates a trace span.
|
||||
|
||||
Args:
|
||||
operation: Operation name for metrics
|
||||
model_attr: Attribute name to extract model from kwargs
|
||||
os_type_attr: Attribute name to extract os_type from kwargs
|
||||
|
||||
Example:
|
||||
@instrument_async("agent.run", model_attr="model")
|
||||
async def run(self, prompt: str, model: str = "claude-3"):
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not is_otel_enabled():
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
model = kwargs.get(model_attr) if model_attr else None
|
||||
os_type = kwargs.get(os_type_attr) if os_type_attr else None
|
||||
|
||||
start_time = time.perf_counter()
|
||||
status = "success"
|
||||
error_type = None
|
||||
|
||||
with create_span(operation, {"model": model, "os_type": os_type}):
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
return result
|
||||
except Exception as e:
|
||||
status = "error"
|
||||
error_type = type(e).__name__
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start_time
|
||||
record_operation(
|
||||
operation=operation,
|
||||
duration_seconds=duration,
|
||||
status=status,
|
||||
model=model,
|
||||
os_type=os_type,
|
||||
)
|
||||
if error_type:
|
||||
record_error(
|
||||
error_type=error_type,
|
||||
operation=operation,
|
||||
model=model,
|
||||
)
|
||||
|
||||
return wrapper # type: ignore
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def instrument_sync(
|
||||
operation: str,
|
||||
model_attr: Optional[str] = None,
|
||||
os_type_attr: Optional[str] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""Decorator to instrument a sync function.
|
||||
|
||||
Records duration, success/error status, and creates a trace span.
|
||||
|
||||
Args:
|
||||
operation: Operation name for metrics
|
||||
model_attr: Attribute name to extract model from kwargs
|
||||
os_type_attr: Attribute name to extract os_type from kwargs
|
||||
|
||||
Example:
|
||||
@instrument_sync("computer.screenshot")
|
||||
def screenshot(self):
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not is_otel_enabled():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
model = kwargs.get(model_attr) if model_attr else None
|
||||
os_type = kwargs.get(os_type_attr) if os_type_attr else None
|
||||
|
||||
start_time = time.perf_counter()
|
||||
status = "success"
|
||||
error_type = None
|
||||
|
||||
with create_span(operation, {"model": model, "os_type": os_type}):
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
except Exception as e:
|
||||
status = "error"
|
||||
error_type = type(e).__name__
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start_time
|
||||
record_operation(
|
||||
operation=operation,
|
||||
duration_seconds=duration,
|
||||
status=status,
|
||||
model=model,
|
||||
os_type=os_type,
|
||||
)
|
||||
if error_type:
|
||||
record_error(
|
||||
error_type=error_type,
|
||||
operation=operation,
|
||||
model=model,
|
||||
)
|
||||
|
||||
return wrapper # type: ignore
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Telemetry client using PostHog for collecting anonymous usage data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import posthog
|
||||
from cua_core import __version__
|
||||
|
||||
logger = logging.getLogger("core.telemetry")
|
||||
|
||||
# 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
|
||||
PUBLIC_POSTHOG_API_KEY = "phc_eSkLnbLxsnYFaXksif1ksbrNzYlJShr35miFLDppF14"
|
||||
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()
|
||||
self.initialized = False
|
||||
self.queued_events: List[Dict[str, Any]] = []
|
||||
|
||||
# Log telemetry status on startup
|
||||
if self.is_telemetry_enabled():
|
||||
logger.info("Telemetry enabled")
|
||||
# Initialize PostHog client if config is available
|
||||
self._initialize_posthog()
|
||||
else:
|
||||
logger.info("Telemetry disabled")
|
||||
|
||||
@classmethod
|
||||
def is_telemetry_enabled(cls) -> bool:
|
||||
"""True if telemetry is currently active for this process.
|
||||
|
||||
Canonical opt-out: ``CUA_TELEMETRY_ENABLED=false``.
|
||||
``CUA_TELEMETRY_DISABLED`` is deprecated — a warning is emitted on
|
||||
first use and the value is honoured for backwards compatibility.
|
||||
"""
|
||||
# Deprecated env var: CUA_TELEMETRY_DISABLED
|
||||
disabled_val = os.environ.get("CUA_TELEMETRY_DISABLED", "")
|
||||
if disabled_val:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"CUA_TELEMETRY_DISABLED is deprecated. " "Use CUA_TELEMETRY_ENABLED=false instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if disabled_val.lower() in {"1", "true", "yes", "on"}:
|
||||
return False
|
||||
|
||||
return 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.
|
||||
|
||||
Stored in ``~/.config/cua/installation_id`` (XDG-compliant) so that
|
||||
the ID survives package upgrades and is shared across venvs.
|
||||
|
||||
This ID is not tied to any personal information.
|
||||
"""
|
||||
try:
|
||||
config_dir = Path.home() / ".config" / "cua"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
id_file = config_dir / "installation_id"
|
||||
|
||||
# Try to read existing ID
|
||||
if id_file.exists():
|
||||
try:
|
||||
stored_id = id_file.read_text().strip()
|
||||
if stored_id: # Make sure it's not empty
|
||||
logger.debug(f"Using existing installation ID: {stored_id}")
|
||||
return stored_id
|
||||
except Exception as e:
|
||||
logger.debug(f"Error reading installation ID file: {e}")
|
||||
|
||||
# Create new ID
|
||||
new_id = str(uuid.uuid4())
|
||||
try:
|
||||
id_file.write_text(new_id)
|
||||
logger.debug(f"Created new installation ID: {new_id}")
|
||||
return new_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write installation ID: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error accessing core module directory: {e}")
|
||||
|
||||
# Last resort: Create a new in-memory ID
|
||||
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.
|
||||
|
||||
Args:
|
||||
event_name: Name of the event
|
||||
properties: Event properties (must not contain sensitive data)
|
||||
"""
|
||||
# Respect runtime telemetry opt-out.
|
||||
if not self.is_telemetry_enabled():
|
||||
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}")
|
||||
|
||||
if self.initialized:
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=self.installation_id, event=event_name, properties=event_properties
|
||||
)
|
||||
logger.info(f"Sent event to PostHog: {event_name}")
|
||||
# Flush immediately to ensure delivery
|
||||
posthog.flush()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send event to PostHog: {e}")
|
||||
else:
|
||||
# Queue the event for later
|
||||
logger.info(f"PostHog not initialized, queuing event for later: {event_name}")
|
||||
self.queued_events.append({"event": event_name, "properties": event_properties})
|
||||
# Try to initialize now if not already
|
||||
initialize_result = self._initialize_posthog()
|
||||
logger.info(f"Attempted to initialize PostHog: {initialize_result}")
|
||||
|
||||
def flush(self) -> bool:
|
||||
"""Flush any pending events to PostHog.
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
if not self.initialized and not self._initialize_posthog():
|
||||
return False
|
||||
|
||||
try:
|
||||
posthog.flush()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to flush PostHog events: {e}")
|
||||
return False
|
||||
|
||||
@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
|
||||
|
||||
@classmethod
|
||||
def destroy_client(cls) -> None:
|
||||
"""Destroy the global PostHogTelemetryClient instance."""
|
||||
cls._singleton = None
|
||||
|
||||
|
||||
def destroy_telemetry_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."""
|
||||
PostHogTelemetryClient.get_client().record_event(event_name, properties or {})
|
||||
@@ -0,0 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
@@ -0,0 +1,47 @@
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[project]
|
||||
name = "cua-core"
|
||||
version = "0.3.1"
|
||||
description = "Core functionality for Cua including telemetry and shared utilities"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "TryCua", email = "gh@trycua.com" }
|
||||
]
|
||||
dependencies = [
|
||||
"posthog>=3.20.0",
|
||||
]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# OpenTelemetry for operational metrics (Four Golden Signals)
|
||||
otel = [
|
||||
"opentelemetry-api>=1.20.0",
|
||||
"opentelemetry-sdk>=1.20.0",
|
||||
"opentelemetry-exporter-otlp-proto-http>=1.20.0",
|
||||
]
|
||||
# All telemetry features
|
||||
telemetry = [
|
||||
"opentelemetry-api>=1.20.0",
|
||||
"opentelemetry-sdk>=1.20.0",
|
||||
"opentelemetry-exporter-otlp-proto-http>=1.20.0",
|
||||
]
|
||||
|
||||
[tool.pdm]
|
||||
distribution = true
|
||||
|
||||
[tool.pdm.build]
|
||||
includes = ["cua_core/"]
|
||||
source-includes = ["tests/", "README.md", "LICENSE"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = "test_*.py"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3.5",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Pytest configuration and shared fixtures for core package tests.
|
||||
|
||||
This file contains shared fixtures and configuration for all core tests.
|
||||
Following SRP: This file ONLY handles test setup/teardown.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client():
|
||||
"""Mock httpx.AsyncClient for API calls.
|
||||
|
||||
Use this fixture to avoid making real HTTP requests during tests.
|
||||
"""
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||
yield mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_posthog():
|
||||
"""Mock PostHog client for telemetry tests.
|
||||
|
||||
Use this fixture to avoid sending real telemetry during tests.
|
||||
"""
|
||||
with patch("posthog.Posthog") as mock_ph:
|
||||
mock_instance = Mock()
|
||||
mock_ph.return_value = mock_instance
|
||||
yield mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_telemetry(monkeypatch):
|
||||
"""Disable telemetry for tests that don't need it.
|
||||
|
||||
Use this fixture to ensure telemetry is disabled during tests.
|
||||
"""
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
|
||||
yield
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Unit tests for core telemetry functionality.
|
||||
|
||||
This file tests ONLY telemetry logic, following SRP.
|
||||
All external dependencies (PostHog, file system) are mocked.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTelemetryEnabled:
|
||||
"""Test telemetry enable/disable logic (SRP: Only tests enable/disable)."""
|
||||
|
||||
def test_telemetry_enabled_by_default(self, monkeypatch):
|
||||
"""Test that telemetry is enabled by default."""
|
||||
# Remove any environment variables that might affect the test
|
||||
monkeypatch.delenv("CUA_TELEMETRY", raising=False)
|
||||
monkeypatch.delenv("CUA_TELEMETRY_ENABLED", raising=False)
|
||||
monkeypatch.delenv("CUA_TELEMETRY_DISABLED", raising=False)
|
||||
|
||||
from cua_core.telemetry import is_telemetry_enabled
|
||||
|
||||
assert is_telemetry_enabled() is True
|
||||
|
||||
def test_telemetry_disabled_with_flag(self, monkeypatch):
|
||||
"""Test that telemetry can be disabled with CUA_TELEMETRY_ENABLED=false."""
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
|
||||
|
||||
from cua_core.telemetry import is_telemetry_enabled
|
||||
|
||||
assert is_telemetry_enabled() is False
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "no", "off"])
|
||||
def test_telemetry_disabled_with_various_values(self, monkeypatch, value):
|
||||
"""Test that telemetry respects various disable values."""
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", value)
|
||||
|
||||
from cua_core.telemetry import is_telemetry_enabled
|
||||
|
||||
assert is_telemetry_enabled() is False
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "yes", "on"])
|
||||
def test_telemetry_enabled_with_various_values(self, monkeypatch, value):
|
||||
"""Test that telemetry respects various enable values."""
|
||||
monkeypatch.delenv("CUA_TELEMETRY_DISABLED", raising=False)
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", value)
|
||||
|
||||
from cua_core.telemetry import is_telemetry_enabled
|
||||
|
||||
assert is_telemetry_enabled() is True
|
||||
|
||||
|
||||
class TestPostHogTelemetryClient:
|
||||
"""Test PostHogTelemetryClient class (SRP: Only tests client logic)."""
|
||||
|
||||
@patch("cua_core.telemetry.posthog.posthog")
|
||||
@patch("cua_core.telemetry.posthog.Path")
|
||||
def test_client_initialization(self, mock_path, mock_posthog, disable_telemetry):
|
||||
"""Test that client initializes correctly."""
|
||||
from cua_core.telemetry.posthog import PostHogTelemetryClient
|
||||
|
||||
# Mock the storage directory
|
||||
mock_storage_dir = MagicMock()
|
||||
mock_storage_dir.exists.return_value = False
|
||||
mock_path.return_value.parent.parent = MagicMock()
|
||||
mock_path.return_value.parent.parent.__truediv__.return_value = mock_storage_dir
|
||||
|
||||
# Reset singleton
|
||||
PostHogTelemetryClient.destroy_client()
|
||||
|
||||
client = PostHogTelemetryClient()
|
||||
|
||||
assert client is not None
|
||||
assert hasattr(client, "installation_id")
|
||||
assert hasattr(client, "initialized")
|
||||
assert hasattr(client, "queued_events")
|
||||
|
||||
@patch("cua_core.telemetry.posthog.posthog")
|
||||
@patch("cua_core.telemetry.posthog.Path")
|
||||
def test_installation_id_generation(self, mock_path, mock_posthog, disable_telemetry):
|
||||
"""Test that installation ID is generated if not exists."""
|
||||
from cua_core.telemetry.posthog import PostHogTelemetryClient
|
||||
|
||||
# Mock file system: Path.home() / ".config" / "cua" / "installation_id"
|
||||
mock_id_file = MagicMock()
|
||||
mock_id_file.exists.return_value = False
|
||||
mock_config_dir = MagicMock()
|
||||
mock_config_dir.__truediv__.return_value = mock_id_file
|
||||
mock_path.home.return_value.__truediv__.return_value.__truediv__.return_value = (
|
||||
mock_config_dir
|
||||
)
|
||||
|
||||
# Reset singleton
|
||||
PostHogTelemetryClient.destroy_client()
|
||||
|
||||
client = PostHogTelemetryClient()
|
||||
|
||||
# Should have generated a new UUID
|
||||
assert client.installation_id is not None
|
||||
assert len(client.installation_id) == 36 # UUID format
|
||||
|
||||
@patch("cua_core.telemetry.posthog.posthog")
|
||||
@patch("cua_core.telemetry.posthog.Path")
|
||||
def test_installation_id_persistence(self, mock_path, mock_posthog, disable_telemetry):
|
||||
"""Test that installation ID is read from file if exists."""
|
||||
from cua_core.telemetry.posthog import PostHogTelemetryClient
|
||||
|
||||
existing_id = "test-installation-id-123"
|
||||
|
||||
# Mock file system: Path.home() / ".config" / "cua" / "installation_id"
|
||||
mock_id_file = MagicMock()
|
||||
mock_id_file.exists.return_value = True
|
||||
mock_id_file.read_text.return_value.strip.return_value = existing_id
|
||||
mock_config_dir = MagicMock()
|
||||
mock_config_dir.__truediv__.return_value = mock_id_file
|
||||
mock_path.home.return_value.__truediv__.return_value.__truediv__.return_value = (
|
||||
mock_config_dir
|
||||
)
|
||||
|
||||
# Reset singleton
|
||||
PostHogTelemetryClient.destroy_client()
|
||||
|
||||
client = PostHogTelemetryClient()
|
||||
|
||||
assert client.installation_id == existing_id
|
||||
|
||||
@patch("cua_core.telemetry.posthog.posthog")
|
||||
@patch("cua_core.telemetry.posthog.Path")
|
||||
def test_record_event_when_disabled(self, mock_path, mock_posthog, monkeypatch):
|
||||
"""Test that events are not recorded when telemetry is disabled."""
|
||||
from cua_core.telemetry.posthog import PostHogTelemetryClient
|
||||
|
||||
# Disable telemetry explicitly using the correct environment variable
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
|
||||
|
||||
# Mock file system
|
||||
mock_storage_dir = MagicMock()
|
||||
mock_storage_dir.exists.return_value = False
|
||||
mock_path.return_value.parent.parent = MagicMock()
|
||||
mock_path.return_value.parent.parent.__truediv__.return_value = mock_storage_dir
|
||||
|
||||
# Reset singleton
|
||||
PostHogTelemetryClient.destroy_client()
|
||||
|
||||
client = PostHogTelemetryClient()
|
||||
client.record_event("test_event", {"key": "value"})
|
||||
|
||||
# PostHog capture should not be called at all when telemetry is disabled
|
||||
mock_posthog.capture.assert_not_called()
|
||||
|
||||
@patch("cua_core.telemetry.posthog.posthog")
|
||||
@patch("cua_core.telemetry.posthog.Path")
|
||||
def test_record_event_when_enabled(self, mock_path, mock_posthog, monkeypatch):
|
||||
"""Test that events are recorded when telemetry is enabled."""
|
||||
from cua_core.telemetry.posthog import PostHogTelemetryClient
|
||||
|
||||
# Enable telemetry
|
||||
monkeypatch.delenv("CUA_TELEMETRY_DISABLED", raising=False)
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "true")
|
||||
|
||||
# Mock file system
|
||||
mock_storage_dir = MagicMock()
|
||||
mock_storage_dir.exists.return_value = False
|
||||
mock_path.return_value.parent.parent = MagicMock()
|
||||
mock_path.return_value.parent.parent.__truediv__.return_value = mock_storage_dir
|
||||
|
||||
# Reset singleton
|
||||
PostHogTelemetryClient.destroy_client()
|
||||
|
||||
client = PostHogTelemetryClient()
|
||||
client.initialized = True # Pretend it's initialized
|
||||
|
||||
event_name = "test_event"
|
||||
event_props = {"key": "value"}
|
||||
client.record_event(event_name, event_props)
|
||||
|
||||
# PostHog capture should be called
|
||||
assert mock_posthog.capture.call_count >= 1
|
||||
|
||||
@patch("cua_core.telemetry.posthog.posthog")
|
||||
@patch("cua_core.telemetry.posthog.Path")
|
||||
def test_singleton_pattern(self, mock_path, mock_posthog, disable_telemetry):
|
||||
"""Test that get_client returns the same instance."""
|
||||
from cua_core.telemetry.posthog import PostHogTelemetryClient
|
||||
|
||||
# Mock file system
|
||||
mock_storage_dir = MagicMock()
|
||||
mock_storage_dir.exists.return_value = False
|
||||
mock_path.return_value.parent.parent = MagicMock()
|
||||
mock_path.return_value.parent.parent.__truediv__.return_value = mock_storage_dir
|
||||
|
||||
# Reset singleton
|
||||
PostHogTelemetryClient.destroy_client()
|
||||
|
||||
client1 = PostHogTelemetryClient.get_client()
|
||||
client2 = PostHogTelemetryClient.get_client()
|
||||
|
||||
assert client1 is client2
|
||||
|
||||
|
||||
class TestRecordEvent:
|
||||
"""Test the public record_event function (SRP: Only tests public API)."""
|
||||
|
||||
@patch("cua_core.telemetry.posthog.PostHogTelemetryClient")
|
||||
def test_record_event_calls_client(self, mock_client_class, disable_telemetry):
|
||||
"""Test that record_event delegates to the client."""
|
||||
from cua_core.telemetry import record_event
|
||||
|
||||
mock_client_instance = Mock()
|
||||
mock_client_class.get_client.return_value = mock_client_instance
|
||||
|
||||
event_name = "test_event"
|
||||
event_props = {"key": "value"}
|
||||
|
||||
record_event(event_name, event_props)
|
||||
|
||||
mock_client_instance.record_event.assert_called_once_with(event_name, event_props)
|
||||
|
||||
@patch("cua_core.telemetry.posthog.PostHogTelemetryClient")
|
||||
def test_record_event_without_properties(self, mock_client_class, disable_telemetry):
|
||||
"""Test that record_event works without properties."""
|
||||
from cua_core.telemetry import record_event
|
||||
|
||||
mock_client_instance = Mock()
|
||||
mock_client_class.get_client.return_value = mock_client_instance
|
||||
|
||||
event_name = "test_event"
|
||||
|
||||
record_event(event_name)
|
||||
|
||||
mock_client_instance.record_event.assert_called_once_with(event_name, {})
|
||||
|
||||
|
||||
class TestDestroyTelemetryClient:
|
||||
"""Test client destruction (SRP: Only tests cleanup)."""
|
||||
|
||||
@patch("cua_core.telemetry.posthog.PostHogTelemetryClient")
|
||||
def test_destroy_client_calls_class_method(self, mock_client_class):
|
||||
"""Test that destroy_telemetry_client delegates correctly."""
|
||||
from cua_core.telemetry import destroy_telemetry_client
|
||||
|
||||
destroy_telemetry_client()
|
||||
|
||||
mock_client_class.destroy_client.assert_called_once()
|
||||
Reference in New Issue
Block a user