diff --git a/CHANGELOG.md b/CHANGELOG.md index e9362893..efb6f7e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.2.0 - 2022-11-14 + +Changes: + +1. Add support for feature flag variant overrides with local evaluation + ## 2.1.2 - 2022-09-15 Changes: diff --git a/README.md b/README.md index a8f39a16..b1656fa1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an eve ### Releasing Versions -Updated are released using GitHub Actions: after bumping `version.py` in `master`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`. +Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`. ## Questions? diff --git a/example.py b/example.py index 1fd30f41..d2a23720 100644 --- a/example.py +++ b/example.py @@ -8,14 +8,30 @@ posthog.debug = True # You can find this key on the /setup page in PostHog -posthog.project_api_key = "" -posthog.personal_api_key = "" +posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh" +posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm" # Where you host PostHog, with no trailing /. # You can remove this line if you're using posthog.com posthog.host = "http://localhost:8000" posthog.poll_interval = 10 +print( + posthog.feature_enabled( + "person-on-events-enabled", + "12345", + groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")}, + group_properties={ + "organization": { + "id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a", + "created_at": "2022-06-30 11:44:52.984121+00:00", + } + }, + only_evaluate_locally=True, + ) +) + +exit() # Capture an event posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index cf819680..b7b95c3b 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -46,12 +46,26 @@ def match_feature_flag_properties(flag, distinct_id, properties): flag_conditions = (flag.get("filters") or {}).get("groups") or [] is_inconclusive = False - for condition in flag_conditions: + # Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are + # evaluated first, and the variant override is applied to the first matching condition. + sorted_flag_conditions = sorted( + flag_conditions, + key=lambda condition: 0 if condition.get("variant") else 1, + ) + + for condition in sorted_flag_conditions: try: # if any one condition resolves to True, we can shortcircuit and return # the matching variant if is_condition_match(flag, distinct_id, condition, properties): - return get_matching_variant(flag, distinct_id) or True + variant_override = condition.get("variant") + # Some filters can be explicitly set to null, which require accessing variants like so + flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or [] + if variant_override and variant_override in [variant["key"] for variant in flag_variants]: + variant = variant_override + else: + variant = get_matching_variant(flag, distinct_id) + return variant or True except InconclusiveMatchError: is_inconclusive = True diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 97554217..73181874 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -969,6 +969,186 @@ def raise_effect(): self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id")) + @mock.patch("posthog.client.decide") + def test_get_feature_flag_with_variant_overrides(self, patch_decide): + patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}} + client = Client(FAKE_TEST_API_KEY, personal_api_key="test") + client.feature_flags = [ + { + "id": 1, + "name": "Beta Feature", + "key": "beta-feature", + "is_simple_flag": False, + "active": True, + "rollout_percentage": 100, + "filters": { + "groups": [ + { + "properties": [ + {"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"} + ], + "rollout_percentage": 100, + "variant": "second-variant", + }, + {"rollout_percentage": 50, "variant": "first-variant"}, + ], + "multivariate": { + "variants": [ + {"key": "first-variant", "name": "First Variant", "rollout_percentage": 50}, + {"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25}, + {"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25}, + ] + }, + }, + } + ] + self.assertEqual( + client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}), + "second-variant", + ) + self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "first-variant") + # decide not called because this can be evaluated locally + self.assertEqual(patch_decide.call_count, 0) + + @mock.patch("posthog.client.decide") + def test_flag_with_clashing_variant_overrides(self, patch_decide): + patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}} + client = Client(FAKE_TEST_API_KEY, personal_api_key="test") + client.feature_flags = [ + { + "id": 1, + "name": "Beta Feature", + "key": "beta-feature", + "is_simple_flag": False, + "active": True, + "rollout_percentage": 100, + "filters": { + "groups": [ + { + "properties": [ + {"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"} + ], + "rollout_percentage": 100, + "variant": "second-variant", + }, + # since second-variant comes first in the list, it will be the one that gets picked + { + "properties": [ + {"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"} + ], + "rollout_percentage": 100, + "variant": "first-variant", + }, + {"rollout_percentage": 50, "variant": "first-variant"}, + ], + "multivariate": { + "variants": [ + {"key": "first-variant", "name": "First Variant", "rollout_percentage": 50}, + {"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25}, + {"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25}, + ] + }, + }, + } + ] + self.assertEqual( + client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}), + "second-variant", + ) + self.assertEqual( + client.get_feature_flag("beta-feature", "example_id", person_properties={"email": "test@posthog.com"}), + "second-variant", + ) + # decide not called because this can be evaluated locally + self.assertEqual(patch_decide.call_count, 0) + + @mock.patch("posthog.client.decide") + def test_flag_with_invalid_variant_overrides(self, patch_decide): + patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}} + client = Client(FAKE_TEST_API_KEY, personal_api_key="test") + client.feature_flags = [ + { + "id": 1, + "name": "Beta Feature", + "key": "beta-feature", + "is_simple_flag": False, + "active": True, + "rollout_percentage": 100, + "filters": { + "groups": [ + { + "properties": [ + {"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"} + ], + "rollout_percentage": 100, + "variant": "second???", + }, + {"rollout_percentage": 50, "variant": "first??"}, + ], + "multivariate": { + "variants": [ + {"key": "first-variant", "name": "First Variant", "rollout_percentage": 50}, + {"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25}, + {"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25}, + ] + }, + }, + } + ] + self.assertEqual( + client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}), + "third-variant", + ) + self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "second-variant") + # decide not called because this can be evaluated locally + self.assertEqual(patch_decide.call_count, 0) + + @mock.patch("posthog.client.decide") + def test_flag_with_multiple_variant_overrides(self, patch_decide): + patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}} + client = Client(FAKE_TEST_API_KEY, personal_api_key="test") + client.feature_flags = [ + { + "id": 1, + "name": "Beta Feature", + "key": "beta-feature", + "is_simple_flag": False, + "active": True, + "rollout_percentage": 100, + "filters": { + "groups": [ + { + "rollout_percentage": 100, + # The override applies even if the first condition matches all and gives everyone their default group + }, + { + "properties": [ + {"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"} + ], + "rollout_percentage": 100, + "variant": "second-variant", + }, + {"rollout_percentage": 50, "variant": "third-variant"}, + ], + "multivariate": { + "variants": [ + {"key": "first-variant", "name": "First Variant", "rollout_percentage": 50}, + {"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25}, + {"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25}, + ] + }, + }, + } + ] + self.assertEqual( + client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "test@posthog.com"}), + "second-variant", + ) + self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "third-variant") + self.assertEqual(client.get_feature_flag("beta-feature", "another_id"), "second-variant") + # decide not called because this can be evaluated locally + self.assertEqual(patch_decide.call_count, 0) + class TestMatchProperties(unittest.TestCase): def property(self, key, value, operator=None): diff --git a/posthog/version.py b/posthog/version.py index 1877de63..ce5812a1 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "2.1.2" +VERSION = "2.2.0" if __name__ == "__main__": print(VERSION, end="")