Compare commits

...
16 Commits
Author SHA1 Message Date
Neil KakkarandGitHub e60d52c199 feat(flags): Add local props and flags to all calls (#106) 2024-01-09 11:30:10 +00:00
Neil KakkarandGitHub a2c73d0536 feat(flags): Add relative date operators, fix numeric ops (#105) 2024-01-09 11:22:18 +00:00
Xavier VelloandGitHub 33ba5d6843 feat: increase message and batch sizes (#108) 2023-12-04 16:27:26 +01:00
Daniil OkhlopkovandGitHub 3515c40483 Update LICENSE (#104) 2023-10-25 16:08:07 +01:00
Neil KakkarandGitHub 139258cacb fix(flags): Ensure feature properties exist on feature flag called ev… (#101)
* fix(flags): Ensure feature properties exist on feature flag called events

* fix

* make flake8 happy, will this bork?
2023-08-17 18:03:28 +01:00
Neil KakkarandGitHub f75d924d4c fix: disable behaviour for feature flags (#99) 2023-04-21 13:10:02 +01:00
617bb53501 Disable geoip for capture and decide calls by default (#98)
Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2023-04-17 12:06:57 +01:00
Eric DuongandGitHub 4aa3499527 version 2.5.0 (#97) 2023-04-10 15:13:25 -04:00
Eric DuongandGitHub de7def97e2 chore: change package to be an instantiable client (#96)
* initial try

* change init file to a class

* remove commas

* format

* sort

* restore original and add renamed client

* format

* move disabled

* format

* handle changing var for global instance

* add test
2023-04-10 14:33:07 -04:00
Neil KakkarandGitHub dfefd0a1b6 Fix analytics package dependencies (#95) 2023-03-30 10:34:59 -04:00
Neil KakkarandGitHub 477a688016 Add CI to ensure no prints (#94) 2023-03-17 15:22:59 +00:00
Neil KakkarandGitHub fa474a0fe6 Release for print fix (#93) 2023-03-17 12:41:16 +00:00
Jann KleenandGitHub f8bc3f17eb Remove print() call (#92) 2023-03-17 12:37:01 +00:00
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
16 changed files with 1429 additions and 126 deletions
+5 -1
View File
@@ -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: |
@@ -58,4 +62,4 @@ jobs:
- name: Run posthog tests
run: |
python setup.py test
pytest --verbose --timeout=30
+56
View File
@@ -1,3 +1,59 @@
## 3.3.0 - 2024-01-09
1. When local evaluation is enabled, we automatically add flag information to all events sent to PostHog, whenever possible. This makes it easier to use these events in experiments.
## 3.2.0 - 2024-01-09
1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
2. Add support for relative date operators for local evaluation.
## 3.1.0 - 2023-12-04
1. Increase maximum event size and batch size
## 3.0.2 - 2023-08-17
1. Returns the current flag property with $feature_flag_called events, to make it easier to use in experiments
## 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
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2020 PostHog (part of Hiberly Inc)
Copyright (c) 2023 PostHog (part of Hiberly Inc)
Copyright (c) 2013 Segment Inc. friends@segment.com
+49 -12
View File
@@ -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,6 +352,7 @@ def get_all_flags(
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
@@ -345,6 +365,7 @@ def get_feature_flag_payload(
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
):
return _proxy(
"get_feature_flag_payload",
@@ -356,6 +377,7 @@ def get_feature_flag_payload(
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
@@ -365,6 +387,7 @@ def get_all_flags_and_payloads(
person_properties={},
group_properties={},
only_evaluate_locally=False,
disable_geoip=None, # type: Optional[bool]
):
return _proxy(
"get_all_flags_and_payloads",
@@ -373,9 +396,15 @@ def get_all_flags_and_payloads(
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)
@@ -405,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,
@@ -418,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
+156 -33
View File
@@ -47,6 +47,8 @@ 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)
@@ -65,9 +67,12 @@ class Client(object):
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,19 +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):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties)
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_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)
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):
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:
@@ -147,8 +169,10 @@ 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
def capture(
@@ -161,6 +185,7 @@ class Client(object):
uuid=None,
groups=None,
send_feature_flags=False,
disable_geoip=None,
):
properties = properties or {}
context = context or {}
@@ -183,17 +208,28 @@ 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:
for feature, variant in feature_variants.items():
msg["properties"]["$feature/{}".format(feature)] = variant
msg["properties"][f"$feature/{feature}"] = variant
msg["properties"]["$active_feature_flags"] = list(feature_variants.keys())
elif self.feature_flags:
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
feature_variants = self.get_all_flags(
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
)
for feature, variant in feature_variants.items():
msg["properties"][f"$feature/{feature}"] = variant
return self._enqueue(msg)
active_feature_flags = [key for (key, value) in feature_variants.items() if value is not False]
if active_feature_flags:
msg["properties"]["$active_feature_flags"] = active_feature_flags
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None):
return self._enqueue(msg, disable_geoip)
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)
@@ -208,9 +244,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)
@@ -225,9 +261,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)
@@ -247,9 +292,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)
@@ -266,9 +311,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 {}
@@ -287,10 +334,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())
@@ -313,6 +364,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)
@@ -368,7 +425,7 @@ class Client(object):
try:
response = get(
self.personal_api_key,
f"/api/feature_flag/local_evaluation/?token={self.api_key}",
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
self.host,
timeout=10,
)
@@ -378,11 +435,12 @@ class Client(object):
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:
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"
"[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(
@@ -443,7 +501,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,
@@ -455,6 +513,7 @@ class Client(object):
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
):
response = self.get_feature_flag(
key,
@@ -464,6 +523,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:
@@ -480,12 +540,20 @@ 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
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
response = None
@@ -513,7 +581,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:
@@ -525,7 +597,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,
@@ -534,8 +606,10 @@ class Client(object):
"$feature_flag": key,
"$feature_flag_response": response,
"locally_evaluated": flag_was_locally_evaluated,
f"$feature/{key}": response,
},
groups=groups,
disable_geoip=disable_geoip,
)
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
return response
@@ -551,7 +625,11 @@ class Client(object):
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,
@@ -561,6 +639,7 @@ class Client(object):
group_properties=group_properties,
send_feature_flag_events=send_feature_flag_events,
only_evaluate_locally=True,
disable_geoip=disable_geoip,
)
response = None
@@ -569,7 +648,9 @@ class Client(object):
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)
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
@@ -587,7 +668,14 @@ class Client(object):
return payload
def get_all_flags(
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, only_evaluate_locally=False
self,
distinct_id,
*,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
disable_geoip=None,
):
flags = self.get_all_flags_and_payloads(
distinct_id,
@@ -595,12 +683,27 @@ class Client(object):
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
self,
distinct_id,
*,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
disable_geoip=None,
):
if self.disabled:
return {"featureFlags": None, "featureFlagPayloads": None}
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
@@ -609,7 +712,11 @@ class Client(object):
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
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
disable_geoip=disable_geoip,
)
response = flags_and_payloads
except Exception as e:
@@ -621,7 +728,7 @@ class Client(object):
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()
flags = {}
@@ -641,7 +748,7 @@ class Client(object):
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
if matched_payload:
payloads[flag["key"]] = matched_payload
except InconclusiveMatchError as e:
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:
@@ -652,6 +759,22 @@ class Client(object):
return flags, payloads, fallback_to_decide
def feature_flag_definitions(self):
return self.feature_flags
def _add_local_person_and_group_properties(self, distinct_id, groups, person_properties, group_properties):
all_person_properties = {"$current_distinct_id": distinct_id, **(person_properties or {})}
all_group_properties = {}
if groups:
for group_name in groups:
all_group_properties[group_name] = {
"$group_key": groups[group_name],
**(group_properties.get(group_name) or {}),
}
return all_person_properties, all_group_properties
def require(name, field, data_type):
"""Require that the named `field` has the right `data_type`"""
+6 -5
View File
@@ -12,11 +12,12 @@ try:
except ImportError:
from Queue import Empty
MAX_MSG_SIZE = 32 << 10
# Our servers only accept batches less than 500KB. Here limit is set slightly
# lower to leave space for extra data that will be added later, eg. "sentAt".
BATCH_SIZE_LIMIT = 475000
MAX_MSG_SIZE = 900 * 1024 # 900KiB per event
# The maximum request body size is currently 20MiB, let's be conservative
# in case we want to lower it in the future.
BATCH_SIZE_LIMIT = 5 * 1024 * 1024
class Consumer(Thread):
@@ -104,7 +105,7 @@ class Consumer(Thread):
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
if item_size > MAX_MSG_SIZE:
self.log.error("Item exceeds 32kb limit, dropping. (%s)", str(item))
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
continue
items.append(item)
total_size += item_size
+186 -32
View File
@@ -1,13 +1,18 @@
import datetime
import hashlib
import logging
import re
from typing import Optional
from dateutil import parser
from dateutil.relativedelta import relativedelta
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 +47,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 +63,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 +83,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):
@@ -106,15 +119,17 @@ def match_property(property, property_values) -> bool:
override_value = property_values[key]
if operator == "exact":
if isinstance(value, list):
return override_value in value
return value == override_value
if operator in ("exact", "is_not"):
if operator == "is_not":
if isinstance(value, list):
return override_value not in value
return value != override_value
def compute_exact_match(value, override_value):
if isinstance(value, list):
return str(override_value).lower() in [str(val).lower() for val in value]
return str(value).lower() == str(override_value).lower()
if operator == "exact":
return compute_exact_match(value, override_value)
else:
return not compute_exact_match(value, override_value)
if operator == "is_set":
return key in property_values
@@ -131,33 +146,56 @@ def match_property(property, property_values) -> bool:
if operator == "not_regex":
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
if operator == "gt":
return type(override_value) == type(value) and override_value > value
if operator in ("gt", "gte", "lt", "lte"):
# :TRICKY: We adjust comparison based on the override value passed in,
# to make sure we handle both numeric and string comparisons appropriately.
def compare(lhs, rhs, operator):
if operator == "gt":
return lhs > rhs
elif operator == "gte":
return lhs >= rhs
elif operator == "lt":
return lhs < rhs
elif operator == "lte":
return lhs <= rhs
else:
raise ValueError(f"Invalid operator: {operator}")
if operator == "gte":
return type(override_value) == type(value) and override_value >= value
if operator == "lt":
return type(override_value) == type(value) and override_value < value
if operator == "lte":
return type(override_value) == type(value) and override_value <= value
if operator in ["is_date_before", "is_date_after"]:
parsed_value = None
try:
parsed_date = parser.parse(value)
parsed_date = convert_to_datetime_aware(parsed_date)
parsed_value = float(value) # type: ignore
except Exception:
pass
if parsed_value is not None and override_value is not None:
if isinstance(override_value, str):
return compare(override_value, str(value), operator)
else:
return compare(override_value, parsed_value, operator)
else:
return compare(str(override_value), str(value), operator)
if operator in ["is_date_before", "is_date_after", "is_relative_date_before", "is_relative_date_after"]:
try:
if operator in ["is_relative_date_before", "is_relative_date_after"]:
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
else:
parsed_date = parser.parse(str(value))
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception as e:
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
if not parsed_date:
raise InconclusiveMatchError("The date set on the flag is not a valid format")
if isinstance(override_value, datetime.datetime):
override_date = convert_to_datetime_aware(override_value)
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_date < parsed_date
else:
return override_date > parsed_date
elif isinstance(override_value, datetime.date):
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_value < parsed_date.date()
else:
return override_value > parsed_date.date()
@@ -165,7 +203,7 @@ def match_property(property, property_values) -> bool:
try:
override_date = parser.parse(override_value)
override_date = convert_to_datetime_aware(override_date)
if operator == "is_date_before":
if operator in ("is_date_before", "is_relative_date_before"):
return override_date < parsed_date
else:
return override_date > parsed_date
@@ -174,4 +212,120 @@ def match_property(property, property_values) -> bool:
else:
raise InconclusiveMatchError("The date provided must be a string or date object")
return False
# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")
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"
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
regex = r"^(?P<number>[0-9]+)(?P<interval>[a-z])$"
match = re.search(regex, value)
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
if match:
number = int(match.group("number"))
if number >= 10_000:
# Guard against overflow, disallow numbers greater than 10_000
return None
interval = match.group("interval")
if interval == "h":
parsed_dt = parsed_dt - relativedelta(hours=number)
elif interval == "d":
parsed_dt = parsed_dt - relativedelta(days=number)
elif interval == "w":
parsed_dt = parsed_dt - relativedelta(weeks=number)
elif interval == "m":
parsed_dt = parsed_dt - relativedelta(months=number)
elif interval == "y":
parsed_dt = parsed_dt - relativedelta(years=number)
else:
return None
return parsed_dt
else:
return None
+1 -1
View File
@@ -11,7 +11,7 @@ def get_distinct_id(request):
return None
try:
return GET_DISTINCT_ID(request)
except:
except: # noqa: E722
return None
+2 -3
View File
@@ -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):
+388 -3
View File
@@ -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):
@@ -106,6 +105,202 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"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,
},
{
"rollout_percentage": 50,
},
],
"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"}},
},
}
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},
},
}
false_flag = {
"id": 1,
"name": "Beta Feature",
"key": "false-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 0,
}
],
"payloads": {"true": 300},
},
}
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
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")
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-local"], "third-variant")
self.assertEqual(msg["properties"]["$feature/false-flag"], False)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
assert "$feature/beta-feature" not in msg["properties"]
self.assertEqual(patch_decide.call_count, 0)
# test that flags are not evaluated without local evaluation
client.feature_flags = []
success, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
assert "$feature/beta-feature" not in msg["properties"]
assert "$feature/beta-feature-local" not in msg["properties"]
assert "$feature/false-flag" not in msg["properties"]
assert "$active_feature_flags" not in msg["properties"]
@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"}}
@@ -272,6 +467,7 @@ class TestClient(unittest.TestCase):
"$group_set": {},
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
)
self.assertTrue(isinstance(msg["timestamp"], str))
@@ -293,6 +489,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")
@@ -435,6 +632,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={"$current_distinct_id": "some_id"},
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={"$current_distinct_id": "feature_enabled_distinct_id"},
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={"$current_distinct_id": "all_flags_payloads_id"},
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):
@@ -446,3 +764,70 @@ class TestClient(unittest.TestCase):
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
self.assertFalse(client.feature_enabled("example", "distinct_id"))
@mock.patch("posthog.client.decide")
def test_default_properties_get_added_properly(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",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"x1": "y1"},
group_properties={"company": {"x": "y"}},
)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"$current_distinct_id": "some_id", "x1": "y1"},
group_properties={
"company": {"$group_key": "id:5", "x": "y"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
)
patch_decide.reset_mock()
client.get_feature_flag(
"random_key",
"some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"$current_distinct_id": "override"},
group_properties={
"company": {
"$group_key": "group_override",
}
},
)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"$current_distinct_id": "override"},
group_properties={
"company": {"$group_key": "group_override"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
)
patch_decide.reset_mock()
# test nones
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={},
person_properties={"$current_distinct_id": "some_id"},
group_properties={},
disable_geoip=False,
)
+9 -4
View File
@@ -145,15 +145,20 @@ class TestConsumer(unittest.TestCase):
def test_max_batch_size(self):
q = Queue()
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
properties = {}
for n in range(0, 500):
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id", "properties": properties}
msg_size = len(json.dumps(track).encode())
# number of messages in a maximum-size batch
n_msgs = int(475000 / msg_size)
# Let's capture 8MB of data to trigger two batches
n_msgs = int(8_000_000 / msg_size)
def mock_post_fn(_, data, **kwargs):
res = mock.Mock()
res.status_code = 200
self.assertTrue(len(data.encode()) < 500000, "batch size (%d) exceeds 500KB limit" % len(data.encode()))
request_size = len(data.encode())
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
self.assertTrue(request_size < (5 * 1024 * 1024) * 1.1, "batch size (%d) higher than limit" % request_size)
return res
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
+547 -11
View File
@@ -6,7 +6,7 @@ from dateutil import parser, tz
from freezegun import freeze_time
from posthog.client import Client
from posthog.feature_flags import InconclusiveMatchError, match_property
from posthog.feature_flags import InconclusiveMatchError, match_property, relative_date_parse_for_feature_flag_matching
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
@@ -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):
@@ -960,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):
@@ -1586,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"}))
@@ -1594,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"}))
@@ -1622,7 +1775,8 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_a, {"key": 0}))
self.assertFalse(match_property(property_a, {"key": -1}))
self.assertFalse(match_property(property_a, {"key": "23"}))
# now we handle type mismatches so this should be true
self.assertTrue(match_property(property_a, {"key": "23"}))
property_b = self.property(key="key", value=1, operator="lt")
self.assertTrue(match_property(property_b, {"key": 0}))
@@ -1639,7 +1793,8 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_c, {"key": 0}))
self.assertFalse(match_property(property_c, {"key": -1}))
self.assertFalse(match_property(property_c, {"key": "3"}))
# now we handle type mismatches so this should be true
self.assertTrue(match_property(property_c, {"key": "3"}))
property_d = self.property(key="key", value="43", operator="lte")
self.assertTrue(match_property(property_d, {"key": "41"}))
@@ -1648,6 +1803,21 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_d, {"key": "44"}))
self.assertFalse(match_property(property_d, {"key": 44}))
self.assertTrue(match_property(property_d, {"key": 42}))
property_e = self.property(key="key", value="30", operator="lt")
self.assertTrue(match_property(property_e, {"key": "29"}))
# depending on the type of override, we adjust type comparison
self.assertTrue(match_property(property_e, {"key": "100"}))
self.assertFalse(match_property(property_e, {"key": 100}))
property_f = self.property(key="key", value="123aloha", operator="gt")
self.assertFalse(match_property(property_f, {"key": "123"}))
self.assertFalse(match_property(property_f, {"key": 122}))
# this turns into a string comparison
self.assertTrue(match_property(property_f, {"key": 129}))
def test_match_property_date_operators(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_date_before")
@@ -1701,6 +1871,305 @@ class TestMatchProperties(unittest.TestCase):
self.assertTrue(match_property(property_d, {"key": "2022-04-05 11:34:11 +00:00"}))
self.assertFalse(match_property(property_d, {"key": "2022-04-05 11:34:13 +00:00"}))
@freeze_time("2022-05-01")
def test_match_property_relative_date_operators(self):
property_a = self.property(key="key", value="6h", operator="is_relative_date_before")
self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
self.assertTrue(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)}))
# false because date comparison, instead of datetime, so reduces to same date
self.assertFalse(match_property(property_a, {"key": datetime.date(2022, 4, 30)}))
self.assertFalse(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 19, 2, 3)}))
self.assertTrue(
match_property(
property_a,
{"key": datetime.datetime(2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid"))},
)
)
self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))
# Can't be a number
with self.assertRaises(InconclusiveMatchError):
match_property(property_a, {"key": 1})
# can't be invalid string
with self.assertRaises(InconclusiveMatchError):
match_property(property_a, {"key": "abcdef"})
property_b = self.property(key="key", value="1h", operator="is_relative_date_after")
self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
self.assertTrue(match_property(property_b, {"key": datetime.datetime(2022, 5, 30)}))
self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))
# can't be invalid string
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_b, {"key": "abcdef"}))
# Invalid flag property
property_c = self.property(key="key", value=1234, operator="is_relative_date_after")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_c, {"key": 1}))
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_c, {"key": "2022-05-30"}))
# # Timezone aware property
property_d = self.property(key="key", value="12d", operator="is_relative_date_before")
self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))
self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
self.assertTrue(match_property(property_d, {"key": "2022-04-05 12:34:11+01:00"}))
self.assertTrue(match_property(property_d, {"key": "2022-04-19 01:34:11+02:00"}))
self.assertFalse(match_property(property_d, {"key": "2022-04-19 02:00:01+02:00"}))
# Try all possible relative dates
property_e = self.property(key="key", value="1h", operator="is_relative_date_before")
self.assertFalse(match_property(property_e, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_e, {"key": "2022-04-30 22:00:00"}))
property_f = self.property(key="key", value="1d", operator="is_relative_date_before")
self.assertTrue(match_property(property_f, {"key": "2022-04-29 23:59:00"}))
self.assertFalse(match_property(property_f, {"key": "2022-04-30 00:00:01"}))
property_g = self.property(key="key", value="1w", operator="is_relative_date_before")
self.assertTrue(match_property(property_g, {"key": "2022-04-23 00:00:00"}))
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:00"}))
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:01"}))
property_h = self.property(key="key", value="1m", operator="is_relative_date_before")
self.assertTrue(match_property(property_h, {"key": "2022-03-01 00:00:00"}))
self.assertFalse(match_property(property_h, {"key": "2022-04-05 00:00:00"}))
property_i = self.property(key="key", value="1y", operator="is_relative_date_before")
self.assertTrue(match_property(property_i, {"key": "2021-04-28 00:00:00"}))
self.assertFalse(match_property(property_i, {"key": "2021-05-01 00:00:01"}))
property_j = self.property(key="key", value="122h", operator="is_relative_date_after")
self.assertTrue(match_property(property_j, {"key": "2022-05-01 00:00:00"}))
self.assertFalse(match_property(property_j, {"key": "2022-04-23 01:00:00"}))
property_k = self.property(key="key", value="2d", operator="is_relative_date_after")
self.assertTrue(match_property(property_k, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_k, {"key": "2022-04-29 00:00:01"}))
self.assertFalse(match_property(property_k, {"key": "2022-04-29 00:00:00"}))
property_l = self.property(key="key", value="02w", operator="is_relative_date_after")
self.assertTrue(match_property(property_l, {"key": "2022-05-01 00:00:00"}))
self.assertFalse(match_property(property_l, {"key": "2022-04-16 00:00:00"}))
property_m = self.property(key="key", value="1m", operator="is_relative_date_after")
self.assertTrue(match_property(property_m, {"key": "2022-04-01 00:00:01"}))
self.assertFalse(match_property(property_m, {"key": "2022-04-01 00:00:00"}))
property_n = self.property(key="key", value="1y", operator="is_relative_date_after")
self.assertTrue(match_property(property_n, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_n, {"key": "2021-05-01 00:00:01"}))
self.assertFalse(match_property(property_n, {"key": "2021-05-01 00:00:00"}))
self.assertFalse(match_property(property_n, {"key": "2021-04-30 00:00:00"}))
self.assertFalse(match_property(property_n, {"key": "2021-03-01 12:13:00"}))
def test_none_property_value_with_all_operators(self):
property_a = self.property(key="key", value="none", operator="is_not")
self.assertFalse(match_property(property_a, {"key": None}))
self.assertTrue(match_property(property_a, {"key": "non"}))
property_b = self.property(key="key", value=None, operator="is_set")
self.assertTrue(match_property(property_b, {"key": None}))
property_c = self.property(key="key", value="no", operator="icontains")
self.assertTrue(match_property(property_c, {"key": None}))
self.assertFalse(match_property(property_c, {"key": "smh"}))
property_d = self.property(key="key", value="No", operator="regex")
self.assertTrue(match_property(property_d, {"key": None}))
property_d_lower_case = self.property(key="key", value="no", operator="regex")
self.assertFalse(match_property(property_d_lower_case, {"key": None}))
property_e = self.property(key="key", value=1, operator="gt")
self.assertTrue(match_property(property_e, {"key": None}))
property_f = self.property(key="key", value=1, operator="lt")
self.assertFalse(match_property(property_f, {"key": None}))
property_g = self.property(key="key", value="xyz", operator="gte")
self.assertFalse(match_property(property_g, {"key": None}))
property_h = self.property(key="key", value="Oo", operator="lte")
self.assertTrue(match_property(property_h, {"key": None}))
property_i = self.property(key="key", value="2022-05-01", operator="is_date_before")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_i, {"key": None}))
property_j = self.property(key="key", value="2022-05-01", operator="is_date_after")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_j, {"key": None}))
property_k = self.property(key="key", value="2022-05-01", operator="is_date_before")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_k, {"key": "random"}))
def test_unknown_operator(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
with self.assertRaises(InconclusiveMatchError) as exception_context:
match_property(property_a, {"key": "random"})
self.assertEqual(str(exception_context.exception), "Unknown operator is_unknown")
class TestRelativeDateParsing(unittest.TestCase):
def test_invalid_input(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1") is None
assert relative_date_parse_for_feature_flag_matching("1x") is None
assert relative_date_parse_for_feature_flag_matching("1.2y") is None
assert relative_date_parse_for_feature_flag_matching("1z") is None
assert relative_date_parse_for_feature_flag_matching("1s") is None
assert relative_date_parse_for_feature_flag_matching("123344000.134m") is None
assert relative_date_parse_for_feature_flag_matching("bazinga") is None
assert relative_date_parse_for_feature_flag_matching("000bello") is None
assert relative_date_parse_for_feature_flag_matching("000hello") is None
assert relative_date_parse_for_feature_flag_matching("000h") is not None
assert relative_date_parse_for_feature_flag_matching("1000h") is not None
def test_overflow(self):
assert relative_date_parse_for_feature_flag_matching("1000000h") is None
assert relative_date_parse_for_feature_flag_matching("100000000000000000y") is None
def test_hour_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1h") == datetime.datetime(
2020, 1, 1, 11, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2h") == datetime.datetime(
2020, 1, 1, 10, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("24h") == datetime.datetime(
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("30h") == datetime.datetime(
2019, 12, 31, 6, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("48h") == datetime.datetime(
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"24h"
) == relative_date_parse_for_feature_flag_matching("1d")
assert relative_date_parse_for_feature_flag_matching(
"48h"
) == relative_date_parse_for_feature_flag_matching("2d")
def test_day_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1d") == datetime.datetime(
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2d") == datetime.datetime(
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("7d") == datetime.datetime(
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("14d") == datetime.datetime(
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("30d") == datetime.datetime(
2019, 12, 2, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("7d") == relative_date_parse_for_feature_flag_matching(
"1w"
)
def test_week_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1w") == datetime.datetime(
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2w") == datetime.datetime(
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4w") == datetime.datetime(
2019, 12, 4, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8w") == datetime.datetime(
2019, 11, 6, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4w") != relative_date_parse_for_feature_flag_matching(
"1m"
)
def test_month_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
2019, 11, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
2019, 9, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
2019, 5, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"12m"
) == relative_date_parse_for_feature_flag_matching("1y")
with freeze_time("2020-04-03T00:00:00"):
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2020, 3, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
2020, 2, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
2019, 12, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
2019, 8, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 4, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"12m"
) == relative_date_parse_for_feature_flag_matching("1y")
def test_year_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2y") == datetime.datetime(
2018, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4y") == datetime.datetime(
2016, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8y") == datetime.datetime(
2012, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
@@ -1735,8 +2204,14 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
{
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=None,
)
patch_capture.reset_mock()
@@ -1759,8 +2234,14 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
{
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=None,
)
patch_capture.reset_mock()
@@ -1791,8 +2272,57 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{"$feature_flag": "decide-flag", "$feature_flag_response": "decide-value", "locally_evaluated": False},
{
"$feature_flag": "decide-flag",
"$feature_flag_response": "decide-value",
"locally_evaluated": False,
"$feature/decide-flag": "decide-value",
},
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,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=False,
)
@mock.patch("posthog.client.MAX_DICT_SIZE", 100)
@@ -1824,8 +2354,14 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.assert_called_with(
distinct_id,
"$feature_flag_called",
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
{
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/complex-flag": True,
},
groups={},
disable_geoip=None,
)
self.assertEqual(len(client.distinct_ids_feature_flags_reported), i % 100 + 1)
@@ -1844,7 +2380,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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
VERSION = "2.3.1"
VERSION = "3.3.0"
if __name__ == "__main__":
print(VERSION, end="")
print(VERSION, end="") # noqa: T201
+3 -1
View File
@@ -20,9 +20,11 @@ extras_require = {
"dev": [
"black",
"isort",
"flake8",
"flake8-print",
"pre-commit",
],
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest"],
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest", "pytest-timeout"],
"sentry": ["sentry-sdk", "django"],
}
+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.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"]