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,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 {})
|
||||
Reference in New Issue
Block a user