Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
20 changes: 18 additions & 2 deletions example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions posthog/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
180 changes: 180 additions & 0 deletions posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion posthog/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION = "2.1.2"
VERSION = "2.2.0"

if __name__ == "__main__":
print(VERSION, end="")