Compare commits

...
10 Commits
Author SHA1 Message Date
Neil KakkarandGitHub 07277d35e7 feat(flags): Enable local evaluation for all cohorts (#91) 2023-03-16 10:47:29 +00:00
Eric DuongandGitHub 15d0716744 fix test (#90) 2023-02-14 14:24:10 -05:00
Eric DuongandGitHub b4103b3ae2 fix: add function for active variants (#89)
* add function for active variants

* format
2023-02-14 10:45:14 -05:00
Eric DuongandGitHub 1aeffa990f add changelog entry and update versoin (#87) 2023-02-07 10:47:26 -05:00
Eric DuongandGitHub 5ae7feb4e9 chore: Remove upper bound (#85)
* remove upper bound

* format
2023-02-07 10:36:26 -05:00
6534afd8e3 fix: change api error to log (#83)
* change api error to log

* change implementation

* sort

* format

* raise when debug is true

* Update posthog/test/test_feature_flags.py

* format

---------

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2023-02-07 10:17:56 -05:00
Eric DuongandGitHub a9d7bf3e0b update version and changelog (#84) 2023-01-31 13:05:04 -05:00
Eric DuongandGitHub d7be253ef8 feat(feature-flags): JSON payload function (#81)
* implementation with test

* format

* v=3 and get_all_payloads

* format

* fix bug

* format

* address comments

* format

* add tests

* format

* add resiliency

* fix lookup

* remove check

* address comments

* example.py

* format

* format
2023-01-31 12:47:32 -05:00
Luke Harries 592c0f362e Revert "added regression tests"
This reverts commit c7fc5a83b4.
2023-01-24 17:31:30 +00:00
Luke Harries c7fc5a83b4 added regression tests 2023-01-24 17:11:37 +00:00
10 changed files with 824 additions and 39 deletions
+14
View File
@@ -1,3 +1,17 @@
## 2.4.0 - 2023-03-14
1. Support evaluating all cohorts in feature flags for local evaluation
## 2.3.1 - 2023-02-07
1. Log instead of raise error on posthog personal api key errors
2. Remove upper bound on backoff dependency
## 2.3.0 - 2023-01-31
1. Add support for returning payloads of matched feature flags
## 2.2.0 - 2022-11-14
Changes:
+4 -1
View File
@@ -31,7 +31,6 @@ print(
)
)
exit()
# Capture an event
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
@@ -41,6 +40,10 @@ print(posthog.feature_enabled("beta-feature-groups", "distinct_id", groups={"com
print(posthog.feature_enabled("beta-feature", "distinct_id"))
# get payload
print(posthog.get_feature_flag_payload("beta-feature", "distinct_id"))
print(posthog.get_all_flags_and_payloads("distinct_id"))
exit()
# # Alias a previous distinct id with a new one
posthog.alias("distinct_id", "new_distinct_id")
+40
View File
@@ -336,6 +336,46 @@ def get_all_flags(
)
def get_feature_flag_payload(
key,
distinct_id,
match_value=None,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
):
return _proxy(
"get_feature_flag_payload",
key=key,
distinct_id=distinct_id,
match_value=match_value,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
)
def get_all_flags_and_payloads(
distinct_id,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
):
return _proxy(
"get_all_flags_and_payloads",
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
)
def page(*args, **kwargs):
"""Send a page call."""
_proxy("page", *args, **kwargs)
+122 -25
View File
@@ -48,7 +48,6 @@ class Client(object):
personal_api_key=None,
project_api_key=None,
):
self.queue = queue.Queue(max_queue_size)
# api_key: This should be the Team API Key (token), public
@@ -64,7 +63,9 @@ class Client(object):
self.gzip = gzip
self.timeout = timeout
self.feature_flags = None
self.feature_flags_by_key = None
self.group_type_mapping = None
self.cohorts = None
self.poll_interval = poll_interval
self.poller = None
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
@@ -127,6 +128,20 @@ class Client(object):
return self._enqueue(msg)
def get_feature_variants(self, distinct_id, groups=None, person_properties=None, group_properties=None):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties)
return resp_data["featureFlags"]
def _get_active_feature_variants(self, distinct_id, groups=None, person_properties=None, group_properties=None):
feature_variants = self.get_feature_variants(distinct_id, groups, person_properties, group_properties)
return {
k: v for (k, v) in feature_variants.items() if v is not False
} # explicitly test for false to account for values that may seem falsy (ex: 0)
def get_feature_payloads(self, distinct_id, groups=None, person_properties=None, group_properties=None):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties)
return resp_data["featureFlagPayloads"]
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None):
require("distinct_id", distinct_id, ID_TYPES)
if groups:
@@ -141,7 +156,9 @@ class Client(object):
"group_properties": group_properties,
}
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
return resp_data["featureFlags"]
print(resp_data)
return resp_data
def capture(
self,
@@ -175,7 +192,7 @@ class Client(object):
if send_feature_flags:
try:
feature_variants = self.get_feature_variants(distinct_id, groups)
feature_variants = self._get_active_feature_variants(distinct_id, groups)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
else:
@@ -359,19 +376,31 @@ class Client(object):
def _load_feature_flags(self):
try:
response = get(
self.personal_api_key, f"/api/feature_flag/local_evaluation/?token={self.api_key}", self.host
self.personal_api_key,
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
self.host,
timeout=10,
)
self.feature_flags = response["flags"] or []
self.feature_flags_by_key = {
flag["key"]: flag for flag in self.feature_flags if flag.get("key") is not None
}
self.group_type_mapping = response["group_type_mapping"] or {}
self.cohorts = response["cohorts"] or {}
except APIError as e:
if e.status == 401:
raise APIError(
status=401,
message="You are using a write-only key with feature flags. "
"To use feature flags, please set a personal_api_key "
"More information: https://posthog.com/docs/api/overview",
self.log.error(
f"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview"
)
if self.debug:
raise APIError(
status=401,
message="You are using a write-only key with feature flags. "
"To use feature flags, please set a personal_api_key "
"More information: https://posthog.com/docs/api/overview",
)
else:
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
except Exception as e:
@@ -395,7 +424,6 @@ class Client(object):
self.poller.start()
def _compute_flag_locally(self, feature_flag, distinct_id, *, groups={}, person_properties={}, group_properties={}):
if feature_flag.get("ensure_experience_continuity", False):
raise InconclusiveMatchError("Flag has experience continuity enabled")
@@ -425,7 +453,7 @@ class Client(object):
focused_group_properties = group_properties[group_name]
return match_feature_flag_properties(feature_flag, groups[group_name], focused_group_properties)
else:
return match_feature_flag_properties(feature_flag, distinct_id, person_properties)
return match_feature_flag_properties(feature_flag, distinct_id, person_properties, self.cohorts)
def feature_enabled(
self,
@@ -522,48 +550,117 @@ class Client(object):
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
return response
def get_feature_flag_payload(
self,
key,
distinct_id,
*,
match_value=None,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
):
if match_value is None:
match_value = self.get_feature_flag(
key,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
send_feature_flag_events=send_feature_flag_events,
only_evaluate_locally=True,
)
response = None
if match_value is not None:
response = self._compute_payload_locally(key, match_value)
if response is None and not only_evaluate_locally:
decide_payloads = self.get_feature_payloads(distinct_id, groups, person_properties, group_properties)
response = decide_payloads.get(str(key).lower(), None)
return response
def _compute_payload_locally(self, key, match_value):
payload = None
if self.feature_flags_by_key is None:
return payload
flag_definition = self.feature_flags_by_key.get(key) or {}
flag_filters = flag_definition.get("filters") or {}
flag_payloads = flag_filters.get("payloads") or {}
payload = flag_payloads.get(str(match_value).lower(), None)
return payload
def get_all_flags(
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, only_evaluate_locally=False
):
flags = self.get_all_flags_and_payloads(
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
)
return flags["featureFlags"]
def get_all_flags_and_payloads(
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, only_evaluate_locally=False
):
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
response = {"featureFlags": flags, "featureFlagPayloads": payloads}
if fallback_to_decide and not only_evaluate_locally:
try:
flags_and_payloads = self.get_decide(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
response = flags_and_payloads
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
return response
def _get_all_flags_and_payloads_locally(self, distinct_id, *, groups={}, person_properties={}, group_properties={}):
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
if self.feature_flags == None and self.personal_api_key:
self.load_feature_flags()
response = {}
flags = {}
payloads = {}
fallback_to_decide = False
# If loading in previous line failed
if self.feature_flags:
for flag in self.feature_flags:
try:
response[flag["key"]] = self._compute_flag_locally(
flags[flag["key"]] = self._compute_flag_locally(
flag,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
)
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
if matched_payload:
payloads[flag["key"]] = matched_payload
except InconclusiveMatchError as e:
# No need to log this, since it's just telling us to fall back to `/decide`
fallback_to_decide = True
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Error while computing variant: {e}")
self.log.exception(f"[FEATURE FLAGS] Error while computing variant and payload: {e}")
fallback_to_decide = True
else:
fallback_to_decide = True
if fallback_to_decide and not only_evaluate_locally:
try:
feature_flags = self.get_feature_variants(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
response = {**response, **feature_flags}
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
return response
return flags, payloads, fallback_to_decide
def require(name, field, data_type):
+102 -6
View File
@@ -1,5 +1,6 @@
import datetime
import hashlib
import logging
import re
from dateutil import parser
@@ -8,6 +9,8 @@ from posthog.utils import convert_to_datetime_aware, is_valid_regex
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
log = logging.getLogger("posthog")
class InconclusiveMatchError(Exception):
pass
@@ -42,9 +45,10 @@ def variant_lookup_table(feature_flag):
return lookup_table
def match_feature_flag_properties(flag, distinct_id, properties):
def match_feature_flag_properties(flag, distinct_id, properties, cohort_properties=None):
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
cohort_properties = cohort_properties or {}
# 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.
@@ -57,7 +61,7 @@ def match_feature_flag_properties(flag, distinct_id, properties):
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):
if is_condition_match(flag, distinct_id, condition, properties, cohort_properties):
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 []
@@ -77,12 +81,19 @@ def match_feature_flag_properties(flag, distinct_id, properties):
return False
def is_condition_match(feature_flag, distinct_id, condition, properties):
def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_properties):
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
if not all(match_property(prop, properties) for prop in condition.get("properties")):
return False
elif rollout_percentage is None:
for prop in condition.get("properties"):
property_type = prop.get("type")
if property_type == "cohort":
matches = match_cohort(prop, properties, cohort_properties)
else:
matches = match_property(prop, properties)
if not matches:
return False
if rollout_percentage is None:
return True
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (rollout_percentage / 100):
@@ -175,3 +186,88 @@ def match_property(property, property_values) -> bool:
raise InconclusiveMatchError("The date provided must be a string or date object")
return False
def match_cohort(property, property_values, cohort_properties) -> bool:
# Cohort properties are in the form of property groups like this:
# {
# "cohort_id": {
# "type": "AND|OR",
# "values": [{
# "key": "property_name", "value": "property_value"
# }]
# }
# }
cohort_id = str(property.get("value"))
if cohort_id not in cohort_properties:
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
property_group = cohort_properties[cohort_id]
return match_property_group(property_group, property_values, cohort_properties)
def match_property_group(property_group, property_values, cohort_properties) -> bool:
if not property_group:
return True
property_group_type = property_group.get("type")
properties = property_group.get("values")
if not properties or len(properties) == 0:
# empty groups are no-ops, always match
return True
error_matching_locally = False
if "values" in properties[0]:
# a nested property group
for prop in properties:
try:
matches = match_property_group(prop, property_values, cohort_properties)
if property_group_type == "AND":
if not matches:
return False
else:
# OR group
if matches:
return True
except InconclusiveMatchError as e:
log.debug(f"Failed to compute property {prop} locally: {e}")
error_matching_locally = True
if error_matching_locally:
raise InconclusiveMatchError("Can't match cohort without a given cohort property value")
# if we get here, all matched in AND case, or none matched in OR case
return property_group_type == "AND"
else:
for prop in properties:
try:
if prop.get("type") == "cohort":
matches = match_cohort(prop, property_values, cohort_properties)
else:
matches = match_property(prop, property_values)
negation = prop.get("negation", False)
if property_group_type == "AND":
# if negated property, do the inverse
if not matches and not negation:
return False
if matches and negation:
return False
else:
# OR group
if matches and not negation:
return True
if not matches and negation:
return True
except InconclusiveMatchError as e:
log.debug(f"Failed to compute property {prop} locally: {e}")
error_matching_locally = True
if error_matching_locally:
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
# if we get here, all matched in AND case, or none matched in OR case
return property_group_type == "AND"
+1 -1
View File
@@ -63,7 +63,7 @@ def _process_response(
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
"""Post the `kwargs to the decide API endpoint"""
res = post(api_key, host, "/decide/?v=2", gzip, timeout, **kwargs)
res = post(api_key, host, "/decide/?v=3", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags decided successfully")
+34 -1
View File
@@ -71,7 +71,6 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
def test_basic_capture_with_project_api_key(self):
client = Client(project_api_key=FAKE_TEST_API_KEY, on_error=self.set_fail)
success, msg = client.capture("distinct_id", "python test event")
@@ -107,6 +106,40 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
@mock.patch("posthog.client.decide")
def test_get_active_feature_flags(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
variants = client._get_active_feature_variants("some_id", None, None, None)
self.assertEqual(variants, {"beta-feature": "random-variant", "alpha-feature": True})
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg["event"], "python test event")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature"], "random-variant")
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
self.assertEqual(patch_decide.call_count, 1)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
+505 -3
View File
@@ -478,7 +478,9 @@ class TestLocalEvaluation(unittest.TestCase):
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_fallback(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
patch_decide.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2", "disabled-feature": False}
} # decide should return the same flags
client = self.client
client.feature_flags = [
{
@@ -536,6 +538,82 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_fallback(self, patch_decide, patch_capture):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
}
client = self.client
client.feature_flags = [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
"payloads": {
"true": "some-payload",
},
},
},
{
"id": 2,
"name": "Beta Feature",
"key": "disabled-feature",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 0,
}
],
"payloads": {
"true": "another-payload",
},
},
},
{
"id": 3,
"name": "Beta Feature",
"key": "beta-feature2",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [{"key": "country", "value": "US"}],
"rollout_percentage": 0,
}
],
"payloads": {
"true": "payload-3",
},
},
},
]
# beta-feature value overridden by /decide
self.assertEqual(
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
{
"beta-feature": 100,
"beta-feature2": 300,
},
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_fallback_empty_local_flags(self, patch_decide, patch_capture):
@@ -549,6 +627,23 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(self, patch_decide, patch_capture):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
}
client = self.client
client.feature_flags = []
# beta-feature value overridden by /decide
self.assertEqual(
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
{"beta-feature": 100, "beta-feature2": 300},
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_no_fallback(self, patch_decide, patch_capture):
@@ -592,6 +687,60 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_no_fallback(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
client = self.client
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
"payloads": {
"true": "new",
},
},
}
disabled_flag = {
"id": 2,
"name": "Beta Feature",
"key": "disabled-feature",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 0,
}
],
"payloads": {
"true": "some-payload",
},
},
}
client.feature_flags = [
basic_flag,
disabled_flag,
]
client.feature_flags_by_key = {"beta-feature": basic_flag, "disabled-feature": disabled_flag}
self.assertEqual(
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"], {"beta-feature": "new"}
)
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_fallback_but_only_local_evaluation_set(self, patch_decide, patch_capture):
@@ -653,6 +802,83 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(self, patch_decide, patch_capture):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
}
client = self.client
flag_1 = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
"payloads": {
"true": "some-payload",
},
},
}
flag_2 = {
"id": 2,
"name": "Beta Feature",
"key": "disabled-feature",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 0,
}
],
"payloads": {
"true": "another-payload",
},
},
}
flag_3 = {
"id": 3,
"name": "Beta Feature",
"key": "beta-feature2",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [{"key": "country", "value": "US"}],
"rollout_percentage": 0,
}
],
"payloads": {
"true": "payload-3",
},
},
}
client.feature_flags = [
flag_1,
flag_2,
flag_3,
]
client.feature_flags_by_key = {"beta-feature": flag_1, "disabled-feature": flag_2, "beta-feature2": flag_3}
# beta-feature2 has no value
self.assertEqual(
client.get_all_flags_and_payloads("distinct_id", only_evaluate_locally=True)["featureFlagPayloads"],
{"beta-feature": "some-payload"},
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_compute_inactive_flags_locally(self, patch_decide, patch_capture):
@@ -734,6 +960,159 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_decide):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 2,
"name": "Beta Feature",
"key": "beta-feature",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
},
{"key": "id", "value": 98, "operator": None, "type": "cohort"},
],
"rollout_percentage": 100,
}
],
},
},
]
client.cohorts = {
"98": {
"type": "OR",
"values": [
{"key": "id", "value": 1, "type": "cohort"},
{
"key": "nation",
"operator": "exact",
"value": ["UK"],
"type": "person",
},
],
},
"1": {
"type": "AND",
"values": [{"key": "other", "operator": "exact", "value": ["thing"], "type": "person"}],
},
}
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "UK"}
)
self.assertEqual(feature_flag_match, False)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "nation": "UK"}
)
# even though 'other' property is not present, the cohort should still match since it's an OR condition
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing"}
)
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_for_negated_cohorts(self, patch_get, patch_decide):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 2,
"name": "Beta Feature",
"key": "beta-feature",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
},
{"key": "id", "value": 98, "operator": None, "type": "cohort"},
],
"rollout_percentage": 100,
}
],
},
},
]
client.cohorts = {
"98": {
"type": "OR",
"values": [
{"key": "id", "value": 1, "type": "cohort"},
{
"key": "nation",
"operator": "exact",
"value": ["UK"],
"type": "person",
},
],
},
"1": {
"type": "AND",
"values": [
{"key": "other", "operator": "exact", "value": ["thing"], "type": "person", "negation": True}
],
},
}
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "UK"}
)
self.assertEqual(feature_flag_match, False)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "nation": "UK"}
)
# even though 'other' property is not present, the cohort should still match since it's an OR condition
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing"}
)
# since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_get.call_count, 0)
patch_decide.reset_mock()
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing2"}
)
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags(self, patch_get, patch_poll):
@@ -755,8 +1134,15 @@ class TestLocalEvaluation(unittest.TestCase):
def test_load_feature_flags_wrong_key(self):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
with freeze_time("2020-01-01T12:01:00.0000Z"):
self.assertRaises(APIError, client.load_feature_flags)
with self.assertLogs("posthog", level="ERROR") as logs:
client.load_feature_flags()
self.assertEqual(
logs.output[0],
"ERROR:posthog:[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview",
)
client.debug = True
self.assertRaises(APIError, client.load_feature_flags)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.get")
@@ -1149,6 +1535,122 @@ class TestLocalEvaluation(unittest.TestCase):
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
@mock.patch("posthog.client.decide")
def test_boolean_feature_flag_payloads_local(self, patch_decide):
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"payloads": {"true": 300},
},
}
self.client.feature_flags = [basic_flag]
self.client.feature_flags_by_key = {"person-flag": basic_flag}
self.assertEqual(
self.client.get_feature_flag_payload(
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
),
300,
)
self.assertEqual(
self.client.get_feature_flag_payload(
"person-flag", "some-distinct-id", match_value=True, person_properties={"region": "USA"}
),
300,
)
self.assertEqual(patch_decide.call_count, 0)
@mock.patch("posthog.client.decide")
def test_boolean_feature_flag_payload_decide(self, patch_decide):
patch_decide.return_value = {"featureFlagPayloads": {"person-flag": 300}}
self.assertEqual(
self.client.get_feature_flag_payload(
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
),
300,
)
self.assertEqual(
self.client.get_feature_flag_payload(
"person-flag", "some-distinct-id", match_value=True, person_properties={"region": "USA"}
),
300,
)
self.assertEqual(patch_decide.call_count, 2)
@mock.patch("posthog.client.decide")
def test_multivariate_feature_flag_payloads(self, patch_decide):
multivariate_flag = {
"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},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
},
}
self.client.feature_flags = [multivariate_flag]
self.client.feature_flags_by_key = {"beta-feature": multivariate_flag}
self.assertEqual(
self.client.get_feature_flag_payload(
"beta-feature", "test_id", person_properties={"email": "test@posthog.com"}
),
{"a": "json"},
)
self.assertEqual(
self.client.get_feature_flag_payload(
"beta-feature", "test_id", match_value="third-variant", person_properties={"email": "test@posthog.com"}
),
{"a": "json"},
)
# Force different match value
self.assertEqual(
self.client.get_feature_flag_payload(
"beta-feature", "test_id", match_value="first-variant", person_properties={"email": "test@posthog.com"}
),
"some-payload",
)
self.assertEqual(patch_decide.call_count, 0)
class TestMatchProperties(unittest.TestCase):
def property(self, key, value, operator=None):
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "2.2.0"
VERSION = "2.4.0"
if __name__ == "__main__":
print(VERSION, end="")
+1 -1
View File
@@ -14,7 +14,7 @@ long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
"""
install_requires = ["requests>=2.7,<3.0", "six>=1.5", "monotonic>=1.5", "backoff>=1.10.0,<2.0.0", "python-dateutil>2.1"]
install_requires = ["requests>=2.7,<3.0", "six>=1.5", "monotonic>=1.5", "backoff>=1.10.0", "python-dateutil>2.1"]
extras_require = {
"dev": [