Compare commits

...
59 Commits
Author SHA1 Message Date
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
Eric DuongandGitHub 1aeffa990f add changelog entry and update versoin (#87) 2023-02-07 10:47:26 -05:00
Eric DuongandGitHub 5ae7feb4e9 chore: Remove upper bound (#85)
* remove upper bound

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

* change implementation

* sort

* format

* raise when debug is true

* Update posthog/test/test_feature_flags.py

* format

---------

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

* format

* v=3 and get_all_payloads

* format

* fix bug

* format

* address comments

* format

* add tests

* format

* add resiliency

* fix lookup

* remove check

* address comments

* example.py

* format

* format
2023-01-31 12:47:32 -05:00
Luke Harries 592c0f362e Revert "added regression tests"
This reverts commit c7fc5a83b4.
2023-01-24 17:31:30 +00:00
Luke Harries c7fc5a83b4 added regression tests 2023-01-24 17:11:37 +00:00
Neil KakkarandGitHub ae8817b611 feat(flags): Add support for variant overrides (#77) 2022-11-14 12:15:33 +00:00
Neil KakkarandGitHub acad2b142e fix: datetime comparison issues (#75) 2022-09-15 15:26:38 +01:00
Neil KakkarandGitHub cb62570e69 Update CHANGELOG.md 2022-09-14 14:22:28 +01:00
Eric DuongandGitHub 33645ecd3c feat: add date comparison to local evaluation (#74) 2022-09-14 13:53:44 +01:00
Neil KakkarandGitHub 81debcef27 fix: Remove defaults for feature flag calls (#72) 2022-08-12 12:20:54 +01:00
Neil KakkarandGitHub dac06bab18 fix(feature-flags): Add more options to make using library easier (#70) 2022-08-04 13:53:20 +01:00
Neil KakkarandGitHub 2dc1298620 Bump version to 2.0.0 and add breaking changes changelog (#69) 2022-08-02 12:10:32 +01:00
Neil KakkarandGitHub 2c6b675be7 feat(feature-flags): Enable local evaluation of flags (#68) 2022-07-29 14:10:51 +01:00
3a6fd07951 Add get feature flag method (#67)
* get feature flag method

* remove groups param from method

* use personal api key

* add groups

* add capture

* black reformat

* black?

* Update posthog/client.py

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>

* add to init

* formatting

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2022-06-30 11:48:53 -04:00
Neil KakkarandGitHub de0ccd29d3 bump version to 1.4.9 (#66) 2022-06-13 12:18:50 +01:00
addd2e3340 Have an option to send feature variants with the .capture(...) calls (#65)
Co-authored-by: Utku Zihnioglu <utku@webshare.io>
2022-06-13 12:13:35 +01:00
Tim GlaserandGitHub 9d2fa72753 bump version 1.4.8 (#61)
* bump version 1.4.8

* Update CHANGELOG.md
2022-05-12 08:12:11 +01:00
306fb2a1fa Feature: Enable multi variate feature flags for Python library (#60)
* Capturing $feature_flag_called at the end of client.feature_enabled method

* Enabling multi-variants for feature flags

Co-authored-by: Utku Zihnioglu <utku@webshare.io>
Co-authored-by: Tim Glaser <tim@posthog.com>
2022-05-12 08:10:54 +01:00
faffd1f88a Capturing $feature_flag_called at the end of client.feature_enabled method (#57)
Co-authored-by: Utku Zihnioglu <utku@webshare.io>
2022-05-12 08:05:26 +01:00
Tim Glaser ab1399d88f Bump version 1.4.7 2022-04-25 14:08:08 +01:00
Tim GlaserandGitHub 1777b7062e fix: Personal api key not required (#56)
* fix: Personal api key not required

* formatting

* Fix false
2022-04-25 14:07:08 +01:00
Tim GlaserandGitHub 565bb8a0eb docs:Remove id: from example (#55) 2022-04-11 16:01:27 +02:00
Marius Andra 009cac8634 1.4.6 2022-03-30 08:45:14 +02:00
Marius AndraandGitHub ec2425996c Support custom UUID values (#53)
* feat(uuid): add support for custom uuids

* fix deprecated assert

* do not send "none" uuid

* you can have it in any color you want, as long as it's black
2022-03-30 08:42:43 +02:00
Paolo D'AmicoandGitHub 90fa0a0604 Update version.py (#49) 2022-01-05 06:46:28 -06:00
Paolo D'AmicoandGitHub a97fe0a40a Use Sentry DSN to obtain project ID (#48) 2022-01-05 06:15:51 -06:00
Karl-Aksel PuulmannandGitHub a474fcff93 Update version to 1.4.4 2021-11-25 11:45:18 +02:00
Karl-Aksel PuulmannandGitHub a181ba718f Groups: Feature flags support (#45)
* Fix a documentation typo

* Feature flags & groups support

* Update examples
2021-11-25 09:55:40 +02:00
Karl-Aksel PuulmannandGitHub b996f3a4e9 Update version to 1.4.3 2021-10-28 18:42:58 +03:00
Karl-Aksel PuulmannandGitHub 6c945a0624 Basic group analytics support (#44)
* Make setup instructions work

* Add basic group analytics support to library

* Resolve formatting issues
2021-10-27 10:25:33 +03:00
Michael Matloka ab8ccb4dff Update pip install 2021-06-22 14:41:11 +02:00
Michael MatlokaandGitHub 870f6f8b6b Update README.md 2021-06-22 14:39:02 +02:00
Michael MatlokaandGitHub 9e0aeaefe6 Update README.md 2021-06-22 14:38:44 +02:00
Michael MatlokaandGitHub a8409960b9 Bump version to 1.4.2 2021-06-22 14:38:15 +02:00
Michael Matloka deb078293a Use PyPI API token instead of username and password 2021-06-22 14:30:53 +02:00
Michael Matloka edb8b7891e Ensure wheel 2021-06-22 14:24:27 +02:00
Michael Matloka 727bdb2b1e Fix make target in release workflow 2021-06-22 14:21:01 +02:00
Michael MatlokaandGitHub 0781a1280e Add release workflow (#42)
* Add release workflow

* Update README.md
2021-06-22 14:19:31 +02:00
Yakko MajuriandGitHub 8b2ed8bb12 Fix integrity issues (#41)
* Fix integrity issues

* fix black, tests

* add test for new behavior

* run black properly

* remove accidental commit
2021-06-16 11:06:14 -03:00
Yakko MajuriandGitHub a139795a74 Better handling on feature_enabled (#40) 2021-06-07 11:39:17 +01:00
Mandeep GillandGitHub 11f1d06761 Remove pinning of backoff dependency (#38) 2021-06-04 14:26:36 -03:00
Neil Kakkar fd321566ed Release 1.4.1 2021-05-28 10:19:56 +01:00
Mandeep GillandGitHub 83737f2477 Bump backoff dependency to 1.10.0 (#36) 2021-05-26 19:02:15 +02:00
Michael MatlokaandGitHub cc5649368f Fix 1.4.0 date in CHANGELOG.md 2021-05-20 15:44:53 +02:00
Michael MatlokaandGitHub 7fe5045da1 Update CHANGELOG.md 2021-05-18 17:21:59 +02:00
Michael MatlokaandGitHub 2c9ad238ec Add PyPI badge to README 2021-05-18 17:20:36 +02:00
21 changed files with 5738 additions and 267 deletions
+4
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: |
+38
View File
@@ -0,0 +1,38 @@
name: 'Release'
on:
- workflow_dispatch
jobs:
release:
name: Publish release
runs-on: ubuntu-20.04
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v2
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Prepare for building release
run: pip install -U pip setuptools wheel twine
- name: Push release to PyPI
run: make release && make release_analytics
- name: Create GitHub release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
+113 -7
View File
@@ -1,15 +1,121 @@
## 1.4.0 - 2021-05-07
## 3.0.1 - 2023-04-21
- Add support for project_api_key (#32)
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
2. Add a `feature_flag_definitions()` debug option, which returns currently loaded feature flag definitions. You can use this to more cleverly decide when to request local evaluation of feature flags.
## 3.0.0 - 2023-04-14
Breaking change:
All events by default now send the `$geoip_disable` property to disable geoip lookup in app. This is because usually we don't
want to update person properties to take the server's location.
The same now happens for feature flag requests, where we discard the IP address of the server for matching on geoip properties like city, country, continent.
To restore previous behaviour, you can set the default to False like so:
```python
posthog.disable_geoip = False
# // and if using client instantiation:
posthog = Posthog('api_key', disable_geoip=False)
```
## 2.5.0 - 2023-04-10
1. Add option for instantiating separate client object
## 2.4.2 - 2023-03-30
1. Update backoff dependency for posthoganalytics package to be the same as posthog package
## 2.4.1 - 2023-03-17
1. Removes accidental print call left in for decide response
## 2.4.0 - 2023-03-14
1. Support evaluating all cohorts in feature flags for local evaluation
## 2.3.1 - 2023-02-07
1. Log instead of raise error on posthog personal api key errors
2. Remove upper bound on backoff dependency
## 2.3.0 - 2023-01-31
1. Add support for returning payloads of matched feature flags
## 2.2.0 - 2022-11-14
Changes:
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
## 1.4.0 - 2021-05-18
- Improve support for `project_api_key` (#32)
- Resolve polling issues with feature flags (#29)
- Add python sentry (and sentry & django) integrations (#13)
- Fix feature flag issue with no % rollout (#30)
- Add Sentry (and Sentry+Django) integrations (#13)
- Fix feature flag issue with no percentage rollout (#30)
## 1.3.1 - 2021-05-07
- Add $set and $set_once support (#23)
- Add distinct id to $create_alias event (#27)
- Add UUID to ID_TYPES (#26)
- Add `$set` and `$set_once` support (#23)
- Add distinct ID to `$create_alias` event (#27)
- Add `UUID` to `ID_TYPES` (#26)
## 1.2.1 - 2021-02-05
+15 -9
View File
@@ -1,27 +1,25 @@
# PostHog Python
Please see the main [PostHog docs](https://posthog.com/docs).
[![PyPI](https://img.shields.io/pypi/v/posthog)](https://pypi.org/project/posthog/)
Specifically, the [Python integration](https://posthog.com/docs/integrations/python-integration) details.
## Questions?
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
## Development
# Local Development
## Testing Locally
### Testing Locally
1. Run `python3 -m venv env` (creates virtual environment called "env")
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
### Running Locally
Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action.
## Running the Django Sentry Integration Locally
### Running the Django Sentry Integration Locally
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
@@ -38,3 +36,11 @@ There's 2 places of importance (Changes required are all marked with TODO in the
To run things: `make django_example`. This installs the posthog-python library with the sentry-sdk add-on, and then runs the django app.
Also start the PostHog app locally.
Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an event in both Sentry and PostHog, with links to each other.
### Releasing Versions
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?
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
+63 -9
View File
@@ -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()
+226 -25
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
@@ -15,6 +16,8 @@ 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
disable_geoip = True # type: bool
default_client = None
@@ -25,7 +28,10 @@ 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,
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
"""
@@ -38,10 +44,14 @@ def capture(
Optionally you can submit
- `properties`, which can be a dict with any information you'd like to add
- `groups`, which is a dict of group type -> group key mappings
For example:
```python
posthog.capture('distinct id', 'opened app')
posthog.capture('distinct id', 'movie played', {'movie_id': '123', 'category': 'romcom'})
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
```
"""
_proxy(
@@ -51,16 +61,20 @@ def capture(
properties=properties,
context=context,
timestamp=timestamp,
message_id=message_id,
uuid=uuid,
groups=groups,
send_feature_flags=send_feature_flags,
disable_geoip=disable_geoip,
)
def identify(
distinct_id, # type: str,
distinct_id, # type: str
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]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
"""
@@ -84,16 +98,18 @@ def identify(
properties=properties,
context=context,
timestamp=timestamp,
message_id=message_id,
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]
message_id=None, # type: Optional[str]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
"""
@@ -117,16 +133,18 @@ def set(
properties=properties,
context=context,
timestamp=timestamp,
message_id=message_id,
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]
message_id=None, # type: Optional[str]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
"""
@@ -150,21 +168,55 @@ def set_once(
properties=properties,
context=context,
timestamp=timestamp,
message_id=message_id,
uuid=uuid,
disable_geoip=disable_geoip,
)
def group(*args, **kwargs):
"""Send a group call."""
_proxy("group", *args, **kwargs)
def group_identify(
group_type, # type: str
group_key, # 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
"""
Set properties on a group
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
For example:
```python
posthog.group_identify('company', 5, {
'employees': 11,
})
```
"""
_proxy(
"group_identify",
group_type=group_type,
group_key=group_key,
properties=properties,
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]
message_id=None, # type: Optional[str]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
"""
@@ -189,14 +241,20 @@ def alias(
distinct_id=distinct_id,
context=context,
timestamp=timestamp,
message_id=message_id,
uuid=uuid,
disable_geoip=disable_geoip,
)
def feature_enabled(
key, # type: str,
distinct_id, # type: str,
default=False, # type: bool
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
"""
@@ -206,11 +264,145 @@ 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,
disable_geoip=disable_geoip,
)
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
disable_geoip=None, # type: Optional[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,
disable_geoip=disable_geoip,
)
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
disable_geoip=None, # type: Optional[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,
disable_geoip=disable_geoip,
)
def get_feature_flag_payload(
key,
distinct_id,
match_value=None,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
):
return _proxy(
"get_feature_flag_payload",
key=key,
distinct_id=distinct_id,
match_value=match_value,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
def get_all_flags_and_payloads(
distinct_id,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
disable_geoip=None, # type: Optional[bool]
):
return _proxy(
"get_all_flags_and_payloads",
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
def feature_flag_definitions():
"""Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded."""
return _proxy("feature_flag_definitions")
def page(*args, **kwargs):
@@ -242,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,
@@ -254,7 +444,18 @@ 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,
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
+448 -71
View File
@@ -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):
@@ -47,8 +47,9 @@ class Client(object):
poll_interval=30,
personal_api_key=None,
project_api_key=None,
disabled=False,
disable_geoip=True,
):
self.queue = queue.Queue(max_queue_size)
# api_key: This should be the Team API Key (token), public
@@ -64,14 +65,24 @@ class Client(object):
self.gzip = gzip
self.timeout = timeout
self.feature_flags = None
self.feature_flags_by_key = None
self.group_type_mapping = None
self.cohorts = None
self.poll_interval = poll_interval
self.poller = None
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
self.disabled = disabled
self.disable_geoip = disable_geoip
# personal_api_key: This should be a generated Personal API Key, private
self.personal_api_key = personal_api_key
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 +114,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, disable_geoip=None):
properties = properties or {}
context = context or {}
require("distinct_id", distinct_id, ID_TYPES)
@@ -115,12 +126,67 @@ class Client(object):
"distinct_id": distinct_id,
"$set": properties,
"event": "$identify",
"messageId": message_id,
"uuid": uuid,
}
return self._enqueue(msg)
return self._enqueue(msg, disable_geoip)
def capture(self, distinct_id=None, event=None, properties=None, context=None, timestamp=None, message_id=None):
def get_feature_variants(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlags"]
def _get_active_feature_variants(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
feature_variants = self.get_feature_variants(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
return {
k: v for (k, v) in feature_variants.items() if v is not False
} # explicitly test for false to account for values that may seem falsy (ex: 0)
def get_feature_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlagPayloads"]
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None):
require("distinct_id", distinct_id, ID_TYPES)
if disable_geoip is None:
disable_geoip = self.disable_geoip
if groups:
require("groups", groups, dict)
else:
groups = {}
request_data = {
"distinct_id": distinct_id,
"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(
self,
distinct_id=None,
event=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
groups=None,
send_feature_flags=False,
disable_geoip=None,
):
properties = properties or {}
context = context or {}
require("distinct_id", distinct_id, ID_TYPES)
@@ -133,12 +199,26 @@ class Client(object):
"context": context,
"distinct_id": distinct_id,
"event": event,
"messageId": message_id,
"uuid": uuid,
}
return self._enqueue(msg)
if groups:
require("groups", groups, dict)
msg["properties"]["$groups"] = groups
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
if send_feature_flags:
try:
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"]["$active_feature_flags"] = list(feature_variants.keys())
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)
@@ -150,12 +230,12 @@ class Client(object):
"distinct_id": distinct_id,
"$set": properties,
"event": "$set",
"messageId": message_id,
"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, message_id=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)
@@ -167,12 +247,43 @@ class Client(object):
"distinct_id": distinct_id,
"$set_once": properties,
"event": "$set_once",
"messageId": message_id,
"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, message_id=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)
require("group_key", group_key, ID_TYPES)
require("properties", properties, dict)
msg = {
"event": "$groupidentify",
"properties": {
"$group_type": group_type,
"$group_key": group_key,
"$group_set": properties,
},
"distinct_id": "${}_{}".format(group_type, group_key),
"timestamp": timestamp,
"context": context,
"uuid": uuid,
}
return self._enqueue(msg, disable_geoip)
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)
@@ -189,9 +300,11 @@ class Client(object):
"distinct_id": previous_id,
}
return self._enqueue(msg)
return self._enqueue(msg, disable_geoip)
def page(self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, message_id=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 {}
@@ -207,19 +320,20 @@ class Client(object):
"timestamp": timestamp,
"context": context,
"distinct_id": distinct_id,
"messageId": message_id,
"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())
message_id = msg.get("messageId")
if message_id is None:
message_id = uuid4()
require("timestamp", timestamp, datetime)
require("context", msg["context"], dict)
@@ -227,12 +341,24 @@ 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"
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)
@@ -286,17 +412,34 @@ class Client(object):
def _load_feature_flags(self):
try:
self.feature_flags = get(self.personal_api_key, "/api/feature_flag/", self.host)["results"]
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."
@@ -317,53 +460,287 @@ 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,
disable_geoip=None,
):
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,
disable_geoip=disable_geoip,
)
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,
disable_geoip=None,
):
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.disabled:
return None
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:
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
flag_was_locally_evaluated = response is not None
if not flag_was_locally_evaluated and not only_evaluate_locally:
try:
feature_flag = [flag for flag in self.feature_flags if flag["key"] == key][0]
except IndexError:
return default
feature_flags = self.get_feature_variants(
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:
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}")
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)
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,
disable_geoip=disable_geoip,
)
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,
disable_geoip=None,
):
if self.disabled:
return None
# 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__
if match_value is None:
match_value = self.get_feature_flag(
key,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
send_feature_flag_events=send_feature_flag_events,
only_evaluate_locally=True,
disable_geoip=disable_geoip,
)
response = None
if match_value is not None:
response = self._compute_payload_locally(key, match_value)
if response is None and not only_evaluate_locally:
decide_payloads = self.get_feature_payloads(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
response = decide_payloads.get(str(key).lower(), None)
return response
def _compute_payload_locally(self, key, match_value):
payload = None
if self.feature_flags_by_key is None:
return payload
flag_definition = self.feature_flags_by_key.get(key) or {}
flag_filters = flag_definition.get("filters") or {}
flag_payloads = flag_filters.get("payloads") or {}
payload = flag_payloads.get(str(match_value).lower(), None)
return payload
def get_all_flags(
self,
distinct_id,
*,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
disable_geoip=None,
):
flags = self.get_all_flags_and_payloads(
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
return flags["featureFlags"]
def get_all_flags_and_payloads(
self,
distinct_id,
*,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
disable_geoip=None,
):
if self.disabled:
return {"featureFlags": None, "featureFlagPayloads": None}
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
response = {"featureFlags": flags, "featureFlagPayloads": payloads}
if fallback_to_decide and not only_evaluate_locally:
try:
flags_and_payloads = self.get_decide(
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
disable_geoip=disable_geoip,
)
response = flags_and_payloads
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
return response
def _get_all_flags_and_payloads_locally(self, distinct_id, *, groups={}, person_properties={}, group_properties={}):
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
if self.feature_flags 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 feature_flag_definitions(self):
return self.feature_flags
def require(name, field, data_type):
+273
View File
@@ -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
View File
@@ -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")
+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
+12 -7
View File
@@ -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)
+354 -106
View File
@@ -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,145 @@ 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, 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"}}
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 +237,7 @@ class TestClient(unittest.TestCase):
{"property": "value"},
{"ip": "192.168.0.1"},
datetime(2014, 9, 3),
"messageId",
"new-uuid",
)
self.assertTrue(success)
@@ -89,8 +248,19 @@ 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"])
def test_groups_capture(self):
success, msg = self.client.capture(
"distinct_id",
"test_event",
groups={"company": "id:5", "instance": "app.posthog.com"},
)
self.assertTrue(success)
self.assertEqual(msg["properties"]["$groups"], {"company": "id:5", "instance": "app.posthog.com"})
def test_basic_identify(self):
client = self.client
@@ -101,13 +271,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)
@@ -118,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_set(self):
@@ -130,13 +300,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)
@@ -147,7 +317,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):
@@ -159,13 +329,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)
@@ -176,9 +346,51 @@ 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):
success, msg = self.client.group_identify("organization", "id:5")
self.assertTrue(success)
self.assertEqual(msg["event"], "$groupidentify")
self.assertEqual(msg["distinct_id"], "$organization_id:5")
self.assertEqual(
msg["properties"],
{
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {},
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
)
self.assertTrue(isinstance(msg["timestamp"], 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), "new-uuid"
)
self.assertTrue(success)
self.assertEqual(msg["event"], "$groupidentify")
self.assertEqual(msg["distinct_id"], "$organization_id:5")
self.assertEqual(
msg["properties"],
{
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {"trait": "value"},
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
)
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
def test_basic_alias(self):
client = self.client
success, msg = client.alias("previousId", "distinct_id")
@@ -215,7 +427,7 @@ class TestClient(unittest.TestCase):
{"property": "value"},
{"ip": "192.168.0.1"},
datetime(2014, 9, 3),
"messageId",
"new-uuid",
)
self.assertTrue(success)
@@ -227,7 +439,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):
@@ -255,7 +467,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)
@@ -263,7 +475,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()
@@ -286,17 +498,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.
@@ -304,102 +516,138 @@ 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)
self.assertEqual(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"}]}
client = Client(TEST_API_KEY, personal_api_key="test")
with freeze_time("2020-01-01T12:01:00.0000Z"):
client.load_feature_flags()
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_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)
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"))
self.assertEqual(msg, "disabled")
@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"))
def test_disabled_with_feature_flags(self, patch_decide):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
@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"))
response = client.get_feature_flag("beta-feature", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
@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"))
response = client.feature_enabled("beta-feature", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
@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 = []
response = client.get_all_flags("12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
self.assertTrue(client.feature_enabled("doesnt-exist", "distinct_id", True))
response = client.get_feature_flag_payload("key", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
@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 = []
response = client.get_all_flags_and_payloads("12345")
self.assertEqual(response, {"featureFlags": None, "featureFlagPayloads": None})
patch_decide.assert_not_called()
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
self.assertTrue(client.feature_enabled("doesnt-exist", "distinct_id", True))
# no capture calls
self.assertTrue(client.queue.empty())
@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")
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()
patch_get.return_value.raiseError.side_effect = raise_effect
client = Client(TEST_API_KEY, personal_api_key="test")
client.feature_flags = []
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg["event"], "python test event")
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
client.disabled = True
success, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertFalse(success)
self.assertFalse(self.failed)
self.assertEqual(msg, "disabled")
def test_disable_geoip_default_on_events(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=True)
_, capture_msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertEqual(capture_msg["properties"]["$geoip_disable"], True)
_, identify_msg = client.identify("distinct_id", {"trait": "value"})
client.flush()
self.assertEqual(identify_msg["properties"]["$geoip_disable"], True)
def test_disable_geoip_override_on_events(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
_, capture_msg = client.set("distinct_id", {"a": "b", "c": "d"}, disable_geoip=True)
client.flush()
self.assertEqual(capture_msg["properties"]["$geoip_disable"], True)
_, identify_msg = client.page("distinct_id", "http://a.com", {"trait": "value"}, disable_geoip=False)
client.flush()
self.assertEqual("$geoip_disable" not in identify_msg["properties"], True)
def test_disable_geoip_method_overrides_init_on_events(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=True)
_, msg = client.capture("distinct_id", "python test event", disable_geoip=False)
client.flush()
self.assertTrue("$geoip_disable" not in msg["properties"])
@mock.patch("posthog.client.decide")
def test_disable_geoip_default_on_decide(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={},
person_properties={},
group_properties={},
disable_geoip=True,
)
patch_decide.reset_mock()
client.feature_enabled("random_key", "feature_enabled_distinct_id", disable_geoip=True)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="feature_enabled_distinct_id",
groups={},
person_properties={},
group_properties={},
disable_geoip=True,
)
patch_decide.reset_mock()
client.get_all_flags_and_payloads("all_flags_payloads_id")
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="all_flags_payloads_id",
groups={},
person_properties={},
group_properties={},
disable_geoip=False,
)
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
@@ -408,7 +656,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"))
+1 -1
View File
@@ -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
+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()
+20
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -1 +1,4 @@
VERSION = "1.4.0"
VERSION = "3.0.1"
if __name__ == "__main__":
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)
+5 -3
View File
@@ -14,15 +14,17 @@ 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"]
extras_require = {
"dev": [
"black",
"isort",
"flake8",
"flake8-print",
"pre-commit",
],
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage"],
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest"],
"sentry": ["sentry-sdk", "django"],
}
@@ -35,7 +37,7 @@ setup(
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthog.test.all",
packages=["posthog", "posthog.test"],
packages=["posthog", "posthog.test", "posthog.sentry"],
license="MIT License",
install_requires=install_requires,
extras_require=extras_require,
+2 -2
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"]
@@ -27,7 +27,7 @@ setup(
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthoganalytics.test.all",
packages=["posthoganalytics", "posthoganalytics.test"],
packages=["posthoganalytics", "posthoganalytics.test", "posthoganalytics.sentry"],
license="MIT License",
install_requires=install_requires,
tests_require=tests_require,