Skip to content
Merged
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
# Your project API key (found on the /setup page in PostHog)
POSTHOG_PROJECT_API_KEY=phc_your_project_api_key_here

# Your personal API key (for local evaluation and other advanced features)
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key_here
# Your secret API key (for local evaluation and other advanced features)
POSTHOG_SECRET_KEY=phx_your_secret_api_key_here

# PostHog host URL (remove this line if using posthog.com)
POSTHOG_HOST=http://localhost:8010
30 changes: 13 additions & 17 deletions example.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def load_env_file():

# Get configuration
project_key = os.getenv("POSTHOG_PROJECT_API_KEY", "")
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
secret_key = os.getenv("POSTHOG_SECRET_KEY", "")
host = os.getenv("POSTHOG_HOST", "http://localhost:8010")

# Check if project key is provided (required)
Expand All @@ -49,23 +49,23 @@ def load_env_file():
posthog.host = host
posthog.poll_interval = 10

# Check if personal API key is available for local evaluation
local_eval_available = bool(personal_api_key)
if personal_api_key:
posthog.personal_api_key = personal_api_key
# Check if a secret key is available for local evaluation
local_eval_available = bool(secret_key)
if secret_key:
posthog.secret_key = secret_key

print("🔑 PostHog Configuration:")
print(f" Project API Key: {project_key[:9]}...")
if local_eval_available:
print(" Personal API Key: [SET]")
print(" Secret Key: [SET]")
else:
print(" Personal API Key: [NOT SET] - Local evaluation examples will be skipped")
print(" Secret Key: [NOT SET] - Local evaluation examples will be skipped")
print(f" Host: {host}\n")

# Display menu and get user choice
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
print("1. Identify and capture examples")
local_eval_note = "" if local_eval_available else " [requires personal API key]"
local_eval_note = "" if local_eval_available else " [requires secret key]"
print(f"2. Feature flag local evaluation examples{local_eval_note}")
print("3. Feature flag payload examples")
print(f"4. Flag dependencies examples{local_eval_note}")
Expand Down Expand Up @@ -135,10 +135,8 @@ def load_env_file():

elif choice == "2":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
print("\n❌ This example requires a secret API key for local evaluation.")
print(" Set POSTHOG_SECRET_KEY environment variable to run this example.")
posthog.shutdown()
exit(1)

Expand Down Expand Up @@ -210,10 +208,8 @@ def load_env_file():

elif choice == "4":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
print("\n❌ This example requires a secret API key for local evaluation.")
print(" Set POSTHOG_SECRET_KEY environment variable to run this example.")
posthog.shutdown()
exit(1)

Expand Down Expand Up @@ -432,7 +428,7 @@ def process_payment(payment_id):
elif choice == "6":
print("\n🔄 Running all examples...")
if not local_eval_available:
print(" (Skipping local evaluation examples - no personal API key set)\n")
print(" (Skipping local evaluation examples - no secret key set)\n")

# Run example 1
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
Expand Down
2 changes: 1 addition & 1 deletion examples/async_redis_flag_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
secret_key="<secret_key>",
flag_definition_cache_provider=cache,
)

Expand Down
2 changes: 1 addition & 1 deletion examples/celery_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def configure_posthog() -> None:
posthog.api_key = POSTHOG_PROJECT_API_KEY
posthog.host = POSTHOG_HOST
posthog.enable_local_evaluation = (
False # to not require personal_api_key for this example
False # to not require secret_key for this example
)
posthog.setup()

Expand Down
2 changes: 1 addition & 1 deletion examples/redis_flag_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
secret_key="<secret_key>",
flag_definition_cache_provider=cache,
)

Expand Down
2 changes: 1 addition & 1 deletion examples/remote_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

# Initialize PostHog client
posthog.api_key = "phc_..."
posthog.personal_api_key = "phs_..." # or "phx_..."
posthog.secret_key = "phs_..." # or "phx_..."
posthog.host = "http://localhost:8010" # or "https://us.posthog.com"
posthog.debug = True

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class PostHogProvider(AbstractProvider):
"""OpenFeature provider backed by a configured :class:`posthog.Posthog` client.

The caller owns the PostHog client lifecycle: construct and configure the
client yourself (project key, ``personal_api_key`` for local evaluation,
client yourself (project key, ``secret_key`` for local evaluation,
``host``, ...), then hand it to this provider.

Evaluation-context mapping:
Expand Down Expand Up @@ -90,13 +90,13 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
# client is configured for local evaluation. We do not otherwise mutate
# the caller-owned client, and a preload failure must not make the
# OpenFeature client un-ready (remote evaluation still works).
if getattr(self._client, "personal_api_key", None):
if getattr(self._client, "secret_key", None):
try:
self._client.load_feature_flags()
except Exception:
# Don't block the OpenFeature client on a preload failure
# (remote evaluation still works), but surface it: an invalid
# personal_api_key, unreachable host, or missing permissions
# secret_key, unreachable host, or missing permissions
# would otherwise silently disable local evaluation.
_logger.warning(
"PostHogProvider: failed to preload feature flag "
Expand Down
4 changes: 2 additions & 2 deletions openfeature-provider/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ def make_result(
def fake_client():
"""A stand-in Posthog client exposing only ``get_feature_flag_result``."""
client = MagicMock()
# No personal API key -> provider.initialize() skips load_feature_flags().
client.personal_api_key = None
# No secret key -> provider.initialize() skips load_feature_flags().
client.secret_key = None
return client


Expand Down
6 changes: 3 additions & 3 deletions openfeature-provider/tests/test_provider_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,14 @@ def test_send_feature_flag_events_forwarded(fake_client):
)


def test_initialize_skips_preload_without_personal_api_key(fake_client):
# fake_client.personal_api_key is None by default.
def test_initialize_skips_preload_without_secret_key(fake_client):
# fake_client.secret_key is None by default.
PostHogProvider(fake_client).initialize(EvaluationContext())
fake_client.load_feature_flags.assert_not_called()


def test_initialize_logs_warning_on_preload_failure(fake_client, caplog):
fake_client.personal_api_key = "phx_test"
fake_client.secret_key = "phx_test"
fake_client.load_feature_flags.side_effect = RuntimeError("bad key")
with caplog.at_level("WARNING"):
PostHogProvider(fake_client).initialize(EvaluationContext())
Expand Down
11 changes: 7 additions & 4 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,9 @@ def get_tags() -> Dict[str, Any]:
sync_mode: If True, send events synchronously instead of using background
worker threads.
disabled: If True, disable captures and API requests. Useful in tests.
personal_api_key: Personal API key used for local feature flag evaluation
and remote config payloads.
secret_key: A Personal API Key or Project Secret API Key used for local
feature flag evaluation and remote config payloads.
personal_api_key: Deprecated alias for secret_key.
poll_interval: Seconds between local feature flag definition refreshes.
disable_geoip: Whether to disable server-side GeoIP enrichment. Defaults to
True.
Expand Down Expand Up @@ -364,7 +365,8 @@ def get_tags() -> Dict[str, Any]:
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
personal_api_key = None # type: Optional[str]
secret_key = None # type: Optional[str]
personal_api_key = None # type: Optional[str] # Deprecated: use secret_key
project_api_key = None # type: Optional[str]
poll_interval = 30 # type: int
disable_geoip = True # type: bool
Expand Down Expand Up @@ -935,7 +937,7 @@ def get_remote_config_payload(
The payload associated with the feature flag. If payload is encrypted, the return value will be decrypted

Note:
Requires personal_api_key to be set for authentication
Requires secret_key to be set for authentication
"""
return _proxy(
"get_remote_config_payload",
Expand Down Expand Up @@ -1159,6 +1161,7 @@ def setup() -> Client:
on_error=on_error,
send=send,
sync_mode=sync_mode,
secret_key=secret_key,
personal_api_key=personal_api_key,
poll_interval=poll_interval,
disabled=disabled,
Expand Down
2 changes: 1 addition & 1 deletion posthog/ai/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class Prompts:
from posthog.ai.prompts import Prompts

# With PostHog client
posthog = Posthog('phc_xxx', host='https://us.posthog.com', personal_api_key='phx_xxx')
posthog = Posthog('phc_xxx', host='https://us.posthog.com', secret_key='phx_xxx')
prompts = Prompts(posthog)

# Or with direct options (no PostHog client needed)
Expand Down
45 changes: 33 additions & 12 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ def __init__(
exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS,
capture_mode: Optional[Union[CaptureMode, str]] = None,
capture_compression: Optional[Union[CaptureCompression, str]] = None,
secret_key=None,
_dedicated_ai_endpoint=False,
):
"""
Expand All @@ -294,8 +295,15 @@ def __init__(
timeout: HTTP request timeout in seconds for event uploads.
thread: Number of background consumer threads.
poll_interval: Seconds between local feature flag definition refreshes.
personal_api_key: Personal API key used for local feature flag
evaluation and remote config payloads.
secret_key: A Personal API Key or Project Secret API Key, used to
authenticate local feature flag evaluation, remote config
payloads, and decrypted flag payloads. Example::

posthog.Client(project_api_key, secret_key="phx_...")

personal_api_key: Deprecated alias for ``secret_key``. Still honored
for backwards compatibility; prefer ``secret_key``, which also
accepts a Project Secret API Key.
disabled: If True, disable captures and API requests. Useful in tests.
disable_geoip: Whether to disable server-side GeoIP enrichment.
Defaults to True.
Expand Down Expand Up @@ -475,12 +483,25 @@ def __init__(

self.project_root = project_root

# personal_api_key: This should be a generated Personal API Key, private
self.personal_api_key = (
personal_api_key.strip()
if isinstance(personal_api_key, str)
else personal_api_key
if personal_api_key is not None and secret_key is None:
warnings.warn(
"`personal_api_key` is deprecated; use `secret_key` instead. "
"`secret_key` accepts a Personal API Key or a Project Secret API Key.",
DeprecationWarning,
stacklevel=2,
)
elif secret_key is not None and personal_api_key is not None:
self.log.warning(
"[FEATURE FLAGS] Both `secret_key` and `personal_api_key` were "
"provided; using `secret_key` and ignoring `personal_api_key`."
)
resolved_secret_key = secret_key if secret_key is not None else personal_api_key
self.secret_key = (
resolved_secret_key.strip()
if isinstance(resolved_secret_key, str)
else resolved_secret_key
) or None
self.personal_api_key = self.secret_key
if debug:
# Ensures that debug level messages are logged when debug mode is on.
# Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided
Expand Down Expand Up @@ -1869,7 +1890,7 @@ def _fetch_feature_flags_from_api(self):
personal_api_key = self.personal_api_key
if personal_api_key is None:
self.log.warning(
"[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags."
"[FEATURE FLAGS] You have to specify a secret_key to use feature flags."
)
return

Expand Down Expand Up @@ -1924,7 +1945,7 @@ def _fetch_feature_flags_from_api(self):
if e.status == 401:
detail = (
f"Error loading feature flags: {e.message}. "
"Please verify both your project_api_key and personal_api_key. "
"Please verify both your project_api_key and secret_key. "
"More information: https://posthog.com/docs/api/overview"
)
self.log.error("[FEATURE FLAGS] %s", detail)
Expand Down Expand Up @@ -1984,7 +2005,7 @@ def load_feature_flags(self):

if not self.personal_api_key:
self.log.warning(
"[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags."
"[FEATURE FLAGS] You have to specify a secret_key to use feature flags."
)
self.feature_flags = []
return
Expand Down Expand Up @@ -2678,7 +2699,7 @@ def get_remote_config_payload(self, key: str):
returned.

Note:
Requires ``personal_api_key`` for authentication.
Requires ``secret_key`` for authentication.

Category:
Feature flags
Expand All @@ -2688,7 +2709,7 @@ def get_remote_config_payload(self, key: str):

if self.personal_api_key is None:
self.log.warning(
"[FEATURE FLAGS] You have to specify a personal_api_key to fetch decrypted feature flag payloads."
"[FEATURE FLAGS] You have to specify a secret_key to fetch decrypted feature flag payloads."
)
return None

Expand Down
2 changes: 1 addition & 1 deletion posthog/flag_definition_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
cache = RedisFlagDefinitionCache(redis_client, "my-team")
posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
secret_key="<secret_key>",
flag_definition_cache_provider=cache,
)
"""
Expand Down
27 changes: 26 additions & 1 deletion posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import asyncio
import time
import unittest
import warnings
from datetime import datetime
from uuid import UUID, uuid4

Expand Down Expand Up @@ -79,6 +80,30 @@ def test_trims_host_and_personal_api_key_whitespace(self):
self.assertEqual(client.host, "https://eu.i.posthog.com")
self.assertIsNone(client.personal_api_key)

@parameterized.expand(
[
("secret_key_only", "phx_secret", None, "phx_secret", False),
("personal_api_key_alias", None, "phx_legacy", "phx_legacy", True),
("secret_key_wins", "phx_secret", "phx_legacy", "phx_secret", False),
]
)
def test_secret_key_resolution(
self, _, secret_key, personal_api_key, expected, expect_deprecation
):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
client = Client(
FAKE_TEST_API_KEY,
secret_key=secret_key,
personal_api_key=personal_api_key,
send=False,
)

self.assertEqual(client.secret_key, expected)
self.assertEqual(client.personal_api_key, expected)
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
self.assertEqual(bool(deprecations), expect_deprecation)

def test_client_with_empty_api_key_is_noop(self):
client = Client("", send=False)

Expand Down Expand Up @@ -811,7 +836,7 @@ def test_load_feature_flags_unauthorized(self, patch_get):
self.assertEqual(client.cohorts, {})
self.assertIn("Unauthorized", logs.output[0])
self.assertIn("project_api_key", logs.output[0])
self.assertIn("personal_api_key", logs.output[0])
self.assertIn("secret_key", logs.output[0])

@mock.patch("posthog.client.flags")
def test_dont_override_capture_with_local_flags(self, patch_flags):
Expand Down
2 changes: 1 addition & 1 deletion posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2767,7 +2767,7 @@ def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll):
client.load_feature_flags()
self.assertIn("Unauthorized", logs.output[0])
self.assertIn("project_api_key", logs.output[0])
self.assertIn("personal_api_key", logs.output[0])
self.assertIn("secret_key", logs.output[0])
client.debug = True
with self.assertRaisesRegex(APIError, "Unauthorized"):
client.load_feature_flags()
Expand Down
Loading
Loading