diff --git a/.env.example b/.env.example index 9c9f1091..4db17a79 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/example.py b/example.py index da6e9fdf..8561fdc9 100644 --- a/example.py +++ b/example.py @@ -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) @@ -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}") @@ -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) @@ -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) @@ -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}") diff --git a/examples/async_redis_flag_cache.py b/examples/async_redis_flag_cache.py index 957bc9d5..c360dc59 100644 --- a/examples/async_redis_flag_cache.py +++ b/examples/async_redis_flag_cache.py @@ -16,7 +16,7 @@ posthog = Posthog( "", - personal_api_key="", + secret_key="", flag_definition_cache_provider=cache, ) diff --git a/examples/celery_integration.py b/examples/celery_integration.py index 4eeb2820..cf29c9f3 100644 --- a/examples/celery_integration.py +++ b/examples/celery_integration.py @@ -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() diff --git a/examples/redis_flag_cache.py b/examples/redis_flag_cache.py index aa4437fd..c5f00dca 100644 --- a/examples/redis_flag_cache.py +++ b/examples/redis_flag_cache.py @@ -13,7 +13,7 @@ posthog = Posthog( "", - personal_api_key="", + secret_key="", flag_definition_cache_provider=cache, ) diff --git a/examples/remote_config.py b/examples/remote_config.py index 1badef9c..b153371d 100644 --- a/examples/remote_config.py +++ b/examples/remote_config.py @@ -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 diff --git a/openfeature-provider/openfeature/contrib/provider/posthog/provider.py b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py index 0061f1fd..d8e59690 100644 --- a/openfeature-provider/openfeature/contrib/provider/posthog/provider.py +++ b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py @@ -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: @@ -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 " diff --git a/openfeature-provider/tests/conftest.py b/openfeature-provider/tests/conftest.py index 05fe871f..9d5dc55d 100644 --- a/openfeature-provider/tests/conftest.py +++ b/openfeature-provider/tests/conftest.py @@ -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 diff --git a/openfeature-provider/tests/test_provider_unit.py b/openfeature-provider/tests/test_provider_unit.py index 80269020..cefd3052 100644 --- a/openfeature-provider/tests/test_provider_unit.py +++ b/openfeature-provider/tests/test_provider_unit.py @@ -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()) diff --git a/posthog/__init__.py b/posthog/__init__.py index 05002962..9b664424 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -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. @@ -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 @@ -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", @@ -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, diff --git a/posthog/ai/prompts.py b/posthog/ai/prompts.py index 1d257ec0..4df46b5b 100644 --- a/posthog/ai/prompts.py +++ b/posthog/ai/prompts.py @@ -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) diff --git a/posthog/client.py b/posthog/client.py index b5136b72..7bcfb591 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -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, ): """ @@ -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. @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/posthog/flag_definition_cache.py b/posthog/flag_definition_cache.py index d1a58e9d..e19b0e19 100644 --- a/posthog/flag_definition_cache.py +++ b/posthog/flag_definition_cache.py @@ -13,7 +13,7 @@ cache = RedisFlagDefinitionCache(redis_client, "my-team") posthog = Posthog( "", - personal_api_key="", + secret_key="", flag_definition_cache_provider=cache, ) """ diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 97ec6dfa..0cdf2ece 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -2,6 +2,7 @@ import asyncio import time import unittest +import warnings from datetime import datetime from uuid import UUID, uuid4 @@ -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) @@ -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): diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index d247a166..74e55520 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -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() diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index d71198ba..60072517 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -527,13 +527,14 @@ attribute posthog.client.Client.log = logging.getLogger('posthog') attribute posthog.client.Client.log_captured_exceptions = log_captured_exceptions attribute posthog.client.Client.max_retries = max_retries attribute posthog.client.Client.on_error = on_error -attribute posthog.client.Client.personal_api_key = (personal_api_key.strip() if isinstance(personal_api_key, str) else personal_api_key) or None +attribute posthog.client.Client.personal_api_key = self.secret_key attribute posthog.client.Client.poll_interval = poll_interval attribute posthog.client.Client.poller: Optional[Poller] = None attribute posthog.client.Client.privacy_mode = privacy_mode attribute posthog.client.Client.project_root = project_root attribute posthog.client.Client.queue: Queue = Queue(max_queue_size) attribute posthog.client.Client.raw_host = normalize_host(host) +attribute posthog.client.Client.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.client.Client.send = send attribute posthog.client.Client.super_properties = super_properties attribute posthog.client.Client.sync_mode = sync_mode @@ -732,6 +733,7 @@ attribute posthog.request.RequestsTimeout = requests.exceptions.Timeout attribute posthog.request.SocketOptions = List[Tuple[int, int, Union[int, bytes]]] attribute posthog.request.USER_AGENT = 'posthog-python/' + VERSION attribute posthog.request.US_INGESTION_ENDPOINT = 'https://us.i.posthog.com' +attribute posthog.secret_key = None attribute posthog.send = True attribute posthog.super_properties = None attribute posthog.sync_mode = False @@ -853,7 +855,7 @@ class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, ref class posthog.capture_compression.CaptureCompression class posthog.capture_mode.CaptureMode class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, _dedicated_ai_endpoint=False) +class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, 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) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, dedicated_ai_endpoint=False, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS)