Skip to content

Commit 411483c

Browse files
mldangeloclaude
andcommitted
feat: add PostHog telemetry for wrapper usage tracking
Add anonymous telemetry to track Python wrapper usage patterns, matching the telemetry implementation in the main TypeScript project. - Track `wrapper_used` event with method (global/npx/error) - Share user ID with TypeScript version (~/.promptfoo/promptfoo.yaml) - Use same PostHog endpoint (https://a.promptfoo.app) - Respect PROMPTFOO_DISABLE_TELEMETRY=1 opt-out - Non-blocking, error-swallowing design Dependencies added: posthog, pyyaml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d6eb593 commit 411483c

File tree

4 files changed

+525
-1
lines changed

4 files changed

+525
-1
lines changed

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,17 @@ classifiers = [
2828
"Operating System :: OS Independent",
2929
]
3030
requires-python = ">=3.9"
31-
dependencies = []
31+
dependencies = [
32+
"posthog>=3.0.0",
33+
"pyyaml>=6.0.0",
34+
]
3235

3336
[project.optional-dependencies]
3437
dev = [
3538
"pytest>=8.4.0",
3639
"mypy>=1.16.0",
3740
"ruff>=0.12.0",
41+
"types-pyyaml>=6.0.0",
3842
]
3943

4044
[project.scripts]

src/promptfoo/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import sys
1212
from typing import NoReturn, Optional
1313

14+
from .telemetry import record_wrapper_used
15+
1416
_WRAPPER_ENV = "PROMPTFOO_PY_WRAPPER"
1517
_WINDOWS_SHELL_EXTENSIONS = (".bat", ".cmd")
1618

@@ -184,16 +186,19 @@ def main() -> NoReturn:
184186
# Build command: try external promptfoo first, fall back to npx
185187
promptfoo_path = None if os.environ.get(_WRAPPER_ENV) else _find_external_promptfoo()
186188
if promptfoo_path:
189+
record_wrapper_used("global")
187190
cmd = [promptfoo_path] + sys.argv[1:]
188191
env = os.environ.copy()
189192
env[_WRAPPER_ENV] = "1"
190193
result = _run_command(cmd, env=env)
191194
else:
192195
npx_path = shutil.which("npx")
193196
if npx_path:
197+
record_wrapper_used("npx")
194198
cmd = [npx_path, "-y", "promptfoo@latest"] + sys.argv[1:]
195199
result = _run_command(cmd)
196200
else:
201+
record_wrapper_used("error")
197202
print("ERROR: Neither promptfoo nor npx is available.", file=sys.stderr)
198203
print("Please install promptfoo: npm install -g promptfoo", file=sys.stderr)
199204
print("Or ensure Node.js is properly installed.", file=sys.stderr)

src/promptfoo/telemetry.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""
2+
Telemetry module for the promptfoo Python wrapper.
3+
4+
Sends anonymous usage analytics to PostHog to help improve promptfoo.
5+
Telemetry can be disabled by setting PROMPTFOO_DISABLE_TELEMETRY=1.
6+
"""
7+
8+
import atexit
9+
import os
10+
import platform
11+
import sys
12+
import uuid
13+
from pathlib import Path
14+
from typing import Any, Optional
15+
16+
import yaml
17+
from posthog import Posthog
18+
19+
from . import __version__
20+
21+
# PostHog configuration - same as the main promptfoo TypeScript project
22+
_POSTHOG_HOST = "https://a.promptfoo.app"
23+
_POSTHOG_KEY = "phc_E5n5uHnDo2eREJL1uqX1cIlbkoRby4yFWt3V94HqRRg"
24+
25+
26+
def _get_env_bool(name: str) -> bool:
27+
"""Check if an environment variable is set to a truthy value."""
28+
value = os.environ.get(name, "").lower()
29+
return value in ("1", "true", "yes", "on")
30+
31+
32+
def _is_ci() -> bool:
33+
"""Detect if running in a CI environment."""
34+
ci_env_vars = [
35+
"CI",
36+
"CONTINUOUS_INTEGRATION",
37+
"GITHUB_ACTIONS",
38+
"GITLAB_CI",
39+
"CIRCLECI",
40+
"TRAVIS",
41+
"JENKINS_URL",
42+
"BUILDKITE",
43+
"TEAMCITY_VERSION",
44+
"TF_BUILD", # Azure Pipelines
45+
]
46+
return any(os.environ.get(var) for var in ci_env_vars)
47+
48+
49+
def _get_config_dir() -> Path:
50+
"""Get the promptfoo config directory path."""
51+
return Path.home() / ".promptfoo"
52+
53+
54+
def _read_global_config() -> dict[str, Any]:
55+
"""Read the global promptfoo config from ~/.promptfoo/promptfoo.yaml."""
56+
config_file = _get_config_dir() / "promptfoo.yaml"
57+
if config_file.exists():
58+
try:
59+
with open(config_file) as f:
60+
config = yaml.safe_load(f)
61+
return config if isinstance(config, dict) else {}
62+
except Exception:
63+
return {}
64+
return {}
65+
66+
67+
def _write_global_config(config: dict[str, Any]) -> None:
68+
"""Write the global promptfoo config to ~/.promptfoo/promptfoo.yaml."""
69+
config_dir = _get_config_dir()
70+
config_dir.mkdir(parents=True, exist_ok=True)
71+
config_file = config_dir / "promptfoo.yaml"
72+
try:
73+
with open(config_file, "w") as f:
74+
yaml.dump(config, f, default_flow_style=False)
75+
except Exception:
76+
pass # Silently fail - telemetry should never break the CLI
77+
78+
79+
def _get_user_id() -> str:
80+
"""Get or create a unique user ID stored in the global config."""
81+
config = _read_global_config()
82+
user_id = config.get("id")
83+
84+
if not user_id:
85+
user_id = str(uuid.uuid4())
86+
config["id"] = user_id
87+
_write_global_config(config)
88+
89+
return user_id
90+
91+
92+
def _get_user_email() -> Optional[str]:
93+
"""Get the user email from the global config if set."""
94+
config = _read_global_config()
95+
account = config.get("account", {})
96+
return account.get("email") if isinstance(account, dict) else None
97+
98+
99+
class _Telemetry:
100+
"""Internal telemetry client for the promptfoo Python wrapper."""
101+
102+
def __init__(self) -> None:
103+
self._client: Optional[Posthog] = None
104+
self._user_id: Optional[str] = None
105+
self._email: Optional[str] = None
106+
self._initialized = False
107+
108+
@property
109+
def _disabled(self) -> bool:
110+
"""Check if telemetry is disabled."""
111+
return _get_env_bool("PROMPTFOO_DISABLE_TELEMETRY") or _get_env_bool("IS_TESTING")
112+
113+
def _ensure_initialized(self) -> None:
114+
"""Lazily initialize the telemetry client."""
115+
if self._initialized:
116+
return
117+
118+
self._initialized = True
119+
120+
if self._disabled:
121+
return
122+
123+
try:
124+
self._user_id = _get_user_id()
125+
self._email = _get_user_email()
126+
self._client = Posthog(
127+
project_api_key=_POSTHOG_KEY,
128+
host=_POSTHOG_HOST,
129+
)
130+
except Exception:
131+
self._client = None # Silently fail
132+
133+
def record(self, event_name: str, properties: Optional[dict[str, Any]] = None) -> None:
134+
"""Record a telemetry event."""
135+
if self._disabled:
136+
return
137+
138+
self._ensure_initialized()
139+
140+
if not self._client or not self._user_id:
141+
return
142+
143+
try:
144+
enriched_properties: dict[str, Any] = {
145+
**(properties or {}),
146+
"packageVersion": __version__,
147+
"pythonVersion": platform.python_version(),
148+
"platform": sys.platform,
149+
"isRunningInCi": _is_ci(),
150+
"source": "python-wrapper",
151+
}
152+
153+
# Only set email if present
154+
if self._email:
155+
enriched_properties["$set"] = {"email": self._email}
156+
157+
self._client.capture(
158+
event=event_name,
159+
distinct_id=self._user_id,
160+
properties=enriched_properties,
161+
)
162+
except Exception:
163+
pass # Silently fail - telemetry should never break the CLI
164+
165+
def shutdown(self) -> None:
166+
"""Shutdown the telemetry client and flush any pending events."""
167+
if self._client:
168+
try:
169+
self._client.flush()
170+
self._client.shutdown()
171+
except Exception:
172+
pass # Silently fail
173+
finally:
174+
self._client = None
175+
176+
177+
# Global singleton instance
178+
_telemetry: Optional[_Telemetry] = None
179+
180+
181+
def _get_telemetry() -> _Telemetry:
182+
"""Get the global telemetry instance."""
183+
global _telemetry
184+
if _telemetry is None:
185+
_telemetry = _Telemetry()
186+
atexit.register(_telemetry.shutdown)
187+
return _telemetry
188+
189+
190+
def record_wrapper_used(method: str) -> None:
191+
"""
192+
Record that the Python wrapper was used.
193+
194+
Args:
195+
method: The execution method used - "global" for global promptfoo install,
196+
"npx" for npx fallback, or "error" if execution failed.
197+
"""
198+
_get_telemetry().record("wrapper_used", {"method": method, "wrapperType": "python"})

0 commit comments

Comments
 (0)