Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfefd0a1b6 | ||
|
|
477a688016 | ||
|
|
fa474a0fe6 | ||
|
|
f8bc3f17eb | ||
|
|
07277d35e7 | ||
|
|
15d0716744 | ||
|
|
b4103b3ae2 | ||
|
|
1aeffa990f | ||
|
|
5ae7feb4e9 | ||
|
|
6534afd8e3 | ||
|
|
a9d7bf3e0b | ||
|
|
d7be253ef8 | ||
|
|
592c0f362e | ||
|
|
c7fc5a83b4 | ||
|
|
ae8817b611 | ||
|
|
acad2b142e | ||
|
|
cb62570e69 | ||
|
|
33645ecd3c | ||
|
|
81debcef27 | ||
|
|
dac06bab18 | ||
|
|
2dc1298620 | ||
|
|
2c6b675be7 | ||
|
|
3a6fd07951 | ||
|
|
de0ccd29d3 | ||
|
|
addd2e3340 | ||
|
|
9d2fa72753 | ||
|
|
306fb2a1fa | ||
|
|
faffd1f88a | ||
|
|
ab1399d88f | ||
|
|
1777b7062e | ||
|
|
565bb8a0eb | ||
|
|
009cac8634 | ||
|
|
ec2425996c | ||
|
|
90fa0a0604 | ||
|
|
a97fe0a40a | ||
|
|
a474fcff93 | ||
|
|
a181ba718f |
@@ -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,79 @@
|
||||
## 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:
|
||||
|
||||
1. Add support for feature flag variant overrides with local evaluation
|
||||
|
||||
## 2.1.2 - 2022-09-15
|
||||
|
||||
Changes:
|
||||
|
||||
1. Fixes issues with date comparison.
|
||||
## 2.1.1 - 2022-09-14
|
||||
|
||||
Changes:
|
||||
|
||||
1. Feature flags local evaluation now supports date property filters as well. Accepts both strings and datetime objects.
|
||||
|
||||
## 2.1.0 - 2022-08-11
|
||||
|
||||
Changes:
|
||||
|
||||
1. Feature flag defaults have been removed
|
||||
2. Setup logging only when debug mode is enabled.
|
||||
## 2.0.1 - 2022-08-04
|
||||
|
||||
- Make poll_interval configurable
|
||||
- Add `send_feature_flag_events` parameter to feature flag calls, which determine whether the `$feature_flag_called` event should be sent or not.
|
||||
- Add `only_evaluate_locally` parameter to feature flag calls, which determines whether the feature flag should only be evaluated locally or not.
|
||||
|
||||
## 2.0.0 - 2022-08-02
|
||||
|
||||
Breaking changes:
|
||||
|
||||
1. The minimum version requirement for PostHog servers is now 1.38. If you're using PostHog Cloud, you satisfy this requirement automatically.
|
||||
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
|
||||
**Note: These are removed in 2.0.2**
|
||||
3. Feature flag remote evaluation doesn't require a personal API key.
|
||||
|
||||
New Changes:
|
||||
|
||||
1. You can now evaluate feature flags locally (i.e. without sending a request to your PostHog servers) by setting a personal API key, and passing in groups and person properties to `is_feature_enabled` and `get_feature_flag` calls.
|
||||
2. Introduces a `get_all_flags` method that returns all feature flags. This is useful for when you want to seed your frontend with some initial flags, given a user ID.
|
||||
|
||||
|
||||
|
||||
## 1.4.9 - 2022-06-13
|
||||
- Support for sending feature flags with capture calls
|
||||
|
||||
## 1.4.8 - 2022-05-12
|
||||
- Support multi variate feature flags
|
||||
|
||||
## 1.4.7 - 2022-04-25
|
||||
- Allow feature flags usage without project_api_key
|
||||
|
||||
## 1.4.1 - 2021-05-28
|
||||
- Fix packaging issues with Sentry integrations
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
[](https://pypi.org/project/posthog/)
|
||||
|
||||
Please see the main [PostHog docs](https://posthog.com/docs).
|
||||
|
||||
Specifically, the [Python integration](https://posthog.com/docs/integrations/python-integration) details.
|
||||
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -14,6 +13,7 @@ Specifically, the [Python integration](https://posthog.com/docs/integrations/pyt
|
||||
2. Run `source env/bin/activate` (activates the virtual environment)
|
||||
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
|
||||
4. Run `make test`
|
||||
1. To run a specific test do `pytest -k test_no_api_key`
|
||||
|
||||
### Running Locally
|
||||
|
||||
@@ -39,7 +39,7 @@ Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an eve
|
||||
|
||||
### Releasing Versions
|
||||
|
||||
Updated are released using GitHub Actions: after bumping `version.py` in `master`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
|
||||
Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
+63
-9
@@ -5,37 +5,63 @@ import time
|
||||
|
||||
import posthog
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.project_api_key = ""
|
||||
posthog.personal_api_key = ""
|
||||
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
|
||||
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
|
||||
|
||||
# Where you host PostHog, with no trailing /.
|
||||
# You can remove this line if you're using posthog.com
|
||||
posthog.host = "http://127.0.0.1:8000"
|
||||
posthog.host = "http://localhost:8000"
|
||||
posthog.poll_interval = 10
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"person-on-events-enabled",
|
||||
"12345",
|
||||
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
|
||||
group_properties={
|
||||
"organization": {
|
||||
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
|
||||
"created_at": "2022-06-30 11:44:52.984121+00:00",
|
||||
}
|
||||
},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Capture an event
|
||||
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"})
|
||||
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
|
||||
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
print("sleeping")
|
||||
time.sleep(5)
|
||||
print(posthog.feature_enabled("beta-feature-groups", "distinct_id", groups={"company": "id:5"}))
|
||||
|
||||
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")
|
||||
|
||||
posthog.capture("new_distinct_id", "event2", {"property1": "value", "property2": "value"})
|
||||
posthog.capture(
|
||||
"new_distinct_id", "event-with-groups", {"property1": "value", "property2": "value"}, groups={"company": "id:5"}
|
||||
)
|
||||
|
||||
# # Add properties to the person
|
||||
posthog.identify("new_distinct_id", {"email": "something@something.com"})
|
||||
|
||||
# Add properties to a group
|
||||
posthog.group_identify("company", "id:5", {"employees": 11})
|
||||
|
||||
# properties set only once to the person
|
||||
posthog.set_once("new_distinct_id", {"self_serve_signup": True})
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
posthog.set_once(
|
||||
"new_distinct_id", {"self_serve_signup": False}
|
||||
@@ -44,4 +70,32 @@ posthog.set_once(
|
||||
posthog.set("new_distinct_id", {"current_browser": "Chrome"})
|
||||
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
|
||||
|
||||
# posthog.shutdown()
|
||||
|
||||
# #############################################################################
|
||||
# Make sure you have a personal API key for the examples below
|
||||
|
||||
# Local Evaluation
|
||||
|
||||
# If flag has City=Sydney, this call doesn't go to `/decide`
|
||||
print(posthog.feature_enabled("test-flag", "distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}))
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"test-flag",
|
||||
"distinct_id_random_22",
|
||||
person_properties={"$geoip_city_name": "Sydney"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
print(posthog.get_all_flags("distinct_id_random_22"))
|
||||
print(posthog.get_all_flags("distinct_id_random_22", only_evaluate_locally=True))
|
||||
print(
|
||||
posthog.get_all_flags(
|
||||
"distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}, only_evaluate_locally=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
posthog.shutdown()
|
||||
|
||||
+145
-16
@@ -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
|
||||
@@ -15,6 +16,7 @@ sync_mode = False # type: bool
|
||||
disabled = False # type: bool
|
||||
personal_api_key = None # type: str
|
||||
project_api_key = None # type: str
|
||||
poll_interval = 30 # type: int
|
||||
|
||||
default_client = None
|
||||
|
||||
@@ -25,8 +27,9 @@ def capture(
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
uuid=None, # type: Optional[str]
|
||||
groups=None, # type: Optional[Dict]
|
||||
send_feature_flags=False,
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -56,8 +59,9 @@ def capture(
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
groups=groups,
|
||||
send_feature_flags=send_feature_flags,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,7 +70,7 @@ def identify(
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -90,7 +94,7 @@ def identify(
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,7 +103,7 @@ def set(
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -123,7 +127,7 @@ def set(
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,7 +136,7 @@ def set_once(
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -156,7 +160,7 @@ def set_once(
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -166,13 +170,13 @@ def group_identify(
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
Set properties on a group
|
||||
|
||||
A `set_once` call requires
|
||||
A `group_identify` call requires
|
||||
- `group_type` type of your group
|
||||
- `group_key` unique identifier of the group
|
||||
- `properties` with a dict with any key: value pairs
|
||||
@@ -191,7 +195,7 @@ def group_identify(
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -200,7 +204,7 @@ def alias(
|
||||
distinct_id, # type: str,
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
@@ -225,14 +229,18 @@ def alias(
|
||||
distinct_id=distinct_id,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
def feature_enabled(
|
||||
key, # type: str,
|
||||
distinct_id, # type: str,
|
||||
default=False, # type: bool
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
):
|
||||
# type: (...) -> bool
|
||||
"""
|
||||
@@ -242,11 +250,131 @@ def feature_enabled(
|
||||
```python
|
||||
if posthog.feature_enabled('beta feature', 'distinct id'):
|
||||
# do something
|
||||
if posthog.feature_enabled('groups feature', 'distinct id', groups={"organization": "5"}):
|
||||
# do something
|
||||
```
|
||||
|
||||
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
|
||||
"""
|
||||
return _proxy("feature_enabled", key=key, distinct_id=distinct_id, default=default)
|
||||
return _proxy(
|
||||
"feature_enabled",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag(
|
||||
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
|
||||
):
|
||||
"""
|
||||
Get feature flag variant for users. Used with experiments.
|
||||
Example:
|
||||
```python
|
||||
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'test-variant':
|
||||
# do test variant code
|
||||
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'control':
|
||||
# do control code
|
||||
```
|
||||
|
||||
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5",
|
||||
you would pass groups={"organization": "5"}.
|
||||
|
||||
`group_properties` take the format: { group_type_name: { group_properties } }
|
||||
|
||||
So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count,
|
||||
you'll send these as:
|
||||
|
||||
```python
|
||||
group_properties={"organization": {"name": "PostHog", "employees": 11}}
|
||||
```
|
||||
"""
|
||||
return _proxy(
|
||||
"get_feature_flag",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags(
|
||||
distinct_id, # type: str,
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
):
|
||||
"""
|
||||
Get all flags for a given user.
|
||||
Example:
|
||||
```python
|
||||
flags = posthog.get_all_flags('distinct_id')
|
||||
```
|
||||
|
||||
flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
|
||||
"""
|
||||
return _proxy(
|
||||
"get_all_flags",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag_payload(
|
||||
key,
|
||||
distinct_id,
|
||||
match_value=None,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
):
|
||||
return _proxy(
|
||||
"get_feature_flag_payload",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
match_value=match_value,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
):
|
||||
return _proxy(
|
||||
"get_all_flags_and_payloads",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
)
|
||||
|
||||
|
||||
def page(*args, **kwargs):
|
||||
@@ -290,6 +418,7 @@ def _proxy(method, *args, **kwargs):
|
||||
sync_mode=sync_mode,
|
||||
personal_api_key=personal_api_key,
|
||||
project_api_key=project_api_key,
|
||||
poll_interval=poll_interval,
|
||||
)
|
||||
|
||||
fn = getattr(default_client, method)
|
||||
|
||||
+332
-69
@@ -1,17 +1,17 @@
|
||||
import atexit
|
||||
import hashlib
|
||||
import logging
|
||||
import numbers
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
from dateutil.tz import tzutc
|
||||
from six import string_types
|
||||
|
||||
from posthog.consumer import Consumer
|
||||
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
|
||||
from posthog.poller import Poller
|
||||
from posthog.request import APIError, batch_post, decide, get
|
||||
from posthog.utils import clean, guess_timezone
|
||||
from posthog.utils import SizeLimitedDict, clean, guess_timezone
|
||||
from posthog.version import VERSION
|
||||
|
||||
try:
|
||||
@@ -21,7 +21,7 @@ except ImportError:
|
||||
|
||||
|
||||
ID_TYPES = (numbers.Number, string_types, UUID)
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
MAX_DICT_SIZE = 50_000
|
||||
|
||||
|
||||
class Client(object):
|
||||
@@ -48,7 +48,6 @@ class Client(object):
|
||||
personal_api_key=None,
|
||||
project_api_key=None,
|
||||
):
|
||||
|
||||
self.queue = queue.Queue(max_queue_size)
|
||||
|
||||
# api_key: This should be the Team API Key (token), public
|
||||
@@ -64,14 +63,22 @@ 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)
|
||||
|
||||
# personal_api_key: This should be a generated Personal API Key, private
|
||||
self.personal_api_key = personal_api_key
|
||||
|
||||
if debug:
|
||||
# Ensures that debug level messages are logged when debug mode is on.
|
||||
# Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided
|
||||
logging.basicConfig()
|
||||
self.log.setLevel(logging.DEBUG)
|
||||
else:
|
||||
self.log.setLevel(logging.WARNING)
|
||||
|
||||
if sync_mode:
|
||||
self.consumers = None
|
||||
@@ -103,7 +110,7 @@ class Client(object):
|
||||
if send:
|
||||
consumer.start()
|
||||
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -115,13 +122,53 @@ class Client(object):
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$identify",
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def get_feature_variants(self, distinct_id, groups=None, person_properties=None, group_properties=None):
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties)
|
||||
return resp_data["featureFlags"]
|
||||
|
||||
def _get_active_feature_variants(self, distinct_id, groups=None, person_properties=None, group_properties=None):
|
||||
feature_variants = self.get_feature_variants(distinct_id, groups, person_properties, group_properties)
|
||||
return {
|
||||
k: v for (k, v) in feature_variants.items() if v is not False
|
||||
} # explicitly test for false to account for values that may seem falsy (ex: 0)
|
||||
|
||||
def get_feature_payloads(self, distinct_id, groups=None, person_properties=None, group_properties=None):
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties)
|
||||
return resp_data["featureFlagPayloads"]
|
||||
|
||||
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None):
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
|
||||
if groups:
|
||||
require("groups", groups, dict)
|
||||
else:
|
||||
groups = {}
|
||||
|
||||
request_data = {
|
||||
"distinct_id": distinct_id,
|
||||
"groups": groups,
|
||||
"person_properties": person_properties,
|
||||
"group_properties": group_properties,
|
||||
}
|
||||
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
|
||||
|
||||
return resp_data
|
||||
|
||||
def capture(
|
||||
self, distinct_id=None, event=None, properties=None, context=None, timestamp=None, message_id=None, groups=None
|
||||
self,
|
||||
distinct_id=None,
|
||||
event=None,
|
||||
properties=None,
|
||||
context=None,
|
||||
timestamp=None,
|
||||
uuid=None,
|
||||
groups=None,
|
||||
send_feature_flags=False,
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
@@ -135,16 +182,26 @@ class Client(object):
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"event": event,
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
if groups:
|
||||
require("groups", groups, dict)
|
||||
msg["properties"]["$groups"] = groups
|
||||
|
||||
if send_feature_flags:
|
||||
try:
|
||||
feature_variants = self._get_active_feature_variants(distinct_id, groups)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
|
||||
else:
|
||||
for feature, variant in feature_variants.items():
|
||||
msg["properties"]["$feature/{}".format(feature)] = variant
|
||||
msg["properties"]["$active_feature_flags"] = list(feature_variants.keys())
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -156,12 +213,12 @@ class Client(object):
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$set",
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -173,14 +230,12 @@ class Client(object):
|
||||
"distinct_id": distinct_id,
|
||||
"$set_once": properties,
|
||||
"event": "$set_once",
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def group_identify(
|
||||
self, group_type=None, group_key=None, properties=None, context=None, timestamp=None, message_id=None
|
||||
):
|
||||
def group_identify(self, group_type=None, group_key=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("group_type", group_type, ID_TYPES)
|
||||
@@ -197,12 +252,12 @@ class Client(object):
|
||||
"distinct_id": "${}_{}".format(group_type, group_key),
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, message_id=None):
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None):
|
||||
context = context or {}
|
||||
|
||||
require("previous_id", previous_id, ID_TYPES)
|
||||
@@ -221,7 +276,7 @@ class Client(object):
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def page(self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def page(self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
|
||||
@@ -237,7 +292,7 @@ class Client(object):
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
@@ -247,9 +302,6 @@ class Client(object):
|
||||
timestamp = msg["timestamp"]
|
||||
if timestamp is None:
|
||||
timestamp = datetime.utcnow().replace(tzinfo=tzutc())
|
||||
message_id = msg.get("messageId")
|
||||
if message_id is None:
|
||||
message_id = uuid4()
|
||||
|
||||
require("timestamp", timestamp, datetime)
|
||||
require("context", msg["context"], dict)
|
||||
@@ -257,7 +309,13 @@ class Client(object):
|
||||
# add common
|
||||
timestamp = guess_timezone(timestamp)
|
||||
msg["timestamp"] = timestamp.isoformat()
|
||||
msg["messageId"] = stringify_id(message_id)
|
||||
|
||||
# only send if "uuid" is truthy
|
||||
if "uuid" in msg:
|
||||
uuid = msg.pop("uuid")
|
||||
if uuid:
|
||||
msg["uuid"] = stringify_id(uuid)
|
||||
|
||||
if not msg.get("properties"):
|
||||
msg["properties"] = {}
|
||||
msg["properties"]["$lib"] = "posthog-python"
|
||||
@@ -316,18 +374,34 @@ class Client(object):
|
||||
|
||||
def _load_feature_flags(self):
|
||||
try:
|
||||
flags = get(self.personal_api_key, f"/api/feature_flag/?token={self.api_key}", self.host)["results"]
|
||||
self.feature_flags = [flag for flag in flags if flag["active"]]
|
||||
response = get(
|
||||
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:
|
||||
raise APIError(status=e.status, message=e.message)
|
||||
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds."
|
||||
@@ -348,55 +422,244 @@ class Client(object):
|
||||
self.poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
|
||||
self.poller.start()
|
||||
|
||||
def feature_enabled(self, key, distinct_id, default=False):
|
||||
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")
|
||||
|
||||
if not feature_flag.get("active"):
|
||||
return False
|
||||
|
||||
flag_filters = feature_flag.get("filters") or {}
|
||||
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
|
||||
if aggregation_group_type_index is not None:
|
||||
group_name = self.group_type_mapping.get(str(aggregation_group_type_index))
|
||||
|
||||
if not group_name:
|
||||
self.log.warning(
|
||||
f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}"
|
||||
)
|
||||
# failover to `/decide/`
|
||||
raise InconclusiveMatchError("Flag has unknown group type index")
|
||||
|
||||
if group_name not in groups:
|
||||
# Group flags are never enabled in `groups` aren't passed in
|
||||
# don't failover to `/decide/`, since response will be the same
|
||||
self.log.warning(
|
||||
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
|
||||
)
|
||||
return False
|
||||
|
||||
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, self.cohorts)
|
||||
|
||||
def feature_enabled(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
):
|
||||
response = self.get_feature_flag(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
)
|
||||
|
||||
if response is None:
|
||||
return None
|
||||
return bool(response)
|
||||
|
||||
def get_feature_flag(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
):
|
||||
require("key", key, string_types)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if not self.personal_api_key:
|
||||
self.log.warning("[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")
|
||||
if not self.feature_flags:
|
||||
if self.feature_flags is None and self.personal_api_key:
|
||||
self.load_feature_flags()
|
||||
response = None
|
||||
|
||||
# If loading in previous line failed
|
||||
if not self.feature_flags:
|
||||
response = default
|
||||
else:
|
||||
if self.feature_flags:
|
||||
for flag in self.feature_flags:
|
||||
if flag["key"] == key:
|
||||
feature_flag = flag
|
||||
break
|
||||
else:
|
||||
return default
|
||||
try:
|
||||
response = self._compute_flag_locally(
|
||||
flag,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
)
|
||||
self.log.debug(f"Successfully computed flag locally: {key} -> {response}")
|
||||
except InconclusiveMatchError as e:
|
||||
self.log.debug(f"Failed to compute flag {key} locally: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
|
||||
continue
|
||||
|
||||
if feature_flag.get("is_simple_flag"):
|
||||
response = _hash(key, distinct_id) <= ((feature_flag.get("rollout_percentage", 100) or 100) / 100)
|
||||
else:
|
||||
try:
|
||||
request_data = {
|
||||
"distinct_id": distinct_id,
|
||||
"personal_api_key": self.personal_api_key,
|
||||
}
|
||||
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
|
||||
response = key in resp_data["featureFlags"]
|
||||
except Exception as e:
|
||||
response = default
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] Unable to get data for flag %s, because of the following error:" % key
|
||||
)
|
||||
self.log.warning(e)
|
||||
flag_was_locally_evaluated = response is not None
|
||||
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
|
||||
)
|
||||
response = feature_flags.get(key)
|
||||
if response is None:
|
||||
response = False
|
||||
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
||||
|
||||
self.capture(distinct_id, "$feature_flag_called", {"$feature_flag": key, "$feature_flag_response": response})
|
||||
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 # noqa: W503
|
||||
):
|
||||
self.capture(
|
||||
distinct_id,
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": key,
|
||||
"$feature_flag_response": response,
|
||||
"locally_evaluated": flag_was_locally_evaluated,
|
||||
},
|
||||
groups=groups,
|
||||
)
|
||||
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
|
||||
return response
|
||||
|
||||
def get_feature_flag_payload(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
match_value=None,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
):
|
||||
if match_value is None:
|
||||
match_value = self.get_feature_flag(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
|
||||
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
||||
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
||||
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
||||
# we can do _hash(key, distinct_id) < 0.2
|
||||
def _hash(key, distinct_id):
|
||||
hash_key = "%s.%s" % (key, distinct_id)
|
||||
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
|
||||
return hash_val / __LONG_SCALE__
|
||||
response = None
|
||||
|
||||
if match_value is not None:
|
||||
response = self._compute_payload_locally(key, match_value)
|
||||
|
||||
if response is None and not only_evaluate_locally:
|
||||
decide_payloads = self.get_feature_payloads(distinct_id, groups, person_properties, group_properties)
|
||||
response = decide_payloads.get(str(key).lower(), None)
|
||||
|
||||
return response
|
||||
|
||||
def _compute_payload_locally(self, key, match_value):
|
||||
payload = None
|
||||
|
||||
if self.feature_flags_by_key is None:
|
||||
return payload
|
||||
|
||||
flag_definition = self.feature_flags_by_key.get(key) or {}
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
payload = flag_payloads.get(str(match_value).lower(), None)
|
||||
return payload
|
||||
|
||||
def get_all_flags(
|
||||
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, only_evaluate_locally=False
|
||||
):
|
||||
flags = self.get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
)
|
||||
return flags["featureFlags"]
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, only_evaluate_locally=False
|
||||
):
|
||||
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
|
||||
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
|
||||
)
|
||||
response = {"featureFlags": flags, "featureFlagPayloads": payloads}
|
||||
|
||||
if fallback_to_decide and not only_evaluate_locally:
|
||||
try:
|
||||
flags_and_payloads = self.get_decide(
|
||||
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
|
||||
)
|
||||
response = flags_and_payloads
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||||
|
||||
return response
|
||||
|
||||
def _get_all_flags_and_payloads_locally(self, distinct_id, *, groups={}, person_properties={}, group_properties={}):
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if self.feature_flags is None and self.personal_api_key:
|
||||
self.load_feature_flags()
|
||||
|
||||
flags = {}
|
||||
payloads = {}
|
||||
fallback_to_decide = False
|
||||
# If loading in previous line failed
|
||||
if self.feature_flags:
|
||||
for flag in self.feature_flags:
|
||||
try:
|
||||
flags[flag["key"]] = self._compute_flag_locally(
|
||||
flag,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
)
|
||||
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
|
||||
if matched_payload:
|
||||
payloads[flag["key"]] = matched_payload
|
||||
except InconclusiveMatchError:
|
||||
# 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 and payload: {e}")
|
||||
fallback_to_decide = True
|
||||
else:
|
||||
fallback_to_decide = True
|
||||
|
||||
return flags, payloads, fallback_to_decide
|
||||
|
||||
|
||||
def require(name, field, data_type):
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from dateutil import parser
|
||||
|
||||
from posthog.utils import convert_to_datetime_aware, is_valid_regex
|
||||
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
|
||||
class InconclusiveMatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
||||
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
||||
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
||||
# we can do _hash(key, distinct_id) < 0.2
|
||||
def _hash(key, distinct_id, salt=""):
|
||||
hash_key = f"{key}.{distinct_id}{salt}"
|
||||
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
|
||||
return hash_val / __LONG_SCALE__
|
||||
|
||||
|
||||
def get_matching_variant(flag, distinct_id):
|
||||
hash_value = _hash(flag["key"], distinct_id, salt="variant")
|
||||
for variant in variant_lookup_table(flag):
|
||||
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
|
||||
return variant["key"]
|
||||
return None
|
||||
|
||||
|
||||
def variant_lookup_table(feature_flag):
|
||||
lookup_table = []
|
||||
value_min = 0
|
||||
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
for variant in multivariates:
|
||||
value_max = value_min + variant["rollout_percentage"] / 100
|
||||
lookup_table.append({"value_min": value_min, "value_max": value_max, "key": variant["key"]})
|
||||
value_min = value_max
|
||||
return lookup_table
|
||||
|
||||
|
||||
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.
|
||||
sorted_flag_conditions = sorted(
|
||||
flag_conditions,
|
||||
key=lambda condition: 0 if condition.get("variant") else 1,
|
||||
)
|
||||
|
||||
for condition in sorted_flag_conditions:
|
||||
try:
|
||||
# if any one condition resolves to True, we can shortcircuit and return
|
||||
# the matching variant
|
||||
if is_condition_match(flag, distinct_id, condition, properties, 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 []
|
||||
if variant_override and variant_override in [variant["key"] for variant in flag_variants]:
|
||||
variant = variant_override
|
||||
else:
|
||||
variant = get_matching_variant(flag, distinct_id)
|
||||
return variant or True
|
||||
except InconclusiveMatchError:
|
||||
is_inconclusive = True
|
||||
|
||||
if is_inconclusive:
|
||||
raise InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
|
||||
|
||||
# We can only return False when either all conditions are False, or
|
||||
# no condition was inconclusive.
|
||||
return False
|
||||
|
||||
|
||||
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:
|
||||
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):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def match_property(property, property_values) -> bool:
|
||||
# only looks for matches where key exists in override_property_values
|
||||
# doesn't support operator is_not_set
|
||||
key = property.get("key")
|
||||
operator = property.get("operator") or "exact"
|
||||
value = property.get("value")
|
||||
|
||||
if key not in property_values:
|
||||
raise InconclusiveMatchError("can't match properties without a given property value")
|
||||
|
||||
if operator == "is_not_set":
|
||||
raise InconclusiveMatchError("can't match properties with operator is_not_set")
|
||||
|
||||
override_value = property_values[key]
|
||||
|
||||
if operator == "exact":
|
||||
if isinstance(value, list):
|
||||
return override_value in value
|
||||
return value == override_value
|
||||
|
||||
if operator == "is_not":
|
||||
if isinstance(value, list):
|
||||
return override_value not in value
|
||||
return value != override_value
|
||||
|
||||
if operator == "is_set":
|
||||
return key in property_values
|
||||
|
||||
if operator == "icontains":
|
||||
return str(value).lower() in str(override_value).lower()
|
||||
|
||||
if operator == "not_icontains":
|
||||
return str(value).lower() not in str(override_value).lower()
|
||||
|
||||
if operator == "regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
|
||||
|
||||
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 == "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"]:
|
||||
try:
|
||||
parsed_date = parser.parse(value)
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception:
|
||||
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":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
elif isinstance(override_value, datetime.date):
|
||||
if operator == "is_date_before":
|
||||
return override_value < parsed_date.date()
|
||||
else:
|
||||
return override_value > parsed_date.date()
|
||||
elif isinstance(override_value, str):
|
||||
try:
|
||||
override_date = parser.parse(override_value)
|
||||
override_date = convert_to_datetime_aware(override_date)
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
except Exception:
|
||||
raise InconclusiveMatchError("The date provided is not a valid format")
|
||||
else:
|
||||
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"
|
||||
+2
-7
@@ -50,11 +50,6 @@ def _process_response(
|
||||
res: requests.Response, success_message: str, *, return_json: bool = True
|
||||
) -> Union[requests.Response, Any]:
|
||||
log = logging.getLogger("posthog")
|
||||
if not res:
|
||||
raise APIError(
|
||||
"N/A",
|
||||
"Error when fetching PostHog API, please make sure you are using your public project token/key and not a private API key.",
|
||||
)
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
return res.json() if return_json else res
|
||||
@@ -62,13 +57,13 @@ def _process_response(
|
||||
payload = res.json()
|
||||
log.debug("received response: %s", payload)
|
||||
raise APIError(res.status_code, payload["detail"])
|
||||
except ValueError:
|
||||
except (KeyError, ValueError):
|
||||
raise APIError(res.status_code, res.text)
|
||||
|
||||
|
||||
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/", 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
|
||||
|
||||
|
||||
|
||||
@@ -2,22 +2,23 @@ from sentry_sdk._types import MYPY
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.integrations import Integration
|
||||
from sentry_sdk.scope import add_global_event_processor
|
||||
from sentry_sdk.utils import Dsn
|
||||
|
||||
import posthog
|
||||
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):
|
||||
identifier = "posthog-python"
|
||||
organization = None # The Sentry organization, used to send a direct link from PostHog to Sentry
|
||||
project_id = None # The Sentry project id, used to send a direct link from PostHog to Sentry
|
||||
prefix = "https://sentry.io/organizations/" # Url of a self-hosted sentry instance (default: https://sentry.io/organizations/)
|
||||
prefix = "https://sentry.io/organizations/" # URL of a hosted sentry instance (default: https://sentry.io/organizations/)
|
||||
|
||||
@staticmethod
|
||||
def setup_once():
|
||||
@@ -37,10 +38,14 @@ class PostHogIntegration(Integration):
|
||||
"$sentry_exception": event["exception"],
|
||||
}
|
||||
|
||||
if PostHogIntegration.organization and PostHogIntegration.project_id:
|
||||
properties[
|
||||
"$sentry_url"
|
||||
] = f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={PostHogIntegration.project_id}&query={event['event_id']}"
|
||||
if PostHogIntegration.organization:
|
||||
project_id = PostHogIntegration.project_id or (
|
||||
not not Hub.current.client.dsn and Dsn(Hub.current.client.dsn).project_id
|
||||
)
|
||||
if project_id:
|
||||
properties[
|
||||
"$sentry_url"
|
||||
] = f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
|
||||
|
||||
posthog.capture(posthog_distinct_id, "$exception", properties)
|
||||
|
||||
|
||||
+137
-127
@@ -1,27 +1,38 @@
|
||||
import time
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import mock
|
||||
import six
|
||||
from freezegun import freeze_time
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.version import VERSION
|
||||
|
||||
|
||||
class TestClient(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
|
||||
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
|
||||
cls.client_post_patcher.start()
|
||||
cls.consumer_post_patcher.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client_post_patcher.stop()
|
||||
cls.consumer_post_patcher.stop()
|
||||
|
||||
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):
|
||||
self.failed = False
|
||||
self.client = Client(TEST_API_KEY, on_error=self.set_fail)
|
||||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
def test_requires_api_key(self):
|
||||
self.assertRaises(AssertionError, Client)
|
||||
@@ -38,14 +49,28 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertTrue(isinstance(msg["messageId"], 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)
|
||||
|
||||
def test_basic_capture_with_uuid(self):
|
||||
client = self.client
|
||||
uuid = str(uuid4())
|
||||
success, msg = client.capture("distinct_id", "python test event", uuid=uuid)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["uuid"], uuid)
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
|
||||
def test_basic_capture_with_project_api_key(self):
|
||||
|
||||
client = Client(project_api_key=TEST_API_KEY, on_error=self.set_fail)
|
||||
client = Client(project_api_key=FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
@@ -54,11 +79,87 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertTrue(isinstance(msg["messageId"], 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)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_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)
|
||||
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertEqual(msg["properties"]["$feature/beta-feature"], "random-variant")
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature"])
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_active_feature_flags(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
variants = client._get_active_feature_variants("some_id", None, None, None)
|
||||
self.assertEqual(variants, {"beta-feature": "random-variant", "alpha-feature": True})
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertEqual(msg["properties"]["$feature/beta-feature"], "random-variant")
|
||||
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
|
||||
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=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.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue("$feature/beta-feature" not in msg["properties"])
|
||||
self.assertTrue("$active_feature_flags" not in msg["properties"])
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
|
||||
def test_stringifies_distinct_id(self):
|
||||
# A large number that loses precision in node:
|
||||
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
|
||||
@@ -78,7 +179,7 @@ class TestClient(unittest.TestCase):
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"messageId",
|
||||
"new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -89,7 +190,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertEqual(msg["messageId"], "messageId")
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertTrue("$groups" not in msg["properties"])
|
||||
|
||||
@@ -112,13 +213,13 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertTrue(isinstance(msg["messageId"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_advanced_identify(self):
|
||||
client = self.client
|
||||
success, msg = client.identify(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "messageId"
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -129,7 +230,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["messageId"], "messageId")
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_basic_set(self):
|
||||
@@ -141,13 +242,13 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertTrue(isinstance(msg["messageId"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_advanced_set(self):
|
||||
client = self.client
|
||||
success, msg = client.set(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "messageId"
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -158,7 +259,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["messageId"], "messageId")
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_basic_set_once(self):
|
||||
@@ -170,13 +271,13 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(msg["$set_once"]["trait"], "value")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertTrue(isinstance(msg["messageId"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_advanced_set_once(self):
|
||||
client = self.client
|
||||
success, msg = client.set_once(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "messageId"
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -187,7 +288,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["messageId"], "messageId")
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_basic_group_identify(self):
|
||||
@@ -207,11 +308,11 @@ class TestClient(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertTrue(isinstance(msg["messageId"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
|
||||
def test_advanced_group_identify(self):
|
||||
success, msg = self.client.group_identify(
|
||||
"organization", "id:5", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "messageId"
|
||||
"organization", "id:5", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -266,7 +367,7 @@ class TestClient(unittest.TestCase):
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"messageId",
|
||||
"new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -278,7 +379,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["messageId"], "messageId")
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_flush(self):
|
||||
@@ -306,7 +407,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertFalse(consumer.is_alive())
|
||||
|
||||
def test_synchronous(self):
|
||||
client = Client(TEST_API_KEY, sync_mode=True)
|
||||
client = Client(FAKE_TEST_API_KEY, sync_mode=True)
|
||||
|
||||
success, message = client.identify("distinct_id")
|
||||
self.assertFalse(client.consumers)
|
||||
@@ -314,7 +415,7 @@ class TestClient(unittest.TestCase):
|
||||
self.assertTrue(success)
|
||||
|
||||
def test_overflow(self):
|
||||
client = Client(TEST_API_KEY, max_queue_size=1)
|
||||
client = Client(FAKE_TEST_API_KEY, max_queue_size=1)
|
||||
# Ensure consumer thread is no longer uploading
|
||||
client.join()
|
||||
|
||||
@@ -337,17 +438,17 @@ class TestClient(unittest.TestCase):
|
||||
Client("bad_key", debug=True)
|
||||
|
||||
def test_gzip(self):
|
||||
client = Client(TEST_API_KEY, on_error=self.fail, gzip=True)
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.fail, gzip=True)
|
||||
for _ in range(10):
|
||||
client.identify("distinct_id", {"trait": "value"})
|
||||
client.flush()
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
def test_user_defined_flush_at(self):
|
||||
client = Client(TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3)
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3)
|
||||
|
||||
def mock_post_fn(*args, **kwargs):
|
||||
self.assertEquals(len(kwargs["batch"]), 10)
|
||||
self.assertEqual(len(kwargs["batch"]), 10)
|
||||
|
||||
# the post function should be called 2 times, with a batch size of 10
|
||||
# each time.
|
||||
@@ -355,108 +456,17 @@ class TestClient(unittest.TestCase):
|
||||
for _ in range(20):
|
||||
client.identify("distinct_id", {"trait": "value"})
|
||||
time.sleep(1)
|
||||
self.assertEquals(mock_post.call_count, 2)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
|
||||
def test_user_defined_timeout(self):
|
||||
client = Client(TEST_API_KEY, timeout=10)
|
||||
client = Client(FAKE_TEST_API_KEY, timeout=10)
|
||||
for consumer in client.consumers:
|
||||
self.assertEquals(consumer.timeout, 10)
|
||||
self.assertEqual(consumer.timeout, 10)
|
||||
|
||||
def test_default_timeout_15(self):
|
||||
client = Client(TEST_API_KEY)
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
for consumer in client.consumers:
|
||||
self.assertEquals(consumer.timeout, 15)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags(self, patch_get, patch_poll):
|
||||
patch_get.return_value = {
|
||||
"results": [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True},
|
||||
{"id": 2, "name": "Alpha Feature", "key": "alpha-feature", "active": False},
|
||||
]
|
||||
}
|
||||
client = Client(TEST_API_KEY, personal_api_key="test")
|
||||
with freeze_time("2020-01-01T12:01:00.0000Z"):
|
||||
client.load_feature_flags()
|
||||
self.assertEqual(len(client.feature_flags), 1)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||||
self.assertEqual(client._last_feature_flag_poll.isoformat(), "2020-01-01T12:01:00+00:00")
|
||||
self.assertEqual(patch_poll.call_count, 1)
|
||||
|
||||
def test_load_feature_flags_wrong_key(self):
|
||||
client = Client(TEST_API_KEY, personal_api_key=TEST_API_KEY)
|
||||
with freeze_time("2020-01-01T12:01:00.0000Z"):
|
||||
self.assertRaises(APIError, client.load_feature_flags)
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple(self, patch_get):
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "is_simple_flag": True, "rollout_percentage": 100}
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple_with_project_api_key(self, patch_get):
|
||||
client = Client(project_api_key=TEST_API_KEY, on_error=self.set_fail)
|
||||
client.feature_flags = [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "is_simple_flag": True, "rollout_percentage": 100}
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_enabled_request(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": ["beta-feature"]}
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "is_simple_flag": False, "rollout_percentage": 100}
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple_without_rollout_percentage(self, patch_get):
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = [{"id": 1, "name": "Beta Feature", "key": "beta-feature", "is_simple_flag": True}]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_simple_with_none_rollout_percentage(self, patch_get):
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "is_simple_flag": True, "rollout_percantage": None}
|
||||
]
|
||||
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_enabled_doesnt_exist(self, patch_get, patch_poll):
|
||||
client = Client(TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = []
|
||||
|
||||
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||||
self.assertTrue(client.feature_enabled("doesnt-exist", "distinct_id", True))
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_personal_api_key_doesnt_exist(self, patch_get, patch_poll):
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = []
|
||||
|
||||
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||||
self.assertTrue(client.feature_enabled("doesnt-exist", "distinct_id", True))
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_error(self, patch_get, patch_poll):
|
||||
def raise_effect():
|
||||
raise Exception("http exception")
|
||||
|
||||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||||
client = Client(TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = []
|
||||
|
||||
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
|
||||
self.assertEqual(consumer.timeout, 15)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
@@ -465,7 +475,7 @@ class TestClient(unittest.TestCase):
|
||||
raise Exception("http exception")
|
||||
|
||||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||||
client = Client(TEST_API_KEY, personal_api_key="test")
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
|
||||
|
||||
self.assertFalse(client.feature_enabled("example", "distinct_id"))
|
||||
|
||||
@@ -161,4 +161,4 @@ class TestConsumer(unittest.TestCase):
|
||||
for _ in range(0, n_msgs + 2):
|
||||
q.put(track)
|
||||
q.join()
|
||||
self.assertEquals(mock_post.call_count, 2)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ from dateutil.tz import tzutc
|
||||
from posthog import utils
|
||||
|
||||
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
|
||||
FAKE_TEST_API_KEY = "random_key"
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
@@ -79,3 +80,22 @@ class TestUtils(unittest.TestCase):
|
||||
def test_remove_slash(self):
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
|
||||
|
||||
|
||||
class TestSizeLimitedDict(unittest.TestCase):
|
||||
def test_size_limited_dict(self):
|
||||
size = 10
|
||||
values = utils.SizeLimitedDict(size, lambda _: -1)
|
||||
|
||||
for i in range(100):
|
||||
values[i] = i
|
||||
|
||||
self.assertEqual(values[i], i)
|
||||
self.assertEqual(len(values), i % size + 1)
|
||||
|
||||
if i % size == 0:
|
||||
# old numbers should've been removed
|
||||
self.assertIsNone(values.get(i - 1))
|
||||
self.assertIsNone(values.get(i - 3))
|
||||
self.assertIsNone(values.get(i - 5))
|
||||
self.assertIsNone(values.get(i - 9))
|
||||
|
||||
+29
-1
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import numbers
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
@@ -87,3 +89,29 @@ def _coerce_unicode(cmplx):
|
||||
log.warning("Error decoding: %s", item)
|
||||
return None
|
||||
return item
|
||||
|
||||
|
||||
def is_valid_regex(value) -> bool:
|
||||
try:
|
||||
re.compile(value)
|
||||
return True
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
class SizeLimitedDict(defaultdict):
|
||||
def __init__(self, max_size, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.max_size = max_size
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if len(self) >= self.max_size:
|
||||
self.clear()
|
||||
|
||||
super().__setitem__(key, value)
|
||||
|
||||
|
||||
def convert_to_datetime_aware(date_obj):
|
||||
if date_obj.tzinfo is None:
|
||||
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||
return date_obj
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
VERSION = "1.4.3"
|
||||
VERSION = "2.4.2"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="")
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -44,7 +44,6 @@ posthog.host = "http://127.0.0.1:8000"
|
||||
from posthog.sentry.posthog_integration import PostHogIntegration
|
||||
|
||||
PostHogIntegration.organization = "posthog" # TODO: your sentry organization
|
||||
PostHogIntegration.project_id = "5624115" # TODO: your sentry projectID
|
||||
# PostHogIntegration.prefix = # TODO: your self hosted Sentry url. (default: https://sentry.io/organizations/)
|
||||
|
||||
# Since Sentry doesn't allow Integrations configuration (see https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/__init__.py#L171-L183)
|
||||
|
||||
@@ -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