Skip to content

Commit 31d5d58

Browse files
committed
Merge main into fix/add-readme-badges
2 parents a865432 + 052cabf commit 31d5d58

File tree

8 files changed

+426
-0
lines changed

8 files changed

+426
-0
lines changed

.github/workflows/release.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Release and PyPI Publish
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
release:
10+
runs-on: ubuntu-latest
11+
environment: release
12+
concurrency: release
13+
permissions:
14+
id-token: write
15+
contents: write
16+
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v4
20+
with:
21+
fetch-depth: 0
22+
token: ${{ secrets.ADMIN_TOKEN }}
23+
24+
- name: Skip semantic-release follow-up commits
25+
id: check_skip
26+
run: |
27+
if [ "$(git log -1 --pretty=format:'%an')" = "semantic-release" ]; then
28+
echo "skip=true" >> $GITHUB_OUTPUT
29+
fi
30+
31+
- name: Set up Python
32+
if: steps.check_skip.outputs.skip != 'true'
33+
uses: actions/setup-python@v5
34+
with:
35+
python-version: '3.12'
36+
37+
- name: Install uv
38+
if: steps.check_skip.outputs.skip != 'true'
39+
uses: astral-sh/setup-uv@v4
40+
41+
- name: Python Semantic Release
42+
if: steps.check_skip.outputs.skip != 'true'
43+
id: release
44+
uses: python-semantic-release/python-semantic-release@v9.15.2
45+
with:
46+
github_token: ${{ secrets.ADMIN_TOKEN }}
47+
48+
- name: Build package
49+
if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true'
50+
run: uv build
51+
52+
- name: Publish to PyPI
53+
if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true'
54+
uses: pypa/gh-action-pypi-publish@release/v1
55+
56+
- name: Publish to GitHub Releases
57+
if: steps.check_skip.outputs.skip != 'true' && steps.release.outputs.released == 'true'
58+
uses: python-semantic-release/publish-action@v9.15.2
59+
with:
60+
github_token: ${{ secrets.ADMIN_TOKEN }}

.github/workflows/test.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Test
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout repository
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: '3.12'
20+
21+
- name: Install uv
22+
uses: astral-sh/setup-uv@v4
23+
24+
- name: Install dependencies
25+
run: uv sync --extra dev
26+
27+
- name: Ruff
28+
run: uv run ruff check .
29+
30+
- name: Pytest
31+
run: PYTHONPATH=src uv run pytest -q

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# CHANGELOG
2+
3+
4+
## v0.1.0 (2026-03-05)
5+
6+
### Bug Fixes
7+
8+
- Add README badges for license and Python version
9+
([#1](https://github.com/OpenAdaptAI/openadapt-telemetry/pull/1),
10+
[`5e867d5`](https://github.com/OpenAdaptAI/openadapt-telemetry/commit/5e867d5644028cb97762a0ce53a830d8c0cd96ac))
11+
12+
Add standard badges for license and Python version. PyPI badges are commented out until the package
13+
is published.
14+
15+
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
16+
17+
- **ci**: Correct semantic-release version targets
18+
([`c51d7c0`](https://github.com/OpenAdaptAI/openadapt-telemetry/commit/c51d7c0c5a1d502b8f910d78273963812b3bf759))
19+
20+
### Code Style
21+
22+
- Clear legacy ruff violations
23+
([`70afe50`](https://github.com/OpenAdaptAI/openadapt-telemetry/commit/70afe507194d8f0dacd30a77b7145ae090c7a317))
24+
25+
### Continuous Integration
26+
27+
- Scope telemetry tests to PostHog additions
28+
([`22663de`](https://github.com/OpenAdaptAI/openadapt-telemetry/commit/22663de436cd4284323d2eaba0ca52c8f8ba99f9))
29+
30+
### Features
31+
32+
- Add PostHog usage events and release automation
33+
([`7a204a4`](https://github.com/OpenAdaptAI/openadapt-telemetry/commit/7a204a4a3c264ff1d3d8e448e8cf115fd4ac622f))

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Unified telemetry and error tracking for OpenAdapt packages.
1313
## Features
1414

1515
- **Unified Error Tracking**: Consistent error reporting across all OpenAdapt packages
16+
- **Usage Counters (PostHog)**: Lightweight product usage events for adoption metrics
1617
- **Privacy-First Design**: Automatic PII scrubbing and path sanitization
1718
- **Configurable Opt-Out**: Respects `DO_NOT_TRACK` and custom environment variables
1819
- **Internal Usage Tagging**: Explicit flags + CI detection with optional git heuristic
@@ -57,6 +58,18 @@ except Exception as e:
5758
raise
5859
```
5960

61+
### Capture Usage Events (PostHog)
62+
63+
```python
64+
from openadapt_telemetry import capture_usage_event
65+
66+
capture_usage_event(
67+
"agent_run",
68+
properties={"entrypoint": "oa evals run", "mode": "live"},
69+
package_name="openadapt-evals",
70+
)
71+
```
72+
6073
### Using Decorators
6174

6275
```python
@@ -100,6 +113,11 @@ with TelemetrySpan("indexing", "build_faiss_index") as span:
100113
| `OPENADAPT_DEV` | `false` | Development mode |
101114
| `OPENADAPT_INTERNAL_FROM_GIT` | `false` | Optional: tag as internal when running from a git checkout |
102115
| `OPENADAPT_TELEMETRY_DSN` | - | GlitchTip/Sentry DSN |
116+
| `OPENADAPT_POSTHOG_PROJECT_API_KEY` | embedded default | PostHog ingestion project token (`phc_...`) |
117+
| `OPENADAPT_POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host |
118+
| `OPENADAPT_TELEMETRY_DISTINCT_ID` | generated UUID | Stable anonymous identifier override |
119+
| `OPENADAPT_TELEMETRY_TIMEOUT_SECONDS` | `1.0` | PostHog network timeout |
120+
| `OPENADAPT_TELEMETRY_IN_CI` | `false` | Enable usage events in CI pipelines |
103121
| `OPENADAPT_TELEMETRY_ENVIRONMENT` | `production` | Environment name |
104122
| `OPENADAPT_TELEMETRY_SAMPLE_RATE` | `1.0` | Error sampling rate (0.0-1.0) |
105123
| `OPENADAPT_TELEMETRY_TRACES_SAMPLE_RATE` | `0.01` | Performance sampling rate |

pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,17 @@ markers = [
7373
"unit: marks tests as unit tests",
7474
"integration: marks tests as integration tests",
7575
]
76+
77+
[tool.semantic_release]
78+
version_toml = ["pyproject.toml:project.version"]
79+
version_variables = ["src/openadapt_telemetry/__init__.py:__version__"]
80+
commit_message = "chore: release {version}"
81+
major_on_zero = false
82+
83+
[tool.semantic_release.branches.main]
84+
match = "main"
85+
86+
[tool.semantic_release.commit_parser_options]
87+
allowed_tags = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "style", "test"]
88+
minor_tags = ["feat"]
89+
patch_tags = ["fix", "perf"]

src/openadapt_telemetry/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ def add_demo(demo_id, task):
8080
track_shutdown,
8181
track_startup,
8282
)
83+
from openadapt_telemetry.posthog import (
84+
capture_event as capture_posthog_event,
85+
)
86+
from openadapt_telemetry.posthog import (
87+
capture_usage_event,
88+
)
8389
from openadapt_telemetry.privacy import (
8490
PII_DENYLIST,
8591
create_before_send_filter,
@@ -132,4 +138,7 @@ def add_demo(demo_id, task):
132138
"track_command",
133139
"track_operation",
134140
"track_error",
141+
# PostHog usage events
142+
"capture_posthog_event",
143+
"capture_usage_event",
135144
]

src/openadapt_telemetry/posthog.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""PostHog usage-event client for OpenAdapt packages.
2+
3+
This module captures lightweight, privacy-safe usage counters (for example:
4+
`agent_run`, `action_executed`, `demo_recorded`) to PostHog ingestion.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import os
11+
import platform
12+
import queue
13+
import threading
14+
import time
15+
import urllib.error
16+
import urllib.request
17+
import uuid
18+
from importlib import metadata
19+
from pathlib import Path
20+
from typing import Any
21+
22+
from .client import is_ci_environment
23+
from .privacy import scrub_dict
24+
25+
DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
26+
DEFAULT_POSTHOG_PROJECT_API_KEY = "phc_935iWKc6O7u6DCp2eFAmK5WmCwv35QXMa6LulTJ3uqh"
27+
DISTINCT_ID_FILE = Path.home() / ".openadapt" / "telemetry_distinct_id"
28+
MAX_STRING_LEN = 256
29+
QUEUE_MAXSIZE = 2048
30+
31+
_event_queue: queue.Queue[dict[str, Any]] | None = None
32+
_worker_started = False
33+
_worker_lock = threading.Lock()
34+
35+
36+
def _is_truthy(raw: str | None) -> bool:
37+
return str(raw or "").strip().lower() in {"1", "true", "yes", "on"}
38+
39+
40+
def _usage_enabled() -> bool:
41+
if _is_truthy(os.getenv("DO_NOT_TRACK")):
42+
return False
43+
44+
explicit = os.getenv("OPENADAPT_TELEMETRY_ENABLED")
45+
if explicit is not None:
46+
return _is_truthy(explicit)
47+
48+
if is_ci_environment() and not _is_truthy(os.getenv("OPENADAPT_TELEMETRY_IN_CI")):
49+
return False
50+
51+
return True
52+
53+
54+
def _posthog_host() -> str:
55+
return os.getenv("OPENADAPT_POSTHOG_HOST", DEFAULT_POSTHOG_HOST).rstrip("/")
56+
57+
58+
def _posthog_project_api_key() -> str:
59+
return os.getenv("OPENADAPT_POSTHOG_PROJECT_API_KEY", DEFAULT_POSTHOG_PROJECT_API_KEY)
60+
61+
62+
def _get_distinct_id() -> str:
63+
env_id = os.getenv("OPENADAPT_TELEMETRY_DISTINCT_ID")
64+
if env_id:
65+
return env_id
66+
67+
try:
68+
if DISTINCT_ID_FILE.exists():
69+
existing = DISTINCT_ID_FILE.read_text(encoding="utf-8").strip()
70+
if existing:
71+
return existing
72+
DISTINCT_ID_FILE.parent.mkdir(parents=True, exist_ok=True)
73+
generated = str(uuid.uuid4())
74+
DISTINCT_ID_FILE.write_text(generated, encoding="utf-8")
75+
return generated
76+
except OSError:
77+
return str(uuid.uuid4())
78+
79+
80+
def _normalize_value(value: Any) -> Any:
81+
if value is None or isinstance(value, (int, float, bool)):
82+
return value
83+
return str(value)[:MAX_STRING_LEN]
84+
85+
86+
def _sanitize_properties(properties: dict[str, Any] | None) -> dict[str, Any]:
87+
if not properties:
88+
return {}
89+
normalized = {str(k): _normalize_value(v) for k, v in properties.items() if str(k).strip()}
90+
redacted = scrub_dict(normalized, deep=True, scrub_values=False)
91+
return {k: v for k, v in redacted.items() if v != "[REDACTED]"}
92+
93+
94+
def _package_version(package_name: str) -> str:
95+
try:
96+
return metadata.version(package_name)
97+
except metadata.PackageNotFoundError:
98+
return "unknown"
99+
100+
101+
def _base_properties(package_name: str) -> dict[str, Any]:
102+
return {
103+
"package": package_name,
104+
"version": _package_version(package_name),
105+
"python_version": platform.python_version(),
106+
"platform": platform.system().lower(),
107+
"timestamp": int(time.time()),
108+
}
109+
110+
111+
def _send_payload(payload: dict[str, Any]) -> None:
112+
timeout_seconds = float(os.getenv("OPENADAPT_TELEMETRY_TIMEOUT_SECONDS", "1.0"))
113+
req = urllib.request.Request(
114+
f"{_posthog_host()}/capture/",
115+
data=json.dumps(payload).encode("utf-8"),
116+
headers={
117+
"Content-Type": "application/json",
118+
"User-Agent": "openadapt-telemetry-posthog/1",
119+
},
120+
method="POST",
121+
)
122+
try:
123+
with urllib.request.urlopen(req, timeout=timeout_seconds):
124+
return
125+
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
126+
return
127+
128+
129+
def _worker_loop() -> None:
130+
assert _event_queue is not None
131+
while True:
132+
payload = _event_queue.get()
133+
_send_payload(payload)
134+
_event_queue.task_done()
135+
136+
137+
def _ensure_worker() -> queue.Queue[dict[str, Any]]:
138+
global _event_queue
139+
global _worker_started
140+
141+
with _worker_lock:
142+
if _event_queue is None:
143+
_event_queue = queue.Queue(maxsize=QUEUE_MAXSIZE)
144+
if not _worker_started:
145+
thread = threading.Thread(target=_worker_loop, daemon=True, name="oa-posthog")
146+
thread.start()
147+
_worker_started = True
148+
return _event_queue
149+
150+
151+
def capture_event(
152+
event: str,
153+
properties: dict[str, Any] | None = None,
154+
package_name: str = "openadapt",
155+
) -> bool:
156+
"""Queue a usage event for PostHog ingestion.
157+
158+
Returns True when queued; False when disabled or dropped.
159+
"""
160+
event_name = str(event or "").strip()
161+
if not event_name or not _usage_enabled():
162+
return False
163+
164+
payload = {
165+
"api_key": _posthog_project_api_key(),
166+
"event": event_name,
167+
"distinct_id": _get_distinct_id(),
168+
"properties": {
169+
**_base_properties(package_name),
170+
**_sanitize_properties(properties),
171+
},
172+
}
173+
174+
try:
175+
_ensure_worker().put_nowait(payload)
176+
return True
177+
except queue.Full:
178+
return False
179+
180+
181+
def capture_usage_event(
182+
event: str,
183+
properties: dict[str, Any] | None = None,
184+
package_name: str = "openadapt",
185+
) -> bool:
186+
"""Alias for capture_event to make usage intent explicit."""
187+
return capture_event(event=event, properties=properties, package_name=package_name)

0 commit comments

Comments
 (0)