diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..24713bd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release and PyPI Publish + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + environment: release + concurrency: release + permissions: + id-token: write + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.ADMIN_TOKEN }} + + - name: Skip semantic-release follow-up commits + id: check_skip + run: | + if [ "$(git log -1 --pretty=format:'%an')" = "semantic-release" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Set up Python + if: steps.check_skip.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + if: steps.check_skip.outputs.skip != 'true' + uses: astral-sh/setup-uv@v4 + + - name: Python Semantic Release + if: steps.check_skip.outputs.skip != 'true' + id: release + uses: python-semantic-release/python-semantic-release@v9.15.2 + with: + github_token: ${{ secrets.ADMIN_TOKEN }} + + - name: Build package + if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' + run: uv build + + - name: Publish to PyPI + if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Publish to GitHub Releases + if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true' + uses: python-semantic-release/publish-action@v9.15.2 + with: + github_token: ${{ secrets.ADMIN_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5c129b8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Ruff + run: uv run ruff check . + + - name: Pytest + run: PYTHONPATH=src uv run pytest -q diff --git a/README.md b/README.md index dcb4774..172da1b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Unified telemetry and error tracking for OpenAdapt packages. ## Features - **Unified Error Tracking**: Consistent error reporting across all OpenAdapt packages +- **Usage Counters (PostHog)**: Lightweight product usage events for adoption metrics - **Privacy-First Design**: Automatic PII scrubbing and path sanitization - **Configurable Opt-Out**: Respects `DO_NOT_TRACK` and custom environment variables - **CI/Dev Mode Detection**: Automatically tags internal usage for filtering @@ -57,6 +58,18 @@ except Exception as e: raise ``` +### Capture Usage Events (PostHog) + +```python +from openadapt_telemetry import capture_usage_event + +capture_usage_event( + "agent_run", + properties={"entrypoint": "oa evals run", "mode": "live"}, + package_name="openadapt-evals", +) +``` + ### Using Decorators ```python @@ -99,6 +112,11 @@ with TelemetrySpan("indexing", "build_faiss_index") as span: | `OPENADAPT_INTERNAL` | `false` | Tag as internal usage | | `OPENADAPT_DEV` | `false` | Development mode | | `OPENADAPT_TELEMETRY_DSN` | - | GlitchTip/Sentry DSN | +| `OPENADAPT_POSTHOG_PROJECT_API_KEY` | embedded default | PostHog ingestion project token (`phc_...`) | +| `OPENADAPT_POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host | +| `OPENADAPT_TELEMETRY_DISTINCT_ID` | generated UUID | Stable anonymous identifier override | +| `OPENADAPT_TELEMETRY_TIMEOUT_SECONDS` | `1.0` | PostHog network timeout | +| `OPENADAPT_TELEMETRY_IN_CI` | `false` | Enable usage events in CI pipelines | | `OPENADAPT_TELEMETRY_ENVIRONMENT` | `production` | Environment name | | `OPENADAPT_TELEMETRY_SAMPLE_RATE` | `1.0` | Error sampling rate (0.0-1.0) | | `OPENADAPT_TELEMETRY_TRACES_SAMPLE_RATE` | `0.01` | Performance sampling rate | diff --git a/pyproject.toml b/pyproject.toml index ac75eed..9b9ec26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,3 +73,16 @@ markers = [ "unit: marks tests as unit tests", "integration: marks tests as integration tests", ] + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version", "src/openadapt_telemetry/__init__.py:__version__"] +commit_message = "chore: release {version}" +major_on_zero = false + +[tool.semantic_release.branches.main] +match = "main" + +[tool.semantic_release.commit_parser_options] +allowed_tags = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "style", "test"] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] diff --git a/src/openadapt_telemetry/__init__.py b/src/openadapt_telemetry/__init__.py index c65a395..63651d4 100644 --- a/src/openadapt_telemetry/__init__.py +++ b/src/openadapt_telemetry/__init__.py @@ -80,6 +80,12 @@ def add_demo(demo_id, task): track_shutdown, track_startup, ) +from openadapt_telemetry.posthog import ( + capture_event as capture_posthog_event, +) +from openadapt_telemetry.posthog import ( + capture_usage_event, +) from openadapt_telemetry.privacy import ( PII_DENYLIST, create_before_send_filter, @@ -132,4 +138,7 @@ def add_demo(demo_id, task): "track_command", "track_operation", "track_error", + # PostHog usage events + "capture_posthog_event", + "capture_usage_event", ] diff --git a/src/openadapt_telemetry/client.py b/src/openadapt_telemetry/client.py index 2db0c43..7dfb81a 100644 --- a/src/openadapt_telemetry/client.py +++ b/src/openadapt_telemetry/client.py @@ -10,13 +10,12 @@ import platform import sys from pathlib import Path -from typing import Any, Callable, Dict, Optional +from typing import Any, Dict, Optional import sentry_sdk -from sentry_sdk.types import Event, Hint from .config import TelemetryConfig, load_config -from .privacy import create_before_send_filter, sanitize_path +from .privacy import create_before_send_filter def is_running_from_executable() -> bool: diff --git a/src/openadapt_telemetry/config.py b/src/openadapt_telemetry/config.py index 39ac6f8..6bbf4c1 100644 --- a/src/openadapt_telemetry/config.py +++ b/src/openadapt_telemetry/config.py @@ -15,7 +15,6 @@ from pathlib import Path from typing import Any, Optional - # Default configuration values DEFAULTS = { "enabled": True, diff --git a/src/openadapt_telemetry/decorators.py b/src/openadapt_telemetry/decorators.py index 7925f34..1e6d714 100644 --- a/src/openadapt_telemetry/decorators.py +++ b/src/openadapt_telemetry/decorators.py @@ -10,7 +10,7 @@ import functools import time -from typing import Any, Callable, Optional, TypeVar, Union +from typing import Any, Callable, Optional, TypeVar import sentry_sdk @@ -57,7 +57,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: result = func(*args, **kwargs) transaction.set_status("ok") return result - except Exception as e: + except Exception: transaction.set_status("internal_error") raise finally: diff --git a/src/openadapt_telemetry/posthog.py b/src/openadapt_telemetry/posthog.py new file mode 100644 index 0000000..9c47024 --- /dev/null +++ b/src/openadapt_telemetry/posthog.py @@ -0,0 +1,187 @@ +"""PostHog usage-event client for OpenAdapt packages. + +This module captures lightweight, privacy-safe usage counters (for example: +`agent_run`, `action_executed`, `demo_recorded`) to PostHog ingestion. +""" + +from __future__ import annotations + +import json +import os +import platform +import queue +import threading +import time +import urllib.error +import urllib.request +import uuid +from importlib import metadata +from pathlib import Path +from typing import Any + +from .client import is_ci_environment +from .privacy import scrub_dict + +DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" +DEFAULT_POSTHOG_PROJECT_API_KEY = "phc_935iWKc6O7u6DCp2eFAmK5WmCwv35QXMa6LulTJ3uqh" +DISTINCT_ID_FILE = Path.home() / ".openadapt" / "telemetry_distinct_id" +MAX_STRING_LEN = 256 +QUEUE_MAXSIZE = 2048 + +_event_queue: queue.Queue[dict[str, Any]] | None = None +_worker_started = False +_worker_lock = threading.Lock() + + +def _is_truthy(raw: str | None) -> bool: + return str(raw or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _usage_enabled() -> bool: + if _is_truthy(os.getenv("DO_NOT_TRACK")): + return False + + explicit = os.getenv("OPENADAPT_TELEMETRY_ENABLED") + if explicit is not None: + return _is_truthy(explicit) + + if is_ci_environment() and not _is_truthy(os.getenv("OPENADAPT_TELEMETRY_IN_CI")): + return False + + return True + + +def _posthog_host() -> str: + return os.getenv("OPENADAPT_POSTHOG_HOST", DEFAULT_POSTHOG_HOST).rstrip("/") + + +def _posthog_project_api_key() -> str: + return os.getenv("OPENADAPT_POSTHOG_PROJECT_API_KEY", DEFAULT_POSTHOG_PROJECT_API_KEY) + + +def _get_distinct_id() -> str: + env_id = os.getenv("OPENADAPT_TELEMETRY_DISTINCT_ID") + if env_id: + return env_id + + try: + if DISTINCT_ID_FILE.exists(): + existing = DISTINCT_ID_FILE.read_text(encoding="utf-8").strip() + if existing: + return existing + DISTINCT_ID_FILE.parent.mkdir(parents=True, exist_ok=True) + generated = str(uuid.uuid4()) + DISTINCT_ID_FILE.write_text(generated, encoding="utf-8") + return generated + except OSError: + return str(uuid.uuid4()) + + +def _normalize_value(value: Any) -> Any: + if value is None or isinstance(value, (int, float, bool)): + return value + return str(value)[:MAX_STRING_LEN] + + +def _sanitize_properties(properties: dict[str, Any] | None) -> dict[str, Any]: + if not properties: + return {} + normalized = {str(k): _normalize_value(v) for k, v in properties.items() if str(k).strip()} + redacted = scrub_dict(normalized, deep=True, scrub_values=False) + return {k: v for k, v in redacted.items() if v != "[REDACTED]"} + + +def _package_version(package_name: str) -> str: + try: + return metadata.version(package_name) + except metadata.PackageNotFoundError: + return "unknown" + + +def _base_properties(package_name: str) -> dict[str, Any]: + return { + "package": package_name, + "version": _package_version(package_name), + "python_version": platform.python_version(), + "platform": platform.system().lower(), + "timestamp": int(time.time()), + } + + +def _send_payload(payload: dict[str, Any]) -> None: + timeout_seconds = float(os.getenv("OPENADAPT_TELEMETRY_TIMEOUT_SECONDS", "1.0")) + req = urllib.request.Request( + f"{_posthog_host()}/capture/", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "User-Agent": "openadapt-telemetry-posthog/1", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout_seconds): + return + except (urllib.error.URLError, TimeoutError, OSError, ValueError): + return + + +def _worker_loop() -> None: + assert _event_queue is not None + while True: + payload = _event_queue.get() + _send_payload(payload) + _event_queue.task_done() + + +def _ensure_worker() -> queue.Queue[dict[str, Any]]: + global _event_queue + global _worker_started + + with _worker_lock: + if _event_queue is None: + _event_queue = queue.Queue(maxsize=QUEUE_MAXSIZE) + if not _worker_started: + thread = threading.Thread(target=_worker_loop, daemon=True, name="oa-posthog") + thread.start() + _worker_started = True + return _event_queue + + +def capture_event( + event: str, + properties: dict[str, Any] | None = None, + package_name: str = "openadapt", +) -> bool: + """Queue a usage event for PostHog ingestion. + + Returns True when queued; False when disabled or dropped. + """ + event_name = str(event or "").strip() + if not event_name or not _usage_enabled(): + return False + + payload = { + "api_key": _posthog_project_api_key(), + "event": event_name, + "distinct_id": _get_distinct_id(), + "properties": { + **_base_properties(package_name), + **_sanitize_properties(properties), + }, + } + + try: + _ensure_worker().put_nowait(payload) + return True + except queue.Full: + return False + + +def capture_usage_event( + event: str, + properties: dict[str, Any] | None = None, + package_name: str = "openadapt", +) -> bool: + """Alias for capture_event to make usage intent explicit.""" + return capture_event(event=event, properties=properties, package_name=package_name) diff --git a/src/openadapt_telemetry/privacy.py b/src/openadapt_telemetry/privacy.py index 3e62c18..8117581 100644 --- a/src/openadapt_telemetry/privacy.py +++ b/src/openadapt_telemetry/privacy.py @@ -12,7 +12,6 @@ import re from typing import Any, Dict, List, Optional, Set - # Sensitive field names that should have their values redacted PII_DENYLIST: Set[str] = { # Authentication diff --git a/tests/test_client.py b/tests/test_client.py index c74ece7..cebcdca 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,10 +1,7 @@ """Tests for telemetry client.""" import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest +from unittest.mock import patch from openadapt_telemetry.client import ( TelemetryClient, diff --git a/tests/test_config.py b/tests/test_config.py index a196895..4809daa 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,7 +9,6 @@ import pytest from openadapt_telemetry.config import ( - CONFIG_FILE, TelemetryConfig, _get_env_config, _load_config_file, diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 00346e7..e5698b8 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,6 +1,5 @@ """Tests for telemetry decorators.""" -import os from unittest.mock import MagicMock, patch import pytest @@ -171,20 +170,20 @@ def teardown_method(self): def test_basic_usage(self): """Basic span usage should work.""" - with TelemetrySpan("test_op", "test_span") as span: + with TelemetrySpan("test_op", "test_span"): result = 1 + 1 assert result == 2 def test_with_description(self): """Span with description should work.""" - with TelemetrySpan("op", "name", description="Test description") as span: + with TelemetrySpan("op", "name", description="Test description"): pass def test_exception_handling(self): """Exceptions should propagate through span.""" with pytest.raises(ValueError, match="test"): - with TelemetrySpan("op", "name") as span: + with TelemetrySpan("op", "name"): raise ValueError("test") def test_set_tag(self): diff --git a/tests/test_posthog.py b/tests/test_posthog.py new file mode 100644 index 0000000..ed5503f --- /dev/null +++ b/tests/test_posthog.py @@ -0,0 +1,74 @@ +"""Tests for PostHog usage-event helpers.""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import openadapt_telemetry.posthog as posthog + + +class _CaptureQueue: + def __init__(self) -> None: + self.payload = None + + def put_nowait(self, payload): # noqa: ANN001 + self.payload = payload + + +def test_capture_event_respects_do_not_track() -> None: + with patch.dict(os.environ, {"DO_NOT_TRACK": "1"}, clear=False): + assert posthog.capture_event("agent_run") is False + + +def test_capture_event_disabled_in_ci_by_default() -> None: + with patch.dict(os.environ, {"CI": "1"}, clear=False): + with patch("openadapt_telemetry.posthog._ensure_worker") as mock_worker: + assert posthog.capture_event("agent_run") is False + mock_worker.assert_not_called() + + +def test_capture_event_enabled_in_ci_with_override() -> None: + queue = _CaptureQueue() + with patch.dict( + os.environ, + { + "CI": "1", + "OPENADAPT_TELEMETRY_IN_CI": "true", + "OPENADAPT_TELEMETRY_DISTINCT_ID": "test-id", + }, + clear=False, + ): + with patch("openadapt_telemetry.posthog._ensure_worker", return_value=queue): + assert posthog.capture_event("agent_run", {"mode": "live"}) is True + assert queue.payload is not None + assert queue.payload["event"] == "agent_run" + assert queue.payload["distinct_id"] == "test-id" + + +def test_capture_event_scrubs_sensitive_properties() -> None: + queue = _CaptureQueue() + with patch.dict( + os.environ, + { + "OPENADAPT_TELEMETRY_ENABLED": "true", + "OPENADAPT_TELEMETRY_DISTINCT_ID": "test-id", + }, + clear=False, + ): + with patch("openadapt_telemetry.posthog._ensure_worker", return_value=queue): + ok = posthog.capture_event( + "agent_run", + { + "entrypoint": "oa evals run", + "api_key": "should-not-send", + "password": "secret", + }, + package_name="openadapt-evals", + ) + assert ok is True + props = queue.payload["properties"] + assert props["package"] == "openadapt-evals" + assert props["entrypoint"] == "oa evals run" + assert "api_key" not in props + assert "password" not in props diff --git a/tests/test_privacy.py b/tests/test_privacy.py index e33dbbc..af37ae0 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -1,6 +1,5 @@ """Tests for privacy filtering and PII scrubbing.""" -import pytest from openadapt_telemetry.privacy import ( is_sensitive_key,