Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f75d924d4c | ||
|
|
617bb53501 | ||
|
|
4aa3499527 | ||
|
|
de7def97e2 | ||
|
|
dfefd0a1b6 | ||
|
|
477a688016 | ||
|
|
fa474a0fe6 | ||
|
|
f8bc3f17eb | ||
|
|
07277d35e7 | ||
|
|
15d0716744 | ||
|
|
b4103b3ae2 | ||
|
|
1aeffa990f | ||
|
|
5ae7feb4e9 | ||
|
|
6534afd8e3 | ||
|
|
a9d7bf3e0b | ||
|
|
d7be253ef8 | ||
|
|
592c0f362e | ||
|
|
c7fc5a83b4 |
@@ -33,6 +33,10 @@ jobs:
|
||||
- name: Check formatting with black
|
||||
run: |
|
||||
black --check .
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 posthog --ignore E501
|
||||
|
||||
- name: Check import order with isort
|
||||
run: |
|
||||
|
||||
@@ -1,3 +1,52 @@
|
||||
## 3.0.1 - 2023-04-21
|
||||
|
||||
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
|
||||
2. Add a `feature_flag_definitions()` debug option, which returns currently loaded feature flag definitions. You can use this to more cleverly decide when to request local evaluation of feature flags.
|
||||
## 3.0.0 - 2023-04-14
|
||||
|
||||
Breaking change:
|
||||
|
||||
All events by default now send the `$geoip_disable` property to disable geoip lookup in app. This is because usually we don't
|
||||
want to update person properties to take the server's location.
|
||||
|
||||
The same now happens for feature flag requests, where we discard the IP address of the server for matching on geoip properties like city, country, continent.
|
||||
|
||||
To restore previous behaviour, you can set the default to False like so:
|
||||
|
||||
```python
|
||||
posthog.disable_geoip = False
|
||||
|
||||
# // and if using client instantiation:
|
||||
posthog = Posthog('api_key', disable_geoip=False)
|
||||
|
||||
```
|
||||
|
||||
## 2.5.0 - 2023-04-10
|
||||
|
||||
1. Add option for instantiating separate client object
|
||||
|
||||
## 2.4.2 - 2023-03-30
|
||||
|
||||
1. Update backoff dependency for posthoganalytics package to be the same as posthog package
|
||||
|
||||
## 2.4.1 - 2023-03-17
|
||||
|
||||
1. Removes accidental print call left in for decide response
|
||||
|
||||
## 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
@@ -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")
|
||||
|
||||
+89
-12
@@ -1,4 +1,5 @@
|
||||
from typing import Callable, Dict, Optional
|
||||
import datetime # noqa: F401
|
||||
from typing import Callable, Dict, Optional # noqa: F401
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.version import VERSION
|
||||
@@ -16,6 +17,7 @@ disabled = False # type: bool
|
||||
personal_api_key = None # type: str
|
||||
project_api_key = None # type: str
|
||||
poll_interval = 30 # type: int
|
||||
disable_geoip = True # type: bool
|
||||
|
||||
default_client = None
|
||||
|
||||
@@ -29,6 +31,7 @@ def capture(
|
||||
uuid=None, # type: Optional[str]
|
||||
groups=None, # type: Optional[Dict]
|
||||
send_feature_flags=False,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -61,6 +64,7 @@ def capture(
|
||||
uuid=uuid,
|
||||
groups=groups,
|
||||
send_feature_flags=send_feature_flags,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +74,7 @@ def identify(
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -94,15 +99,17 @@ def identify(
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def set(
|
||||
distinct_id, # type: str,
|
||||
distinct_id, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -127,15 +134,17 @@ def set(
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def set_once(
|
||||
distinct_id, # type: str,
|
||||
distinct_id, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -160,6 +169,7 @@ def set_once(
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
@@ -170,6 +180,7 @@ def group_identify(
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -195,15 +206,17 @@ def group_identify(
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def alias(
|
||||
previous_id, # type: str,
|
||||
distinct_id, # type: str,
|
||||
previous_id, # type: str
|
||||
distinct_id, # type: str
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -229,17 +242,19 @@ def alias(
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def feature_enabled(
|
||||
key, # type: str,
|
||||
distinct_id, # type: str,
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> bool
|
||||
"""
|
||||
@@ -264,17 +279,19 @@ def feature_enabled(
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag(
|
||||
key, # type: str,
|
||||
distinct_id, # type: str,
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
"""
|
||||
Get feature flag variant for users. Used with experiments.
|
||||
@@ -307,15 +324,17 @@ def get_feature_flag(
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags(
|
||||
distinct_id, # type: str,
|
||||
distinct_id, # type: str
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
"""
|
||||
Get all flags for a given user.
|
||||
@@ -333,9 +352,59 @@ def get_all_flags(
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
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,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
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,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def feature_flag_definitions():
|
||||
"""Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded."""
|
||||
return _proxy("feature_flag_definitions")
|
||||
|
||||
|
||||
def page(*args, **kwargs):
|
||||
"""Send a page call."""
|
||||
_proxy("page", *args, **kwargs)
|
||||
@@ -365,8 +434,6 @@ def shutdown():
|
||||
def _proxy(method, *args, **kwargs):
|
||||
"""Create an analytics client if one doesn't exist and send to it."""
|
||||
global default_client
|
||||
if disabled:
|
||||
return None
|
||||
if not default_client:
|
||||
default_client = Client(
|
||||
api_key,
|
||||
@@ -378,7 +445,17 @@ def _proxy(method, *args, **kwargs):
|
||||
personal_api_key=personal_api_key,
|
||||
project_api_key=project_api_key,
|
||||
poll_interval=poll_interval,
|
||||
disabled=disabled,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
# always set incase user changes it
|
||||
default_client.disabled = disabled
|
||||
default_client.debug = debug
|
||||
|
||||
fn = getattr(default_client, method)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
class Posthog(Client):
|
||||
pass
|
||||
|
||||
+223
-46
@@ -47,8 +47,9 @@ class Client(object):
|
||||
poll_interval=30,
|
||||
personal_api_key=None,
|
||||
project_api_key=None,
|
||||
disabled=False,
|
||||
disable_geoip=True,
|
||||
):
|
||||
|
||||
self.queue = queue.Queue(max_queue_size)
|
||||
|
||||
# api_key: This should be the Team API Key (token), public
|
||||
@@ -64,10 +65,14 @@ 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)
|
||||
self.disabled = disabled
|
||||
self.disable_geoip = disable_geoip
|
||||
|
||||
# personal_api_key: This should be a generated Personal API Key, private
|
||||
self.personal_api_key = personal_api_key
|
||||
@@ -109,7 +114,7 @@ class Client(object):
|
||||
if send:
|
||||
consumer.start()
|
||||
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -124,11 +129,36 @@ class Client(object):
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def get_feature_variants(self, distinct_id, groups=None, person_properties=None, group_properties=None):
|
||||
def get_feature_variants(
|
||||
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||||
):
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
return resp_data["featureFlags"]
|
||||
|
||||
def _get_active_feature_variants(
|
||||
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||||
):
|
||||
feature_variants = self.get_feature_variants(
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
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, disable_geoip=None
|
||||
):
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
return resp_data["featureFlagPayloads"]
|
||||
|
||||
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None):
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
|
||||
if disable_geoip is None:
|
||||
disable_geoip = self.disable_geoip
|
||||
|
||||
if groups:
|
||||
require("groups", groups, dict)
|
||||
else:
|
||||
@@ -139,9 +169,11 @@ class Client(object):
|
||||
"groups": groups,
|
||||
"person_properties": person_properties,
|
||||
"group_properties": group_properties,
|
||||
"disable_geoip": disable_geoip,
|
||||
}
|
||||
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
|
||||
return resp_data["featureFlags"]
|
||||
|
||||
return resp_data
|
||||
|
||||
def capture(
|
||||
self,
|
||||
@@ -153,6 +185,7 @@ class Client(object):
|
||||
uuid=None,
|
||||
groups=None,
|
||||
send_feature_flags=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
@@ -175,7 +208,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, disable_geoip=disable_geoip)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
|
||||
else:
|
||||
@@ -183,9 +216,9 @@ class Client(object):
|
||||
msg["properties"]["$feature/{}".format(feature)] = variant
|
||||
msg["properties"]["$active_feature_flags"] = list(feature_variants.keys())
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -200,9 +233,9 @@ class Client(object):
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -217,9 +250,18 @@ class Client(object):
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def group_identify(self, group_type=None, group_key=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
def group_identify(
|
||||
self,
|
||||
group_type=None,
|
||||
group_key=None,
|
||||
properties=None,
|
||||
context=None,
|
||||
timestamp=None,
|
||||
uuid=None,
|
||||
disable_geoip=None,
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("group_type", group_type, ID_TYPES)
|
||||
@@ -239,9 +281,9 @@ class Client(object):
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None):
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
context = context or {}
|
||||
|
||||
require("previous_id", previous_id, ID_TYPES)
|
||||
@@ -258,9 +300,11 @@ class Client(object):
|
||||
"distinct_id": previous_id,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def page(self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
def page(
|
||||
self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
|
||||
@@ -279,10 +323,14 @@ class Client(object):
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def _enqueue(self, msg):
|
||||
def _enqueue(self, msg, disable_geoip):
|
||||
"""Push a new `msg` onto the queue, return `(success, msg)`"""
|
||||
|
||||
if self.disabled:
|
||||
return False, "disabled"
|
||||
|
||||
timestamp = msg["timestamp"]
|
||||
if timestamp is None:
|
||||
timestamp = datetime.utcnow().replace(tzinfo=tzutc())
|
||||
@@ -305,6 +353,12 @@ class Client(object):
|
||||
msg["properties"]["$lib"] = "posthog-python"
|
||||
msg["properties"]["$lib_version"] = VERSION
|
||||
|
||||
if disable_geoip is None:
|
||||
disable_geoip = self.disable_geoip
|
||||
|
||||
if disable_geoip:
|
||||
msg["properties"]["$geoip_disable"] = True
|
||||
|
||||
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
|
||||
|
||||
msg = clean(msg)
|
||||
@@ -359,19 +413,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(
|
||||
"[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 +461,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 +490,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,
|
||||
@@ -437,6 +502,7 @@ class Client(object):
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
response = self.get_feature_flag(
|
||||
key,
|
||||
@@ -446,6 +512,7 @@ class Client(object):
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
if response is None:
|
||||
@@ -462,12 +529,16 @@ class Client(object):
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
require("key", key, string_types)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if self.feature_flags == None and self.personal_api_key:
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
if self.feature_flags is None and self.personal_api_key:
|
||||
self.load_feature_flags()
|
||||
response = None
|
||||
|
||||
@@ -495,7 +566,11 @@ class Client(object):
|
||||
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||||
try:
|
||||
feature_flags = self.get_feature_variants(
|
||||
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
response = feature_flags.get(key)
|
||||
if response is None:
|
||||
@@ -507,7 +582,7 @@ class Client(object):
|
||||
feature_flag_reported_key = f"{key}_{str(response)}"
|
||||
if (
|
||||
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
|
||||
and send_feature_flag_events
|
||||
and send_feature_flag_events # noqa: W503
|
||||
):
|
||||
self.capture(
|
||||
distinct_id,
|
||||
@@ -518,52 +593,154 @@ class Client(object):
|
||||
"locally_evaluated": flag_was_locally_evaluated,
|
||||
},
|
||||
groups=groups,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
|
||||
return response
|
||||
|
||||
def get_all_flags(
|
||||
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, only_evaluate_locally=False
|
||||
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,
|
||||
disable_geoip=None,
|
||||
):
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
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,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
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, disable_geoip
|
||||
)
|
||||
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,
|
||||
disable_geoip=None,
|
||||
):
|
||||
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,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
return flags["featureFlags"]
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
self,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
if self.disabled:
|
||||
return {"featureFlags": None, "featureFlagPayloads": None}
|
||||
|
||||
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,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
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:
|
||||
if self.feature_flags is 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,
|
||||
)
|
||||
except InconclusiveMatchError as e:
|
||||
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
|
||||
if matched_payload:
|
||||
payloads[flag["key"]] = matched_payload
|
||||
except InconclusiveMatchError:
|
||||
# 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 flags, payloads, fallback_to_decide
|
||||
|
||||
return response
|
||||
def feature_flag_definitions(self):
|
||||
return self.feature_flags
|
||||
|
||||
|
||||
def require(name, field, data_type):
|
||||
|
||||
+102
-6
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ def get_distinct_id(request):
|
||||
return None
|
||||
try:
|
||||
return GET_DISTINCT_ID(request)
|
||||
except:
|
||||
except: # noqa: E722
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from sentry_sdk._types import MYPY
|
||||
from sentry_sdk.client import Client
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.integrations import Integration
|
||||
from sentry_sdk.scope import add_global_event_processor
|
||||
@@ -10,9 +9,9 @@ from posthog.request import DEFAULT_HOST
|
||||
from posthog.sentry import POSTHOG_ID_TAG
|
||||
|
||||
if MYPY:
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Optional # noqa: F401
|
||||
|
||||
from sentry_sdk._types import Event, Hint
|
||||
from sentry_sdk._types import Event, Hint # noqa: F401
|
||||
|
||||
|
||||
class PostHogIntegration(Integration):
|
||||
|
||||
+217
-4
@@ -1,7 +1,6 @@
|
||||
import time
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import mock
|
||||
@@ -28,7 +27,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch)
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
@@ -71,7 +70,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 +105,98 @@ 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, False)
|
||||
self.assertEqual(variants, {"beta-feature": "random-variant", "alpha-feature": True})
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
distinct_id="some_id",
|
||||
groups={},
|
||||
person_properties=None,
|
||||
group_properties=None,
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@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.assertTrue(msg["properties"]["$geoip_disable"])
|
||||
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)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
distinct_id="distinct_id",
|
||||
groups={},
|
||||
person_properties=None,
|
||||
group_properties=None,
|
||||
disable_geoip=True,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(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, disable_geoip=True
|
||||
)
|
||||
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True, disable_geoip=False)
|
||||
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.assertTrue("$geoip_disable" not in msg["properties"])
|
||||
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)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
distinct_id="distinct_id",
|
||||
groups={},
|
||||
person_properties=None,
|
||||
group_properties=None,
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@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"}}
|
||||
@@ -273,6 +363,7 @@ class TestClient(unittest.TestCase):
|
||||
"$group_set": {},
|
||||
"$lib": "posthog-python",
|
||||
"$lib_version": VERSION,
|
||||
"$geoip_disable": True,
|
||||
},
|
||||
)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
@@ -294,6 +385,7 @@ class TestClient(unittest.TestCase):
|
||||
"$group_set": {"trait": "value"},
|
||||
"$lib": "posthog-python",
|
||||
"$lib_version": VERSION,
|
||||
"$geoip_disable": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
@@ -436,6 +528,127 @@ class TestClient(unittest.TestCase):
|
||||
for consumer in client.consumers:
|
||||
self.assertEqual(consumer.timeout, 15)
|
||||
|
||||
def test_disabled(self):
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
self.assertFalse(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg, "disabled")
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_disabled_with_feature_flags(self, patch_decide):
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
|
||||
|
||||
response = client.get_feature_flag("beta-feature", "12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
|
||||
response = client.feature_enabled("beta-feature", "12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
|
||||
response = client.get_all_flags("12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
|
||||
response = client.get_feature_flag_payload("key", "12345")
|
||||
self.assertIsNone(response)
|
||||
patch_decide.assert_not_called()
|
||||
|
||||
response = client.get_all_flags_and_payloads("12345")
|
||||
self.assertEqual(response, {"featureFlags": None, "featureFlagPayloads": None})
|
||||
patch_decide.assert_not_called()
|
||||
|
||||
# no capture calls
|
||||
self.assertTrue(client.queue.empty())
|
||||
|
||||
def test_enabled_to_disabled(self):
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=False)
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
|
||||
client.disabled = True
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
self.assertFalse(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg, "disabled")
|
||||
|
||||
def test_disable_geoip_default_on_events(self):
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=True)
|
||||
_, capture_msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
self.assertEqual(capture_msg["properties"]["$geoip_disable"], True)
|
||||
|
||||
_, identify_msg = client.identify("distinct_id", {"trait": "value"})
|
||||
client.flush()
|
||||
self.assertEqual(identify_msg["properties"]["$geoip_disable"], True)
|
||||
|
||||
def test_disable_geoip_override_on_events(self):
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
|
||||
_, capture_msg = client.set("distinct_id", {"a": "b", "c": "d"}, disable_geoip=True)
|
||||
client.flush()
|
||||
self.assertEqual(capture_msg["properties"]["$geoip_disable"], True)
|
||||
|
||||
_, identify_msg = client.page("distinct_id", "http://a.com", {"trait": "value"}, disable_geoip=False)
|
||||
client.flush()
|
||||
self.assertEqual("$geoip_disable" not in identify_msg["properties"], True)
|
||||
|
||||
def test_disable_geoip_method_overrides_init_on_events(self):
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=True)
|
||||
_, msg = client.capture("distinct_id", "python test event", disable_geoip=False)
|
||||
client.flush()
|
||||
self.assertTrue("$geoip_disable" not in msg["properties"])
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_disable_geoip_default_on_decide(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, disable_geoip=False)
|
||||
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
distinct_id="some_id",
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
patch_decide.reset_mock()
|
||||
client.feature_enabled("random_key", "feature_enabled_distinct_id", disable_geoip=True)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
distinct_id="feature_enabled_distinct_id",
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
patch_decide.reset_mock()
|
||||
client.get_all_flags_and_payloads("all_flags_payloads_id")
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
distinct_id="all_flags_payloads_id",
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_call_identify_fails(self, patch_get, patch_poll):
|
||||
|
||||
@@ -24,7 +24,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch)
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
@@ -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):
|
||||
@@ -1237,7 +1739,7 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertFalse(match_property(property_b, {"key": "three"}))
|
||||
|
||||
def test_match_properties_regex(self):
|
||||
property_a = self.property(key="key", value="\.com$", operator="regex")
|
||||
property_a = self.property(key="key", value="\.com$", operator="regex") # noqa: W605
|
||||
self.assertTrue(match_property(property_a, {"key": "value.com"}))
|
||||
self.assertTrue(match_property(property_a, {"key": "value2.com"}))
|
||||
|
||||
@@ -1245,7 +1747,7 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertFalse(match_property(property_a, {"key": "Alakazam"}))
|
||||
self.assertFalse(match_property(property_a, {"key": 123}))
|
||||
self.assertFalse(match_property(property_a, {"key": "valuecom"}))
|
||||
self.assertFalse(match_property(property_a, {"key": "value\com"}))
|
||||
self.assertFalse(match_property(property_a, {"key": "value\com"})) # noqa: W605
|
||||
|
||||
property_b = self.property(key="key", value="3", operator="regex")
|
||||
self.assertTrue(match_property(property_b, {"key": "3"}))
|
||||
@@ -1388,6 +1890,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
patch_capture.reset_mock()
|
||||
|
||||
@@ -1412,6 +1915,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
patch_capture.reset_mock()
|
||||
|
||||
@@ -1444,6 +1948,45 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "decide-flag", "$feature_flag_response": "decide-value", "locally_evaluated": False},
|
||||
groups={"organization": "org1"},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_disable_geoip_get_flag_capture_call(self, patch_decide, patch_capture):
|
||||
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY, disable_geoip=True)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [{"key": "region", "value": "USA"}],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
client.get_feature_flag(
|
||||
"complex-flag",
|
||||
"some-distinct-id",
|
||||
person_properties={"region": "USA", "name": "Aloha"},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
groups={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.MAX_DICT_SIZE", 100)
|
||||
@@ -1477,6 +2020,7 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
self.assertEqual(len(client.distinct_ids_feature_flags_reported), i % 100 + 1)
|
||||
@@ -1495,7 +2039,7 @@ class TestConsistency(unittest.TestCase):
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch)
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
|
||||
+17
-16
@@ -1,40 +1,41 @@
|
||||
import unittest
|
||||
|
||||
import posthog
|
||||
from posthog import Posthog
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
posthog = None
|
||||
|
||||
def failed(self):
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
posthog.api_key = "testsecret"
|
||||
posthog.on_error = self.failed
|
||||
self.posthog = Posthog("testsecret", host="http://localhost:8000", on_error=self.failed)
|
||||
|
||||
def test_no_api_key(self):
|
||||
posthog.api_key = None
|
||||
self.assertRaises(Exception, posthog.capture)
|
||||
self.posthog.api_key = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_no_host(self):
|
||||
posthog.host = None
|
||||
self.assertRaises(Exception, posthog.capture)
|
||||
self.posthog.host = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
posthog.capture("distinct_id", "python module event")
|
||||
posthog.flush()
|
||||
self.posthog.capture("distinct_id", "python module event")
|
||||
self.posthog.flush()
|
||||
|
||||
def test_identify(self):
|
||||
posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
posthog.flush()
|
||||
self.posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
self.posthog.flush()
|
||||
|
||||
def test_alias(self):
|
||||
posthog.alias("previousId", "distinct_id")
|
||||
posthog.flush()
|
||||
self.posthog.alias("previousId", "distinct_id")
|
||||
self.posthog.flush()
|
||||
|
||||
def test_page(self):
|
||||
posthog.page("distinct_id", "https://posthog.com/contact")
|
||||
posthog.flush()
|
||||
self.posthog.page("distinct_id", "https://posthog.com/contact")
|
||||
self.posthog.flush()
|
||||
|
||||
def test_flush(self):
|
||||
posthog.flush()
|
||||
self.posthog.flush()
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
VERSION = "2.2.0"
|
||||
VERSION = "3.0.1"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="")
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -14,12 +14,14 @@ 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": [
|
||||
"black",
|
||||
"isort",
|
||||
"flake8",
|
||||
"flake8-print",
|
||||
"pre-commit",
|
||||
],
|
||||
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest"],
|
||||
|
||||
+1
-1
@@ -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.6.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"]
|
||||
|
||||
tests_require = ["mock>=2.0.0"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user