Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
13 changes: 13 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
9 changes: 9 additions & 0 deletions src/openadapt_telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
]
5 changes: 2 additions & 3 deletions src/openadapt_telemetry/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion src/openadapt_telemetry/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from pathlib import Path
from typing import Any, Optional


# Default configuration values
DEFAULTS = {
"enabled": True,
Expand Down
4 changes: 2 additions & 2 deletions src/openadapt_telemetry/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
187 changes: 187 additions & 0 deletions src/openadapt_telemetry/posthog.py
Original file line number Diff line number Diff line change
@@ -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)
Loading