diff --git a/CHANGELOG.md b/CHANGELOG.md index 213ffd3c..339ea489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 6.1.0 - 2025-07-10 + +- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller + # 6.0.4 - 2025-07-09 - fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture. diff --git a/posthog/__init__.py b/posthog/__init__.py index 9fd6160b..b4ff65c8 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -60,6 +60,9 @@ def tag(name: str, value: Any): project_root = None # type: Optional[str] # Used for our AI observability feature to not capture any prompt or output just usage + metadata privacy_mode = False # type: bool +# Whether to enable feature flag polling for local evaluation by default. Defaults to True. +# We recommend setting this to False if you are only using the personalApiKey for evaluating remote config payloads via `get_remote_config_payload` and not using local evaluation. +enable_local_evaluation = True # type: bool default_client = None # type: Optional[Client] @@ -465,6 +468,7 @@ def setup(): # or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months) enable_exception_autocapture=enable_exception_autocapture, log_captured_exceptions=log_captured_exceptions, + enable_local_evaluation=enable_local_evaluation, ) # always set incase user changes it diff --git a/posthog/client.py b/posthog/client.py index 76134faf..73244965 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -152,6 +152,7 @@ def __init__( privacy_mode=False, before_send=None, flag_fallback_cache_url=None, + enable_local_evaluation=True, ): self.queue = queue.Queue(max_queue_size) @@ -187,6 +188,7 @@ def __init__( self.log_captured_exceptions = log_captured_exceptions self.exception_capture = None self.privacy_mode = privacy_mode + self.enable_local_evaluation = enable_local_evaluation if project_root is None: try: @@ -807,7 +809,11 @@ def load_feature_flags(self): return self._load_feature_flags() - if not (self.poller and self.poller.is_alive()): + + # Only start the poller if local evaluation is enabled + if self.enable_local_evaluation and not ( + self.poller and self.poller.is_alive() + ): self.poller = Poller( interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags, diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 76b1ec3b..03b4d993 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -1903,3 +1903,93 @@ def test_set_context_session_override_in_capture(self): self.assertEqual( msg["properties"]["$session_id"], "explicit-session-override" ) + + @mock.patch("posthog.client.Poller") + @mock.patch("posthog.client.get") + def test_enable_local_evaluation_false_disables_poller( + self, patch_get, patch_poller + ): + """Test that when enable_local_evaluation=False, the poller is not started""" + patch_get.return_value = { + "flags": [ + {"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True} + ], + "group_type_mapping": {}, + "cohorts": {}, + } + + client = Client( + FAKE_TEST_API_KEY, + personal_api_key="test-personal-key", + enable_local_evaluation=False, + ) + + # Load feature flags should not start the poller + client.load_feature_flags() + + # Assert that the poller was not created/started + patch_poller.assert_not_called() + # But the feature flags should still be loaded + patch_get.assert_called_once() + self.assertEqual(len(client.feature_flags), 1) + self.assertEqual(client.feature_flags[0]["key"], "beta-feature") + + @mock.patch("posthog.client.Poller") + @mock.patch("posthog.client.get") + def test_enable_local_evaluation_true_starts_poller(self, patch_get, patch_poller): + """Test that when enable_local_evaluation=True (default), the poller is started""" + patch_get.return_value = { + "flags": [ + {"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True} + ], + "group_type_mapping": {}, + "cohorts": {}, + } + + client = Client( + FAKE_TEST_API_KEY, + personal_api_key="test-personal-key", + enable_local_evaluation=True, + ) + + # Load feature flags should start the poller + client.load_feature_flags() + + # Assert that the poller was created and started + patch_poller.assert_called_once() + patch_get.assert_called_once() + self.assertEqual(len(client.feature_flags), 1) + self.assertEqual(client.feature_flags[0]["key"], "beta-feature") + + @mock.patch("posthog.client.remote_config") + def test_get_remote_config_payload_works_without_poller(self, patch_remote_config): + """Test that get_remote_config_payload works without local evaluation enabled""" + patch_remote_config.return_value = {"test": "payload"} + + client = Client( + FAKE_TEST_API_KEY, + personal_api_key="test-personal-key", + enable_local_evaluation=False, + ) + + # Should work without poller + result = client.get_remote_config_payload("test-flag") + + self.assertEqual(result, {"test": "payload"}) + patch_remote_config.assert_called_once_with( + "test-personal-key", + client.host, + "test-flag", + timeout=client.feature_flags_request_timeout_seconds, + ) + + def test_get_remote_config_payload_requires_personal_api_key(self): + """Test that get_remote_config_payload requires personal API key""" + client = Client( + FAKE_TEST_API_KEY, + enable_local_evaluation=False, + ) + + result = client.get_remote_config_payload("test-flag") + + self.assertIsNone(result) diff --git a/posthog/version.py b/posthog/version.py index 79aad072..819b934c 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "6.0.4" +VERSION = "6.1.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201