Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc1298620 | ||
|
|
2c6b675be7 | ||
|
|
3a6fd07951 | ||
|
|
de0ccd29d3 | ||
|
|
addd2e3340 | ||
|
|
9d2fa72753 | ||
|
|
306fb2a1fa | ||
|
|
faffd1f88a | ||
|
|
ab1399d88f | ||
|
|
1777b7062e | ||
|
|
565bb8a0eb | ||
|
|
009cac8634 | ||
|
|
ec2425996c | ||
|
|
90fa0a0604 | ||
|
|
a97fe0a40a | ||
|
|
a474fcff93 | ||
|
|
a181ba718f | ||
|
|
b996f3a4e9 | ||
|
|
6c945a0624 | ||
|
|
ab8ccb4dff | ||
|
|
870f6f8b6b | ||
|
|
9e0aeaefe6 | ||
|
|
a8409960b9 | ||
|
|
deb078293a | ||
|
|
edb8b7891e | ||
|
|
727bdb2b1e | ||
|
|
0781a1280e | ||
|
|
8b2ed8bb12 | ||
|
|
a139795a74 | ||
|
|
11f1d06761 | ||
|
|
fd321566ed | ||
|
|
83737f2477 | ||
|
|
cc5649368f | ||
|
|
7fe5045da1 | ||
|
|
2c9ad238ec | ||
|
|
de6e60f12f | ||
|
|
fd92502d99 | ||
|
|
fe6d0dc1ec | ||
|
|
fbde5cafc4 | ||
|
|
2e99081cb1 | ||
|
|
372fb74637 | ||
|
|
ba11548089 | ||
|
|
49d0821e27 | ||
|
|
b4489f1dca | ||
|
|
8040964761 | ||
|
|
4f853403b9 | ||
|
|
ac61fb0e01 | ||
|
|
7196dc6048 | ||
|
|
45303b899e | ||
|
|
7e463ccad6 | ||
|
|
41dec34929 | ||
|
|
0bf9db0108 | ||
|
|
e8308360bb | ||
|
|
15ebe85a78 | ||
|
|
dbc22d2f9f | ||
|
|
f3ee238823 | ||
|
|
71d81b2da9 | ||
|
|
5493029577 | ||
|
|
d66f944571 | ||
|
|
9d620967f8 | ||
|
|
563404f914 | ||
|
|
d15aac41a9 | ||
|
|
8a3e28b949 | ||
|
|
1aa0d6335c | ||
|
|
3b46c60cf1 | ||
|
|
4ad8cbfa58 | ||
|
|
984a679b19 | ||
|
|
dd1bad6175 | ||
|
|
e6f71e4cc3 | ||
|
|
17874cb131 | ||
|
|
8366e09df9 | ||
|
|
e28b237ff1 | ||
|
|
05fde2a51e | ||
|
|
2be04f3b8b | ||
|
|
d52b605742 | ||
|
|
a5f2e030b5 |
@@ -0,0 +1,61 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
name: Code quality checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dev dependencies
|
||||
run: |
|
||||
python -m pip install -e .[dev]
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Check formatting with black
|
||||
run: |
|
||||
black --check .
|
||||
|
||||
- name: Check import order with isort
|
||||
run: |
|
||||
isort --check-only .
|
||||
|
||||
tests:
|
||||
name: Python tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
run: |
|
||||
python -m pip install -e .[test]
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
python setup.py test
|
||||
@@ -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 }}
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Backend CI
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Python tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
run: |
|
||||
python -m pip install -e .
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
python setup.py test
|
||||
+7
-6
@@ -1,16 +1,17 @@
|
||||
**sublime**
|
||||
*.pyc
|
||||
dist
|
||||
dist/
|
||||
*.egg-info
|
||||
dist
|
||||
MANIFEST
|
||||
build
|
||||
.eggs
|
||||
build/
|
||||
.eggs/
|
||||
.coverage
|
||||
.vscode/
|
||||
env
|
||||
env/
|
||||
venv/
|
||||
flake8.out
|
||||
pylint.out
|
||||
posthog-analytics
|
||||
.idea
|
||||
|
||||
.python-version
|
||||
.coverage
|
||||
@@ -0,0 +1,9 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: stable
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.7.0
|
||||
hooks:
|
||||
- id: isort
|
||||
@@ -0,0 +1,43 @@
|
||||
## 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.
|
||||
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 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)
|
||||
|
||||
## 1.2.1 - 2021-02-05
|
||||
|
||||
Initial release logged in CHANGELOG.md.
|
||||
@@ -1,7 +1,10 @@
|
||||
test:
|
||||
lint:
|
||||
pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out
|
||||
flake8 --max-complexity=10 --statistics analytics > flake8.out || true
|
||||
coverage run --branch --include=analytics/\* --omit=*/test* setup.py test
|
||||
|
||||
test:
|
||||
coverage run -m pytest
|
||||
coverage report
|
||||
|
||||
release:
|
||||
rm -rf dist/*
|
||||
@@ -26,4 +29,8 @@ release_analytics:
|
||||
e2e_test:
|
||||
.buildscripts/e2e.sh
|
||||
|
||||
.PHONY: test release e2e_test
|
||||
django_example:
|
||||
python -m pip install -e ".[sentry]"
|
||||
cd sentry_django_example && python manage.py runserver 8080
|
||||
|
||||
.PHONY: test lint release e2e_test
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# PostHog Python
|
||||
|
||||
Please see the main [PostHog docs](https://posthog.com/docs).
|
||||
[](https://pypi.org/project/posthog/)
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
|
||||
|
||||
There's 2 places of importance (Changes required are all marked with TODO in the sample project directory)
|
||||
|
||||
1. Settings.py
|
||||
1. Input your Sentry DSN
|
||||
2. Input your Sentry Org and ProjectID details into `PosthogIntegration()`
|
||||
3. Add `POSTHOG_DJANGO` to settings.py. This allows the `PosthogDistinctIdMiddleware` to get the distinct_ids
|
||||
|
||||
2. urls.py
|
||||
1. This includes the `sentry-debug/` endpoint, which generates an exception
|
||||
|
||||
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`, 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)
|
||||
|
||||
+48
-11
@@ -1,29 +1,66 @@
|
||||
# PostHog Python library example
|
||||
|
||||
# Import the library
|
||||
import posthog
|
||||
import time
|
||||
|
||||
import posthog
|
||||
|
||||
# posthog.debug = True
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.api_key = ''
|
||||
posthog.personal_api_key = ''
|
||||
posthog.project_api_key = ""
|
||||
posthog.personal_api_key = ""
|
||||
|
||||
# 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"
|
||||
|
||||
# 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(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id", groups={"company": "id:5"}))
|
||||
|
||||
print('sleeping')
|
||||
time.sleep(45)
|
||||
print("sleeping")
|
||||
# time.sleep(5)
|
||||
|
||||
print(posthog.feature_enabled('beta-feature', 'distinct_id'))
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
# # Alias a previous distinct id with a new one
|
||||
posthog.alias('distinct_id', 'new_distinct_id')
|
||||
|
||||
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('distinct_id', {'email': 'something@something.com'})
|
||||
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}
|
||||
) # this will not change the property (because it was already set)
|
||||
|
||||
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.get_all_flags("distinct_id_random_22"))
|
||||
|
||||
+266
-51
@@ -1,64 +1,82 @@
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from posthog.version import VERSION
|
||||
from posthog.client import Client
|
||||
from typing import Optional, Dict, Callable
|
||||
from posthog.version import VERSION
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
"""Settings."""
|
||||
api_key = None # type: str
|
||||
host = None # type: str
|
||||
on_error = None # type: Callable
|
||||
debug = False # type: bool
|
||||
send = True # type: bool
|
||||
sync_mode = False # type: bool
|
||||
disabled = False # type: bool
|
||||
personal_api_key = None # type: str
|
||||
api_key = None # type: str
|
||||
host = None # type: str
|
||||
on_error = None # type: Callable
|
||||
debug = False # type: bool
|
||||
send = True # type: bool
|
||||
sync_mode = False # type: bool
|
||||
disabled = False # type: bool
|
||||
personal_api_key = None # type: str
|
||||
project_api_key = None # type: str
|
||||
|
||||
default_client = None
|
||||
|
||||
|
||||
def capture(
|
||||
distinct_id, # type: str,
|
||||
event, # type: str,
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
):
|
||||
distinct_id, # type: str
|
||||
event, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
groups=None, # type: Optional[Dict]
|
||||
send_feature_flags=False,
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up.
|
||||
|
||||
A `capture` call requires
|
||||
- `distinct id` which uniquely identifies your user
|
||||
- `event name` to make sure
|
||||
- `event name` to specify the event
|
||||
- We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on.
|
||||
|
||||
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('capture', distinct_id=distinct_id, event=event, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
|
||||
_proxy(
|
||||
"capture",
|
||||
distinct_id=distinct_id,
|
||||
event=event,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
groups=groups,
|
||||
send_feature_flags=send_feature_flags,
|
||||
)
|
||||
|
||||
|
||||
def identify(
|
||||
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]
|
||||
):
|
||||
distinct_id, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, and even do things like segment users by these properties.
|
||||
|
||||
An `identify` call requires
|
||||
- `distinct id` which uniquely identifies your user
|
||||
- `properties` with a dict with any key: value pairs
|
||||
- `properties` with a dict with any key: value pairs
|
||||
|
||||
For example:
|
||||
```python
|
||||
@@ -68,20 +86,124 @@ def identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy('identify', distinct_id=distinct_id, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
|
||||
_proxy(
|
||||
"identify",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
def group(*args, **kwargs):
|
||||
"""Send a group call."""
|
||||
_proxy('group', *args, **kwargs)
|
||||
|
||||
def set(
|
||||
distinct_id, # type: str,
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
Set properties on a user record.
|
||||
This will overwrite previous people property values, just like `identify`.
|
||||
|
||||
A `set` call requires
|
||||
- `distinct id` which uniquely identifies your user
|
||||
- `properties` with a dict with any key: value pairs
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.set('distinct id', {
|
||||
'current_browser': 'Chrome',
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
"set",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
def set_once(
|
||||
distinct_id, # type: str,
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
Set properties on a user record, only if they do not yet exist.
|
||||
This will not overwrite previous people property values, unlike `identify`.
|
||||
|
||||
A `set_once` call requires
|
||||
- `distinct id` which uniquely identifies your user
|
||||
- `properties` with a dict with any key: value pairs
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.set_once('distinct id', {
|
||||
'referred_by': 'friend',
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
"set_once",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
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]
|
||||
):
|
||||
# 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,
|
||||
)
|
||||
|
||||
|
||||
def alias(
|
||||
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]
|
||||
):
|
||||
previous_id, # type: str,
|
||||
distinct_id, # type: str,
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
"""
|
||||
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?"
|
||||
@@ -99,13 +221,24 @@ def alias(
|
||||
posthog.alias('anonymous session id', 'distinct id')
|
||||
```
|
||||
"""
|
||||
_proxy('alias', previous_id=previous_id, distinct_id=distinct_id, context=context, timestamp=timestamp, message_id=message_id)
|
||||
_proxy(
|
||||
"alias",
|
||||
previous_id=previous_id,
|
||||
distinct_id=distinct_id,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
|
||||
def feature_enabled(
|
||||
key, # type: str,
|
||||
distinct_id, # type: str,
|
||||
default=False, # type: bool
|
||||
):
|
||||
key, # type: str,
|
||||
distinct_id, # type: str,
|
||||
default=False, # type: bool
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
):
|
||||
# type: (...) -> bool
|
||||
"""
|
||||
Use feature flags to enable or disable features for users.
|
||||
@@ -114,37 +247,112 @@ 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,
|
||||
default=default,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag(
|
||||
key, # type: str,
|
||||
distinct_id, # type: str,
|
||||
default=False, # type: bool
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
):
|
||||
"""
|
||||
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,
|
||||
default=default,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags(
|
||||
distinct_id, # type: str,
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
):
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def page(*args, **kwargs):
|
||||
"""Send a page call."""
|
||||
_proxy('page', *args, **kwargs)
|
||||
_proxy("page", *args, **kwargs)
|
||||
|
||||
|
||||
def screen(*args, **kwargs):
|
||||
"""Send a screen call."""
|
||||
_proxy('screen', *args, **kwargs)
|
||||
_proxy("screen", *args, **kwargs)
|
||||
|
||||
|
||||
def flush():
|
||||
"""Tell the client to flush."""
|
||||
_proxy('flush')
|
||||
_proxy("flush")
|
||||
|
||||
|
||||
def join():
|
||||
"""Block program until the client clears the queue"""
|
||||
_proxy('join')
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def shutdown():
|
||||
"""Flush all messages and cleanly shutdown the client"""
|
||||
_proxy('flush')
|
||||
_proxy('join')
|
||||
_proxy("flush")
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def _proxy(method, *args, **kwargs):
|
||||
@@ -153,9 +361,16 @@ def _proxy(method, *args, **kwargs):
|
||||
if disabled:
|
||||
return None
|
||||
if not default_client:
|
||||
default_client = Client(api_key, host=host, debug=debug,
|
||||
on_error=on_error, send=send,
|
||||
sync_mode=sync_mode, personal_api_key=personal_api_key)
|
||||
default_client = Client(
|
||||
api_key,
|
||||
host=host,
|
||||
debug=debug,
|
||||
on_error=on_error,
|
||||
send=send,
|
||||
sync_mode=sync_mode,
|
||||
personal_api_key=personal_api_key,
|
||||
project_api_key=project_api_key,
|
||||
)
|
||||
|
||||
fn = getattr(default_client, method)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
+364
-138
@@ -1,19 +1,20 @@
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
import atexit
|
||||
import logging
|
||||
import numbers
|
||||
import atexit
|
||||
import hashlib
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from tokenize import group
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import requests
|
||||
from dateutil.tz import tzutc
|
||||
from six import string_types
|
||||
|
||||
from posthog.utils import guess_timezone, clean
|
||||
from posthog.consumer import Consumer
|
||||
from posthog.request import batch_post, decide, get, APIError
|
||||
from posthog.version import VERSION
|
||||
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 SizeLimitedDict, clean, guess_timezone
|
||||
from posthog.version import VERSION
|
||||
|
||||
try:
|
||||
import queue
|
||||
@@ -21,25 +22,41 @@ except ImportError:
|
||||
import Queue as queue
|
||||
|
||||
|
||||
ID_TYPES = (numbers.Number, string_types)
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
ID_TYPES = (numbers.Number, string_types, UUID)
|
||||
MAX_DICT_SIZE = 50_000
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Create a new PostHog client."""
|
||||
log = logging.getLogger('posthog')
|
||||
|
||||
def __init__(self, api_key=None, host=None, debug=False,
|
||||
max_queue_size=10000, send=True, on_error=None, flush_at=100,
|
||||
flush_interval=0.5, gzip=False, max_retries=3,
|
||||
sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, project_api_key=None):
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key=None,
|
||||
host=None,
|
||||
debug=False,
|
||||
max_queue_size=10000,
|
||||
send=True,
|
||||
on_error=None,
|
||||
flush_at=100,
|
||||
flush_interval=0.5,
|
||||
gzip=False,
|
||||
max_retries=3,
|
||||
sync_mode=False,
|
||||
timeout=15,
|
||||
thread=1,
|
||||
poll_interval=30,
|
||||
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
|
||||
self.api_key = api_key or project_api_key
|
||||
|
||||
require('api_key', self.api_key, string_types)
|
||||
# api_key: This should be the Team API Key (token), public
|
||||
self.api_key = project_api_key or api_key
|
||||
|
||||
require("api_key", self.api_key, string_types)
|
||||
|
||||
self.on_error = on_error
|
||||
self.debug = debug
|
||||
@@ -49,13 +66,20 @@ class Client(object):
|
||||
self.gzip = gzip
|
||||
self.timeout = timeout
|
||||
self.feature_flags = None
|
||||
self.group_type_mapping = 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:
|
||||
self.log.setLevel(logging.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(level=logging.DEBUG)
|
||||
else:
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
if sync_mode:
|
||||
self.consumers = None
|
||||
@@ -71,9 +95,15 @@ class Client(object):
|
||||
for n in range(thread):
|
||||
self.consumers = []
|
||||
consumer = Consumer(
|
||||
self.queue, api_key, host=host, on_error=on_error,
|
||||
flush_at=flush_at, flush_interval=flush_interval,
|
||||
gzip=gzip, retries=max_retries, timeout=timeout,
|
||||
self.queue,
|
||||
self.api_key,
|
||||
host=host,
|
||||
on_error=on_error,
|
||||
flush_at=flush_at,
|
||||
flush_interval=flush_interval,
|
||||
gzip=gzip,
|
||||
retries=max_retries,
|
||||
timeout=timeout,
|
||||
)
|
||||
self.consumers.append(consumer)
|
||||
|
||||
@@ -81,127 +111,223 @@ 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)
|
||||
require('properties', properties, dict)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
'timestamp': timestamp,
|
||||
'context': context,
|
||||
'distinct_id': distinct_id,
|
||||
'$set': properties,
|
||||
'event': '$identify',
|
||||
'messageId': message_id,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$identify",
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
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):
|
||||
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["featureFlags"]
|
||||
|
||||
def capture(
|
||||
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 {}
|
||||
require('distinct_id', distinct_id, ID_TYPES)
|
||||
require('properties', properties, dict)
|
||||
require('event', event, string_types)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
require("event", event, string_types)
|
||||
|
||||
msg = {
|
||||
'properties': properties,
|
||||
'timestamp': timestamp,
|
||||
'context': context,
|
||||
'distinct_id': distinct_id,
|
||||
'event': event,
|
||||
'messageId': message_id,
|
||||
"properties": properties,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"event": event,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
if groups:
|
||||
require("groups", groups, dict)
|
||||
msg["properties"]["$groups"] = groups
|
||||
|
||||
if send_feature_flags:
|
||||
try:
|
||||
feature_variants = self.get_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, uuid=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$set",
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def alias(self, previous_id=None, distinct_id=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('previous_id', previous_id, ID_TYPES)
|
||||
require('distinct_id', distinct_id, ID_TYPES)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
'properties': {
|
||||
'distinct_id': previous_id,
|
||||
'alias': distinct_id,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set_once": properties,
|
||||
"event": "$set_once",
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
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)
|
||||
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,
|
||||
},
|
||||
'timestamp': timestamp,
|
||||
'context': context,
|
||||
'event': '$create_alias'
|
||||
"distinct_id": "${}_{}".format(group_type, group_key),
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def page(self, distinct_id=None, url=None, properties=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)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
|
||||
msg = {
|
||||
"properties": {
|
||||
"distinct_id": previous_id,
|
||||
"alias": distinct_id,
|
||||
},
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"event": "$create_alias",
|
||||
"distinct_id": previous_id,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def page(self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
|
||||
require('distinct_id', distinct_id, ID_TYPES)
|
||||
require('properties', properties, dict)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
require('url', url, string_types)
|
||||
properties['$current_url'] = url
|
||||
require("url", url, string_types)
|
||||
properties["$current_url"] = url
|
||||
|
||||
msg = {
|
||||
'event': '$pageview',
|
||||
'properties': properties,
|
||||
'timestamp': timestamp,
|
||||
'context': context,
|
||||
'distinct_id': distinct_id,
|
||||
'messageId': message_id,
|
||||
"event": "$pageview",
|
||||
"properties": properties,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
|
||||
def _enqueue(self, msg):
|
||||
"""Push a new `msg` onto the queue, return `(success, msg)`"""
|
||||
timestamp = msg['timestamp']
|
||||
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)
|
||||
require("timestamp", timestamp, datetime)
|
||||
require("context", msg["context"], dict)
|
||||
|
||||
# add common
|
||||
timestamp = guess_timezone(timestamp)
|
||||
msg['timestamp'] = timestamp.isoformat()
|
||||
msg['messageId'] = stringify_id(message_id)
|
||||
if not msg.get('properties'):
|
||||
msg['properties'] = {}
|
||||
msg['properties']['$lib'] = 'posthog-python'
|
||||
msg['properties']['$lib_version'] = VERSION
|
||||
msg["timestamp"] = timestamp.isoformat()
|
||||
|
||||
msg['distinct_id'] = stringify_id(msg.get('distinct_id', None))
|
||||
# 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
|
||||
|
||||
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
|
||||
|
||||
msg = clean(msg)
|
||||
self.log.debug('queueing: %s', msg)
|
||||
self.log.debug("queueing: %s", msg)
|
||||
|
||||
# if send is False, return msg as if it was successfully queued
|
||||
if not self.send:
|
||||
return True, msg
|
||||
|
||||
if self.sync_mode:
|
||||
self.log.debug('enqueued with blocking %s.', msg['event'])
|
||||
batch_post(self.api_key, self.host, gzip=self.gzip,
|
||||
timeout=self.timeout, batch=[msg])
|
||||
self.log.debug("enqueued with blocking %s.", msg["event"])
|
||||
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=[msg])
|
||||
|
||||
return True, msg
|
||||
|
||||
try:
|
||||
self.queue.put(msg, block=False)
|
||||
self.log.debug('enqueued %s.', msg['event'])
|
||||
self.log.debug("enqueued %s.", msg["event"])
|
||||
return True, msg
|
||||
except queue.Full:
|
||||
self.log.warning('analytics-python queue is full')
|
||||
self.log.warning("analytics-python queue is full")
|
||||
return False, msg
|
||||
|
||||
def flush(self):
|
||||
@@ -210,7 +336,7 @@ class Client(object):
|
||||
size = queue.qsize()
|
||||
queue.join()
|
||||
# Note that this message may not be precise, because of threading.
|
||||
self.log.debug('successfully flushed about %s items.', size)
|
||||
self.log.debug("successfully flushed about %s items.", size)
|
||||
|
||||
def join(self):
|
||||
"""Ends the consumer thread once the queue is empty.
|
||||
@@ -224,6 +350,9 @@ class Client(object):
|
||||
# consumer thread has not started
|
||||
pass
|
||||
|
||||
if self.poller:
|
||||
self.poller.stop()
|
||||
|
||||
def shutdown(self):
|
||||
"""Flush all messages and cleanly shutdown the client"""
|
||||
self.flush()
|
||||
@@ -231,85 +360,182 @@ 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}", self.host
|
||||
)
|
||||
self.feature_flags = response["flags"] or []
|
||||
self.group_type_mapping = response["group_type_mapping"] 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'
|
||||
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
|
||||
)
|
||||
raise APIError(status=e.status, message=e.message)
|
||||
except Exception as e:
|
||||
self.log.warning('[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds.' % self.poll_interval)
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds."
|
||||
% self.poll_interval
|
||||
)
|
||||
self.log.warning(e)
|
||||
|
||||
self._last_feature_flag_poll = datetime.utcnow().replace(tzinfo=tzutc())
|
||||
|
||||
def load_feature_flags(self):
|
||||
if not self.personal_api_key:
|
||||
self.log.warning('[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.')
|
||||
self.log.warning("[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")
|
||||
self.feature_flags = []
|
||||
return
|
||||
|
||||
self._load_feature_flags()
|
||||
poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
|
||||
poller.start()
|
||||
if not (self.poller and self.poller.is_alive()):
|
||||
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):
|
||||
require('key', key, string_types)
|
||||
require('distinct_id', distinct_id, ID_TYPES)
|
||||
def _compute_flag_locally(self, feature_flag, distinct_id, *, groups={}, person_properties={}, group_properties={}):
|
||||
|
||||
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 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)
|
||||
|
||||
def feature_enabled(self, key, distinct_id, default=False, *, groups={}, person_properties={}, group_properties={}):
|
||||
return bool(
|
||||
self.get_feature_flag(
|
||||
key,
|
||||
distinct_id,
|
||||
default,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
)
|
||||
)
|
||||
|
||||
def get_feature_flag(
|
||||
self, key, distinct_id, default=False, *, groups={}, person_properties={}, group_properties={}
|
||||
):
|
||||
require("key", key, string_types)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if self.feature_flags == None and self.personal_api_key:
|
||||
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
|
||||
|
||||
if response is None:
|
||||
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
|
||||
)
|
||||
response = feature_flags.get(key)
|
||||
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}")
|
||||
response = default
|
||||
|
||||
if key not in self.distinct_ids_feature_flags_reported[distinct_id]:
|
||||
self.capture(
|
||||
distinct_id, "$feature_flag_called", {"$feature_flag": key, "$feature_flag_response": response}
|
||||
)
|
||||
self.distinct_ids_feature_flags_reported[distinct_id].add(key)
|
||||
return response
|
||||
|
||||
def get_all_flags(self, distinct_id, *, groups={}, person_properties={}, group_properties={}):
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if self.feature_flags == None and self.personal_api_key:
|
||||
self.load_feature_flags()
|
||||
|
||||
response = {}
|
||||
fallback_to_decide = False
|
||||
|
||||
# If loading in previous line failed
|
||||
if self.feature_flags:
|
||||
for flag in self.feature_flags:
|
||||
try:
|
||||
response[flag["key"]] = self._compute_flag_locally(
|
||||
flag,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
)
|
||||
except InconclusiveMatchError as e:
|
||||
# No need to log this, since it's just telling us to fall back to `/decide`
|
||||
fallback_to_decide = True
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Error while computing variant: {e}")
|
||||
fallback_to_decide = True
|
||||
else:
|
||||
fallback_to_decide = True
|
||||
|
||||
if fallback_to_decide:
|
||||
try:
|
||||
feature_flags = self.get_feature_variants(
|
||||
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
|
||||
)
|
||||
response = {**response, **feature_flags}
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
|
||||
|
||||
if feature_flag.get('is_simple_flag'):
|
||||
response = _hash(key, distinct_id) <= (feature_flag['rollout_percentage'] / 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})
|
||||
return response
|
||||
|
||||
# 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__
|
||||
|
||||
def require(name, field, data_type):
|
||||
"""Require that the named `field` has the right `data_type`"""
|
||||
if not isinstance(field, data_type):
|
||||
msg = '{0} must have {1}, got: {2}'.format(name, data_type, field)
|
||||
msg = "{0} must have {1}, got: {2}".format(name, data_type, field)
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
|
||||
+31
-27
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
from threading import Thread
|
||||
import monotonic
|
||||
import backoff
|
||||
import json
|
||||
|
||||
from posthog.request import batch_post, APIError, DatetimeSerializer
|
||||
import backoff
|
||||
import monotonic
|
||||
|
||||
from posthog.request import APIError, DatetimeSerializer, batch_post
|
||||
|
||||
try:
|
||||
from queue import Empty
|
||||
@@ -20,11 +21,21 @@ BATCH_SIZE_LIMIT = 475000
|
||||
|
||||
class Consumer(Thread):
|
||||
"""Consumes the messages from the client's queue."""
|
||||
log = logging.getLogger('posthog')
|
||||
|
||||
def __init__(self, queue, api_key, flush_at=100, host=None,
|
||||
on_error=None, flush_interval=0.5, gzip=False, retries=10,
|
||||
timeout=15):
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queue,
|
||||
api_key,
|
||||
flush_at=100,
|
||||
host=None,
|
||||
on_error=None,
|
||||
flush_interval=0.5,
|
||||
gzip=False,
|
||||
retries=10,
|
||||
timeout=15,
|
||||
):
|
||||
"""Create a consumer thread."""
|
||||
Thread.__init__(self)
|
||||
# Make consumer a daemon thread so that it doesn't block program exit
|
||||
@@ -46,11 +57,11 @@ class Consumer(Thread):
|
||||
|
||||
def run(self):
|
||||
"""Runs the consumer."""
|
||||
self.log.debug('consumer is running...')
|
||||
self.log.debug("consumer is running...")
|
||||
while self.running:
|
||||
self.upload()
|
||||
|
||||
self.log.debug('consumer exited.')
|
||||
self.log.debug("consumer exited.")
|
||||
|
||||
def pause(self):
|
||||
"""Pause the consumer."""
|
||||
@@ -67,7 +78,7 @@ class Consumer(Thread):
|
||||
self.request(batch)
|
||||
success = True
|
||||
except Exception as e:
|
||||
self.log.error('error uploading: %s', e)
|
||||
self.log.error("error uploading: %s", e)
|
||||
success = False
|
||||
if self.on_error:
|
||||
self.on_error(e, batch)
|
||||
@@ -90,19 +101,15 @@ class Consumer(Thread):
|
||||
if elapsed >= self.flush_interval:
|
||||
break
|
||||
try:
|
||||
item = queue.get(
|
||||
block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(
|
||||
item, cls=DatetimeSerializer).encode())
|
||||
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
|
||||
if item_size > MAX_MSG_SIZE:
|
||||
self.log.error(
|
||||
'Item exceeds 32kb limit, dropping. (%s)', str(item))
|
||||
self.log.error("Item exceeds 32kb limit, dropping. (%s)", str(item))
|
||||
continue
|
||||
items.append(item)
|
||||
total_size += item_size
|
||||
if total_size >= BATCH_SIZE_LIMIT:
|
||||
self.log.debug(
|
||||
'hit batch size limit (size: %d)', total_size)
|
||||
self.log.debug("hit batch size limit (size: %d)", total_size)
|
||||
break
|
||||
except Empty:
|
||||
break
|
||||
@@ -110,25 +117,22 @@ class Consumer(Thread):
|
||||
return items
|
||||
|
||||
def request(self, batch):
|
||||
"""Attempt to upload the batch and retry before raising an error """
|
||||
"""Attempt to upload the batch and retry before raising an error"""
|
||||
|
||||
def fatal_exception(exc):
|
||||
if isinstance(exc, APIError):
|
||||
# retry on server errors and client errors
|
||||
# with 429 status code (rate limited),
|
||||
# don't retry on other client errors
|
||||
if exc.status == "N/A":
|
||||
return False
|
||||
return (400 <= exc.status < 500) and exc.status != 429
|
||||
else:
|
||||
# retry on all other errors (eg. network)
|
||||
return False
|
||||
|
||||
@backoff.on_exception(
|
||||
backoff.expo,
|
||||
Exception,
|
||||
max_tries=self.retries + 1,
|
||||
giveup=fatal_exception)
|
||||
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
|
||||
def send_request():
|
||||
batch_post(self.api_key, self.host, gzip=self.gzip,
|
||||
timeout=self.timeout, batch=batch)
|
||||
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=batch)
|
||||
|
||||
send_request()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from posthog.utils import is_valid_regex
|
||||
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
|
||||
|
||||
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):
|
||||
flag_conditions = (flag.get("filters") or {}).get("groups") or []
|
||||
is_inconclusive = False
|
||||
|
||||
for condition in 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):
|
||||
return get_matching_variant(flag, distinct_id) 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):
|
||||
rollout_percentage = condition.get("rollout_percentage")
|
||||
if len(condition.get("properties") or []) > 0:
|
||||
if not all(match_property(prop, properties) for prop in condition.get("properties")):
|
||||
return False
|
||||
elif rollout_percentage is None:
|
||||
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
|
||||
|
||||
return False
|
||||
+4
-3
@@ -1,19 +1,20 @@
|
||||
import threading
|
||||
|
||||
|
||||
class Poller(threading.Thread):
|
||||
def __init__(self, interval, execute, *args, **kwargs):
|
||||
threading.Thread.__init__(self)
|
||||
self.daemon = False
|
||||
self.daemon = True # Make daemon to not interfere with program exit
|
||||
self.stopped = threading.Event()
|
||||
self.interval = interval
|
||||
self.execute = execute
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
def stop(self):
|
||||
self.stopped.set()
|
||||
self.join()
|
||||
|
||||
def run(self):
|
||||
while not self.stopped.wait(self.interval.total_seconds()):
|
||||
self.execute(*self.args, **self.kwargs)
|
||||
self.execute(*self.args, **self.kwargs)
|
||||
|
||||
+48
-47
@@ -1,92 +1,93 @@
|
||||
from datetime import date, datetime
|
||||
from dateutil.tz import tzutc
|
||||
import logging
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from gzip import GzipFile
|
||||
from requests.auth import HTTPBasicAuth
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from dateutil.tz import tzutc
|
||||
|
||||
from posthog.version import VERSION
|
||||
from posthog.utils import remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
_session = requests.sessions.Session()
|
||||
|
||||
DEFAULT_HOST = 'https://app.posthog.com'
|
||||
USER_AGENT = 'posthog-python/' + VERSION
|
||||
DEFAULT_HOST = "https://app.posthog.com"
|
||||
USER_AGENT = "posthog-python/" + VERSION
|
||||
|
||||
|
||||
def post(api_key, host=None, path=None, gzip=False, timeout=15, **kwargs):
|
||||
def post(
|
||||
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the API"""
|
||||
log = logging.getLogger('posthog')
|
||||
log = logging.getLogger("posthog")
|
||||
body = kwargs
|
||||
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + path
|
||||
body['api_key'] = api_key
|
||||
body["api_key"] = api_key
|
||||
data = json.dumps(body, cls=DatetimeSerializer)
|
||||
log.debug('making request: %s', data)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
log.debug("making request: %s", data)
|
||||
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
|
||||
if gzip:
|
||||
headers['Content-Encoding'] = 'gzip'
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
buf = BytesIO()
|
||||
with GzipFile(fileobj=buf, mode='w') as gz:
|
||||
with GzipFile(fileobj=buf, mode="w") as gz:
|
||||
# 'data' was produced by json.dumps(),
|
||||
# whose default encoding is utf-8.
|
||||
gz.write(data.encode('utf-8'))
|
||||
gz.write(data.encode("utf-8"))
|
||||
data = buf.getvalue()
|
||||
|
||||
res = _session.post(url, data=data,
|
||||
headers=headers, timeout=timeout)
|
||||
res = _session.post(url, data=data, headers=headers, timeout=timeout)
|
||||
|
||||
if res.status_code == 200:
|
||||
log.debug('data uploaded successfully')
|
||||
return res
|
||||
log.debug("data uploaded successfully")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _process_response(res, success_message, return_json=True):
|
||||
log = logging.getLogger('posthog')
|
||||
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.')
|
||||
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
|
||||
try:
|
||||
payload = res.json()
|
||||
log.debug('received response: %s', payload)
|
||||
raise APIError(res.status_code, payload['detail'])
|
||||
log.debug("received response: %s", payload)
|
||||
raise APIError(res.status_code, payload["detail"])
|
||||
except ValueError:
|
||||
raise APIError(res.status_code, res.text)
|
||||
|
||||
def decide(api_key, host=None, gzip=False, timeout=15, **kwargs):
|
||||
|
||||
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)
|
||||
return _process_response(res, success_message='Feature flags decided successfully')
|
||||
res = post(api_key, host, "/decide/?v=2", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="Feature flags decided successfully")
|
||||
|
||||
|
||||
def batch_post(api_key, host=None, gzip=False, timeout=15, **kwargs):
|
||||
def batch_post(
|
||||
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the batch API endpoint for events"""
|
||||
res = post(api_key, host, '/batch/', gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message='data uploaded successfully', return_json=False)
|
||||
res = post(api_key, host, "/batch/", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="data uploaded successfully", return_json=False)
|
||||
|
||||
|
||||
def get(api_key, url, host=None, timeout=None):
|
||||
def get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None) -> requests.Response:
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
||||
res = requests.get(
|
||||
url,
|
||||
headers={
|
||||
'Authorization': 'Bearer %s' % api_key,
|
||||
'User-Agent': USER_AGENT
|
||||
},
|
||||
timeout=timeout
|
||||
)
|
||||
return _process_response(res, success_message=f'GET {url} completed successfully')
|
||||
res = requests.get(url, headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}, timeout=timeout)
|
||||
return _process_response(res, success_message=f"GET {url} completed successfully")
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
|
||||
def __init__(self, status, message):
|
||||
def __init__(self, status: Union[int, str], message: str):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
@@ -96,7 +97,7 @@ class APIError(Exception):
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
return obj.isoformat()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
POSTHOG_ID_TAG = "posthog_distinct_id"
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.conf import settings
|
||||
from sentry_sdk import configure_scope
|
||||
|
||||
from posthog.sentry import POSTHOG_ID_TAG
|
||||
|
||||
GET_DISTINCT_ID = getattr(settings, "POSTHOG_DJANGO", {}).get("distinct_id")
|
||||
|
||||
|
||||
def get_distinct_id(request):
|
||||
if not GET_DISTINCT_ID:
|
||||
return None
|
||||
try:
|
||||
return GET_DISTINCT_ID(request)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
class PosthogDistinctIdMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
with configure_scope() as scope:
|
||||
distinct_id = get_distinct_id(request)
|
||||
if distinct_id:
|
||||
scope.set_tag(POSTHOG_ID_TAG, distinct_id)
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
@@ -0,0 +1,53 @@
|
||||
from sentry_sdk._types import MYPY
|
||||
from sentry_sdk.client import Client
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.integrations import Integration
|
||||
from sentry_sdk.scope import add_global_event_processor
|
||||
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 sentry_sdk._types import Event, Hint
|
||||
|
||||
|
||||
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 hosted sentry instance (default: https://sentry.io/organizations/)
|
||||
|
||||
@staticmethod
|
||||
def setup_once():
|
||||
@add_global_event_processor
|
||||
def processor(event, hint):
|
||||
# type: (Event, Optional[Hint]) -> Optional[Event]
|
||||
if Hub.current.get_integration(PostHogIntegration) is not None:
|
||||
if event.get("level") != "error":
|
||||
return event
|
||||
|
||||
if event.get("tags", {}).get(POSTHOG_ID_TAG):
|
||||
posthog_distinct_id = event["tags"][POSTHOG_ID_TAG]
|
||||
event["tags"]["PostHog URL"] = f"{posthog.host or DEFAULT_HOST}/person/{posthog_distinct_id}"
|
||||
|
||||
properties = {
|
||||
"$sentry_event_id": event["event_id"],
|
||||
"$sentry_exception": event["exception"],
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return event
|
||||
@@ -1,12 +1,12 @@
|
||||
import unittest
|
||||
import pkgutil
|
||||
import logging
|
||||
import pkgutil
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
def all_names():
|
||||
for _, modname, _ in pkgutil.iter_modules(__path__):
|
||||
yield 'posthog.test.' + modname
|
||||
yield "posthog.test." + modname
|
||||
|
||||
|
||||
def all():
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
from datetime import date, datetime
|
||||
import unittest
|
||||
import six
|
||||
import mock
|
||||
import time
|
||||
from freezegun import freeze_time
|
||||
|
||||
from posthog.version import VERSION
|
||||
from posthog.client import Client
|
||||
from posthog.test.utils import TEST_API_KEY
|
||||
|
||||
from posthog.request import APIError
|
||||
|
||||
class TestClient(unittest.TestCase):
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print('FAIL', e, batch)
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
def test_requires_api_key(self):
|
||||
self.assertRaises(AssertionError, Client)
|
||||
|
||||
def test_empty_flush(self):
|
||||
self.client.flush()
|
||||
|
||||
def test_basic_capture(self):
|
||||
client = self.client
|
||||
success, msg = client.capture('distinct_id', 'python test event')
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg['event'], 'python test event')
|
||||
self.assertTrue(isinstance(msg['timestamp'], str))
|
||||
self.assertTrue(isinstance(msg['messageId'], str))
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
|
||||
self.assertEqual(msg['properties']['$lib_version'], VERSION)
|
||||
|
||||
def test_stringifies_distinct_id(self):
|
||||
# A large number that loses precision in node:
|
||||
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
|
||||
client = self.client
|
||||
success, msg = client.capture(
|
||||
distinct_id=157963456373623802, event='python test event')
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg['distinct_id'], '157963456373623802')
|
||||
|
||||
def test_advanced_capture(self):
|
||||
client = self.client
|
||||
success, msg = client.capture(
|
||||
'distinct_id', 'python test event', {'property': 'value'},
|
||||
{'ip': '192.168.0.1'}, datetime(2014, 9, 3),
|
||||
'messageId')
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
|
||||
self.assertEqual(msg['properties']['property'], 'value')
|
||||
self.assertEqual(msg['context']['ip'], '192.168.0.1')
|
||||
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['distinct_id'], 'distinct_id')
|
||||
|
||||
def test_basic_identify(self):
|
||||
client = self.client
|
||||
success, msg = client.identify('distinct_id', {'trait': 'value'})
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg['$set']['trait'], 'value')
|
||||
self.assertTrue(isinstance(msg['timestamp'], str))
|
||||
self.assertTrue(isinstance(msg['messageId'], str))
|
||||
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')
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
|
||||
self.assertEqual(msg['context']['ip'], '192.168.0.1')
|
||||
self.assertEqual(msg['$set']['trait'], 'value')
|
||||
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['distinct_id'], 'distinct_id')
|
||||
|
||||
def test_basic_alias(self):
|
||||
client = self.client
|
||||
success, msg = client.alias('previousId', 'distinct_id')
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
self.assertEqual(msg['properties']['distinct_id'], 'previousId')
|
||||
self.assertEqual(msg['properties']['alias'], 'distinct_id')
|
||||
|
||||
def test_basic_page(self):
|
||||
client = self.client
|
||||
success, msg = client.page('distinct_id', url='https://posthog.com/contact')
|
||||
self.assertFalse(self.failed)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
self.assertEqual(msg['properties']['$current_url'], 'https://posthog.com/contact')
|
||||
|
||||
def test_advanced_page(self):
|
||||
client = self.client
|
||||
success, msg = client.page(
|
||||
'distinct_id', 'https://posthog.com/contact', {'property': 'value'},
|
||||
{'ip': '192.168.0.1'}, datetime(2014, 9, 3), 'messageId')
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
|
||||
self.assertEqual(msg['context']['ip'], '192.168.0.1')
|
||||
self.assertEqual(msg['properties']['$current_url'], 'https://posthog.com/contact')
|
||||
self.assertEqual(msg['properties']['property'], 'value')
|
||||
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['distinct_id'], 'distinct_id')
|
||||
|
||||
def test_flush(self):
|
||||
client = self.client
|
||||
# set up the consumer with more requests than a single batch will allow
|
||||
for i in range(1000):
|
||||
success, msg = client.identify('distinct_id', {'trait': 'value'})
|
||||
# We can't reliably assert that the queue is non-empty here; that's
|
||||
# a race condition. We do our best to load it up though.
|
||||
client.flush()
|
||||
# Make sure that the client queue is empty after flushing
|
||||
self.assertTrue(client.queue.empty())
|
||||
|
||||
def test_shutdown(self):
|
||||
client = self.client
|
||||
# set up the consumer with more requests than a single batch will allow
|
||||
for i in range(1000):
|
||||
success, msg = client.identify('distinct_id', {'trait': 'value'})
|
||||
client.shutdown()
|
||||
# we expect two things after shutdown:
|
||||
# 1. client queue is empty
|
||||
# 2. consumer thread has stopped
|
||||
self.assertTrue(client.queue.empty())
|
||||
for consumer in client.consumers:
|
||||
self.assertFalse(consumer.is_alive())
|
||||
|
||||
def test_synchronous(self):
|
||||
client = Client(TEST_API_KEY, sync_mode=True)
|
||||
|
||||
success, message = client.identify('distinct_id')
|
||||
self.assertFalse(client.consumers)
|
||||
self.assertTrue(client.queue.empty())
|
||||
self.assertTrue(success)
|
||||
|
||||
def test_overflow(self):
|
||||
client = Client(TEST_API_KEY, max_queue_size=1)
|
||||
# Ensure consumer thread is no longer uploading
|
||||
client.join()
|
||||
|
||||
for i in range(10):
|
||||
client.identify('distinct_id')
|
||||
|
||||
success, msg = client.identify('distinct_id')
|
||||
# Make sure we are informed that the queue is at capacity
|
||||
self.assertFalse(success)
|
||||
|
||||
def test_unicode(self):
|
||||
Client(six.u('unicode_key'))
|
||||
|
||||
def test_numeric_distinct_id(self):
|
||||
self.client.capture(1234, 'python event')
|
||||
self.client.flush()
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
def test_debug(self):
|
||||
Client('bad_key', debug=True)
|
||||
|
||||
def test_gzip(self):
|
||||
client = Client(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)
|
||||
|
||||
def mock_post_fn(*args, **kwargs):
|
||||
self.assertEquals(len(kwargs['batch']), 10)
|
||||
|
||||
# the post function should be called 2 times, with a batch size of 10
|
||||
# each time.
|
||||
with mock.patch('posthog.consumer.batch_post', side_effect=mock_post_fn) \
|
||||
as mock_post:
|
||||
for _ in range(20):
|
||||
client.identify('distinct_id', {'trait': 'value'})
|
||||
time.sleep(1)
|
||||
self.assertEquals(mock_post.call_count, 2)
|
||||
|
||||
def test_user_defined_timeout(self):
|
||||
client = Client(TEST_API_KEY, timeout=10)
|
||||
for consumer in client.consumers:
|
||||
self.assertEquals(consumer.timeout, 10)
|
||||
|
||||
def test_default_timeout_15(self):
|
||||
client = Client(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'
|
||||
}]
|
||||
}
|
||||
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_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.decide')
|
||||
def test_feature_enabled_request(self, patch_get):
|
||||
patch_get.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.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'))
|
||||
|
||||
@mock.patch('posthog.client.Poller')
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_call_identify_fails(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 = [{'key': 'example', 'is_simple_flag': False}]
|
||||
|
||||
self.assertFalse(client.feature_enabled('example', 'distinct_id'))
|
||||
@@ -1,54 +0,0 @@
|
||||
from datetime import datetime, date
|
||||
import unittest
|
||||
import json
|
||||
import requests
|
||||
|
||||
from posthog.request import batch_post, DatetimeSerializer
|
||||
from posthog.test.utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
|
||||
def test_valid_request(self):
|
||||
res = batch_post(TEST_API_KEY, batch=[{
|
||||
'distinct_id': 'distinct_id',
|
||||
'event': 'python event',
|
||||
'type': 'track'
|
||||
}])
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_invalid_request_error(self):
|
||||
self.assertRaises(Exception, batch_post, 'testsecret',
|
||||
'https://t.posthog.com', False, '[{]')
|
||||
|
||||
def test_invalid_host(self):
|
||||
self.assertRaises(Exception, batch_post, 'testsecret',
|
||||
't.posthog.com/', batch=[])
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {'created': datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
|
||||
|
||||
def test_date_serialization(self):
|
||||
today = date.today()
|
||||
data = {'created': today}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
expected = '{"created": "%s"}' % today.isoformat()
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = batch_post(TEST_API_KEY, batch=[{
|
||||
'distinct_id': 'distinct_id',
|
||||
'event': 'python event',
|
||||
'type': 'track'
|
||||
}], timeout=15)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_should_timeout(self):
|
||||
with self.assertRaises(requests.ReadTimeout):
|
||||
batch_post('key', batch=[{
|
||||
'distinct_id': 'distinct_id',
|
||||
'event': 'python event',
|
||||
'type': 'track'
|
||||
}], timeout=0.0001)
|
||||
@@ -0,0 +1,449 @@
|
||||
import time
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import mock
|
||||
import six
|
||||
|
||||
from posthog.client import Client
|
||||
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)
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
def test_requires_api_key(self):
|
||||
self.assertRaises(AssertionError, Client)
|
||||
|
||||
def test_empty_flush(self):
|
||||
self.client.flush()
|
||||
|
||||
def test_basic_capture(self):
|
||||
client = self.client
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
|
||||
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=FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
|
||||
@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_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
|
||||
client = self.client
|
||||
success, msg = client.capture(distinct_id=157963456373623802, event="python test event")
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["distinct_id"], "157963456373623802")
|
||||
|
||||
def test_advanced_capture(self):
|
||||
client = self.client
|
||||
success, msg = client.capture(
|
||||
"distinct_id",
|
||||
"python test event",
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["properties"]["property"], "value")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
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
|
||||
success, msg = client.identify("distinct_id", {"trait": "value"})
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertTrue(isinstance(msg["timestamp"], 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), "new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_basic_set(self):
|
||||
client = self.client
|
||||
success, msg = client.set("distinct_id", {"trait": "value"})
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertTrue(isinstance(msg["timestamp"], 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), "new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_basic_set_once(self):
|
||||
client = self.client
|
||||
success, msg = client.set_once("distinct_id", {"trait": "value"})
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["$set_once"]["trait"], "value")
|
||||
self.assertTrue(isinstance(msg["timestamp"], 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), "new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set_once"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
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,
|
||||
},
|
||||
)
|
||||
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,
|
||||
},
|
||||
)
|
||||
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")
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
self.assertEqual(msg["properties"]["distinct_id"], "previousId")
|
||||
self.assertEqual(msg["properties"]["alias"], "distinct_id")
|
||||
|
||||
def test_basic_page(self):
|
||||
client = self.client
|
||||
success, msg = client.page("distinct_id", url="https://posthog.com/contact")
|
||||
self.assertFalse(self.failed)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
|
||||
|
||||
def test_basic_page_distinct_uuid(self):
|
||||
client = self.client
|
||||
distinct_id = uuid4()
|
||||
success, msg = client.page(distinct_id, url="https://posthog.com/contact")
|
||||
self.assertFalse(self.failed)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg["distinct_id"], str(distinct_id))
|
||||
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
|
||||
|
||||
def test_advanced_page(self):
|
||||
client = self.client
|
||||
success, msg = client.page(
|
||||
"distinct_id",
|
||||
"https://posthog.com/contact",
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
|
||||
self.assertEqual(msg["properties"]["property"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertEqual(msg["uuid"], "new-uuid")
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
|
||||
def test_flush(self):
|
||||
client = self.client
|
||||
# set up the consumer with more requests than a single batch will allow
|
||||
for i in range(1000):
|
||||
success, msg = client.identify("distinct_id", {"trait": "value"})
|
||||
# We can't reliably assert that the queue is non-empty here; that's
|
||||
# a race condition. We do our best to load it up though.
|
||||
client.flush()
|
||||
# Make sure that the client queue is empty after flushing
|
||||
self.assertTrue(client.queue.empty())
|
||||
|
||||
def test_shutdown(self):
|
||||
client = self.client
|
||||
# set up the consumer with more requests than a single batch will allow
|
||||
for i in range(1000):
|
||||
success, msg = client.identify("distinct_id", {"trait": "value"})
|
||||
client.shutdown()
|
||||
# we expect two things after shutdown:
|
||||
# 1. client queue is empty
|
||||
# 2. consumer thread has stopped
|
||||
self.assertTrue(client.queue.empty())
|
||||
for consumer in client.consumers:
|
||||
self.assertFalse(consumer.is_alive())
|
||||
|
||||
def test_synchronous(self):
|
||||
client = Client(FAKE_TEST_API_KEY, sync_mode=True)
|
||||
|
||||
success, message = client.identify("distinct_id")
|
||||
self.assertFalse(client.consumers)
|
||||
self.assertTrue(client.queue.empty())
|
||||
self.assertTrue(success)
|
||||
|
||||
def test_overflow(self):
|
||||
client = Client(FAKE_TEST_API_KEY, max_queue_size=1)
|
||||
# Ensure consumer thread is no longer uploading
|
||||
client.join()
|
||||
|
||||
for i in range(10):
|
||||
client.identify("distinct_id")
|
||||
|
||||
success, msg = client.identify("distinct_id")
|
||||
# Make sure we are informed that the queue is at capacity
|
||||
self.assertFalse(success)
|
||||
|
||||
def test_unicode(self):
|
||||
Client(six.u("unicode_key"))
|
||||
|
||||
def test_numeric_distinct_id(self):
|
||||
self.client.capture(1234, "python event")
|
||||
self.client.flush()
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
def test_debug(self):
|
||||
Client("bad_key", debug=True)
|
||||
|
||||
def test_gzip(self):
|
||||
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(FAKE_TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3)
|
||||
|
||||
def mock_post_fn(*args, **kwargs):
|
||||
self.assertEqual(len(kwargs["batch"]), 10)
|
||||
|
||||
# the post function should be called 2 times, with a batch size of 10
|
||||
# each time.
|
||||
with mock.patch("posthog.consumer.batch_post", side_effect=mock_post_fn) as mock_post:
|
||||
for _ in range(20):
|
||||
client.identify("distinct_id", {"trait": "value"})
|
||||
time.sleep(1)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
|
||||
def test_user_defined_timeout(self):
|
||||
client = Client(FAKE_TEST_API_KEY, timeout=10)
|
||||
for consumer in client.consumers:
|
||||
self.assertEqual(consumer.timeout, 10)
|
||||
|
||||
def test_default_timeout_15(self):
|
||||
client = Client(FAKE_TEST_API_KEY)
|
||||
for consumer in client.consumers:
|
||||
self.assertEqual(consumer.timeout, 15)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_call_identify_fails(self, patch_get, patch_poll):
|
||||
def raise_effect():
|
||||
raise Exception("http exception")
|
||||
|
||||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||||
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,23 +1,23 @@
|
||||
import unittest
|
||||
import mock
|
||||
import time
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
try:
|
||||
from queue import Queue
|
||||
except ImportError:
|
||||
from Queue import Queue
|
||||
|
||||
from posthog.consumer import Consumer, MAX_MSG_SIZE
|
||||
from posthog.consumer import MAX_MSG_SIZE, Consumer
|
||||
from posthog.request import APIError
|
||||
from posthog.test.utils import TEST_API_KEY
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestConsumer(unittest.TestCase):
|
||||
|
||||
def test_next(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, '')
|
||||
consumer = Consumer(q, "")
|
||||
q.put(1)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [1])
|
||||
@@ -25,7 +25,7 @@ class TestConsumer(unittest.TestCase):
|
||||
def test_next_limit(self):
|
||||
q = Queue()
|
||||
flush_at = 50
|
||||
consumer = Consumer(q, '', flush_at)
|
||||
consumer = Consumer(q, "", flush_at)
|
||||
for i in range(10000):
|
||||
q.put(i)
|
||||
next = consumer.next()
|
||||
@@ -33,8 +33,8 @@ class TestConsumer(unittest.TestCase):
|
||||
|
||||
def test_dropping_oversize_msg(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, '')
|
||||
oversize_msg = {'m': 'x' * MAX_MSG_SIZE}
|
||||
consumer = Consumer(q, "")
|
||||
oversize_msg = {"m": "x" * MAX_MSG_SIZE}
|
||||
q.put(oversize_msg)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [])
|
||||
@@ -43,11 +43,7 @@ class TestConsumer(unittest.TestCase):
|
||||
def test_upload(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY)
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event',
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
q.put(track)
|
||||
success = consumer.upload()
|
||||
self.assertTrue(success)
|
||||
@@ -58,16 +54,11 @@ class TestConsumer(unittest.TestCase):
|
||||
# The consumer should upload _n_ times.
|
||||
q = Queue()
|
||||
flush_interval = 0.3
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=10,
|
||||
flush_interval=flush_interval)
|
||||
with mock.patch('posthog.consumer.batch_post') as mock_post:
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
|
||||
with mock.patch("posthog.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(0, 3):
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event %d' % i,
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 3)
|
||||
@@ -78,45 +69,30 @@ class TestConsumer(unittest.TestCase):
|
||||
q = Queue()
|
||||
flush_interval = 0.5
|
||||
flush_at = 10
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at,
|
||||
flush_interval=flush_interval)
|
||||
with mock.patch('posthog.consumer.batch_post') as mock_post:
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval)
|
||||
with mock.patch("posthog.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(0, flush_at * 2):
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event %d' % i,
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
|
||||
def test_request(self):
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event',
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
consumer.request([track])
|
||||
|
||||
def _test_request_retry(self, consumer,
|
||||
expected_exception, exception_count):
|
||||
|
||||
def _test_request_retry(self, consumer, expected_exception, exception_count):
|
||||
def mock_post(*args, **kwargs):
|
||||
mock_post.call_count += 1
|
||||
if mock_post.call_count <= exception_count:
|
||||
raise expected_exception
|
||||
|
||||
mock_post.call_count = 0
|
||||
|
||||
with mock.patch('posthog.consumer.batch_post',
|
||||
mock.Mock(side_effect=mock_post)):
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event',
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
with mock.patch("posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)):
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
# request() should succeed if the number of exceptions raised is
|
||||
# less than the retries paramater.
|
||||
if exception_count <= consumer.retries:
|
||||
@@ -131,38 +107,35 @@ class TestConsumer(unittest.TestCase):
|
||||
self.assertEqual(exc, expected_exception)
|
||||
else:
|
||||
self.fail(
|
||||
"request() should raise an exception if still failing "
|
||||
"after %d retries" % consumer.retries)
|
||||
"request() should raise an exception if still failing after %d retries" % consumer.retries
|
||||
)
|
||||
|
||||
def test_request_retry(self):
|
||||
# we should retry on general errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, Exception('generic exception'), 2)
|
||||
self._test_request_retry(consumer, Exception("generic exception"), 2)
|
||||
|
||||
# we should retry on server errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, APIError(
|
||||
500, 'Internal Server Error'), 2)
|
||||
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2)
|
||||
|
||||
# we should retry on HTTP 429 errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, APIError(
|
||||
429, 'Too Many Requests'), 2)
|
||||
self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2)
|
||||
|
||||
# we should NOT retry on other client errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
api_error = APIError(400, 'Client Errors')
|
||||
api_error = APIError(400, "Client Errors")
|
||||
try:
|
||||
self._test_request_retry(consumer, api_error, 1)
|
||||
except APIError:
|
||||
pass
|
||||
else:
|
||||
self.fail('request() should not retry on client errors')
|
||||
self.fail("request() should not retry on client errors")
|
||||
|
||||
# test for number of exceptions raise > retries value
|
||||
consumer = Consumer(None, TEST_API_KEY, retries=3)
|
||||
self._test_request_retry(consumer, APIError(
|
||||
500, 'Internal Server Error'), 3)
|
||||
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3)
|
||||
|
||||
def test_pause(self):
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
@@ -171,13 +144,8 @@ class TestConsumer(unittest.TestCase):
|
||||
|
||||
def test_max_batch_size(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(
|
||||
q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event',
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
msg_size = len(json.dumps(track).encode())
|
||||
# number of messages in a maximum-size batch
|
||||
n_msgs = int(475000 / msg_size)
|
||||
@@ -185,15 +153,12 @@ class TestConsumer(unittest.TestCase):
|
||||
def mock_post_fn(_, data, **kwargs):
|
||||
res = mock.Mock()
|
||||
res.status_code = 200
|
||||
self.assertTrue(len(data.encode()) < 500000,
|
||||
'batch size (%d) exceeds 500KB limit'
|
||||
% len(data.encode()))
|
||||
self.assertTrue(len(data.encode()) < 500000, "batch size (%d) exceeds 500KB limit" % len(data.encode()))
|
||||
return res
|
||||
|
||||
with mock.patch('posthog.request._session.post',
|
||||
side_effect=mock_post_fn) as mock_post:
|
||||
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
|
||||
consumer.start()
|
||||
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
@@ -4,13 +4,12 @@ import posthog
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
|
||||
def failed(self):
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
posthog.api_key = 'testsecret'
|
||||
posthog.api_key = "testsecret"
|
||||
posthog.on_error = self.failed
|
||||
|
||||
def test_no_api_key(self):
|
||||
@@ -22,19 +21,19 @@ class TestModule(unittest.TestCase):
|
||||
self.assertRaises(Exception, posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
posthog.capture('distinct_id', 'python module event')
|
||||
posthog.capture("distinct_id", "python module event")
|
||||
posthog.flush()
|
||||
|
||||
def test_identify(self):
|
||||
posthog.identify('distinct_id', {'email': 'user@email.com'})
|
||||
posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
posthog.flush()
|
||||
|
||||
def test_alias(self):
|
||||
posthog.alias('previousId', 'distinct_id')
|
||||
posthog.alias("previousId", "distinct_id")
|
||||
posthog.flush()
|
||||
|
||||
def test_page(self):
|
||||
posthog.page('distinct_id', 'https://posthog.com/contact')
|
||||
posthog.page("distinct_id", "https://posthog.com/contact")
|
||||
posthog.flush()
|
||||
|
||||
def test_flush(self):
|
||||
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import requests
|
||||
|
||||
from posthog.request import DatetimeSerializer, batch_post
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
def test_valid_request(self):
|
||||
res = batch_post(TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}])
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_invalid_request_error(self):
|
||||
self.assertRaises(Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]")
|
||||
|
||||
def test_invalid_host(self):
|
||||
self.assertRaises(Exception, batch_post, "testsecret", "t.posthog.com/", batch=[])
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
|
||||
|
||||
def test_date_serialization(self):
|
||||
today = date.today()
|
||||
data = {"created": today}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
expected = '{"created": "%s"}' % today.isoformat()
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = batch_post(
|
||||
TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=15
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_should_timeout(self):
|
||||
with self.assertRaises(requests.ReadTimeout):
|
||||
batch_post(
|
||||
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
import unittest
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
import six
|
||||
from dateutil.tz import tzutc
|
||||
|
||||
from posthog import utils
|
||||
|
||||
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
|
||||
FAKE_TEST_API_KEY = "random_key"
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
def test_timezone_utils(self):
|
||||
now = datetime.now()
|
||||
utcnow = datetime.now(tz=tzutc())
|
||||
self.assertTrue(utils.is_naive(now))
|
||||
self.assertFalse(utils.is_naive(utcnow))
|
||||
|
||||
fixed = utils.guess_timezone(now)
|
||||
self.assertFalse(utils.is_naive(fixed))
|
||||
|
||||
shouldnt_be_edited = utils.guess_timezone(utcnow)
|
||||
self.assertEqual(utcnow, shouldnt_be_edited)
|
||||
|
||||
def test_clean(self):
|
||||
simple = {
|
||||
"decimal": Decimal("0.142857"),
|
||||
"unicode": six.u("woo"),
|
||||
"date": datetime.now(),
|
||||
"long": 200000000,
|
||||
"integer": 1,
|
||||
"float": 2.0,
|
||||
"bool": True,
|
||||
"str": "woo",
|
||||
"none": None,
|
||||
}
|
||||
|
||||
complicated = {
|
||||
"exception": Exception("This should show up"),
|
||||
"timedelta": timedelta(microseconds=20),
|
||||
"list": [1, 2, 3],
|
||||
}
|
||||
|
||||
combined = dict(simple.items())
|
||||
combined.update(complicated.items())
|
||||
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
self.assertEqual(combined.keys(), pre_clean_keys)
|
||||
|
||||
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
|
||||
self.assertEqual(utils.clean(UUID("12345678123456781234567812345678")), "12345678-1234-5678-1234-567812345678")
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
"birthdate": date(1980, 1, 1),
|
||||
"registration": datetime.utcnow(),
|
||||
}
|
||||
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
|
||||
|
||||
def test_bytes(self):
|
||||
if six.PY3:
|
||||
item = bytes(10)
|
||||
else:
|
||||
item = bytearray(10)
|
||||
|
||||
utils.clean(item)
|
||||
|
||||
def test_clean_fn(self):
|
||||
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
|
||||
self.assertEqual(cleaned["number"], 4)
|
||||
# TODO: fixme, different behavior on python 2 and 3
|
||||
if "fn" in cleaned:
|
||||
self.assertEqual(cleaned["fn"], None)
|
||||
|
||||
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))
|
||||
@@ -1,79 +0,0 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import unittest
|
||||
|
||||
from dateutil.tz import tzutc
|
||||
import six
|
||||
|
||||
from posthog import utils
|
||||
|
||||
TEST_API_KEY = 'kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4'
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
|
||||
def test_timezone_utils(self):
|
||||
now = datetime.now()
|
||||
utcnow = datetime.now(tz=tzutc())
|
||||
self.assertTrue(utils.is_naive(now))
|
||||
self.assertFalse(utils.is_naive(utcnow))
|
||||
|
||||
fixed = utils.guess_timezone(now)
|
||||
self.assertFalse(utils.is_naive(fixed))
|
||||
|
||||
shouldnt_be_edited = utils.guess_timezone(utcnow)
|
||||
self.assertEqual(utcnow, shouldnt_be_edited)
|
||||
|
||||
def test_clean(self):
|
||||
simple = {
|
||||
'decimal': Decimal('0.142857'),
|
||||
'unicode': six.u('woo'),
|
||||
'date': datetime.now(),
|
||||
'long': 200000000,
|
||||
'integer': 1,
|
||||
'float': 2.0,
|
||||
'bool': True,
|
||||
'str': 'woo',
|
||||
'none': None
|
||||
}
|
||||
|
||||
complicated = {
|
||||
'exception': Exception('This should show up'),
|
||||
'timedelta': timedelta(microseconds=20),
|
||||
'list': [1, 2, 3]
|
||||
}
|
||||
|
||||
combined = dict(simple.items())
|
||||
combined.update(complicated.items())
|
||||
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
self.assertEqual(combined.keys(), pre_clean_keys)
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
'birthdate': date(1980, 1, 1),
|
||||
'registration': datetime.utcnow(),
|
||||
}
|
||||
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
|
||||
|
||||
def test_bytes(self):
|
||||
if six.PY3:
|
||||
item = bytes(10)
|
||||
else:
|
||||
item = bytearray(10)
|
||||
|
||||
utils.clean(item)
|
||||
|
||||
def test_clean_fn(self):
|
||||
cleaned = utils.clean({'fn': lambda x: x, 'number': 4})
|
||||
self.assertEqual(cleaned['number'], 4)
|
||||
# TODO: fixme, different behavior on python 2 and 3
|
||||
if 'fn' in cleaned:
|
||||
self.assertEqual(cleaned['fn'], None)
|
||||
|
||||
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'))
|
||||
+37
-13
@@ -1,12 +1,15 @@
|
||||
from dateutil.tz import tzlocal, tzutc
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
import numbers
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
import six
|
||||
from dateutil.tz import tzlocal, tzutc
|
||||
|
||||
log = logging.getLogger('posthog')
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
|
||||
def is_naive(dt):
|
||||
@@ -17,8 +20,7 @@ def is_naive(dt):
|
||||
def total_seconds(delta):
|
||||
"""Determines total seconds with python < 2.7 compat."""
|
||||
# http://stackoverflow.com/questions/3694835/python-2-6-5-divide-timedelta-with-timedelta
|
||||
return (delta.microseconds
|
||||
+ (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
|
||||
return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
|
||||
|
||||
|
||||
def guess_timezone(dt):
|
||||
@@ -39,7 +41,7 @@ def guess_timezone(dt):
|
||||
|
||||
|
||||
def remove_trailing_slash(host):
|
||||
if host.endswith('/'):
|
||||
if host.endswith("/"):
|
||||
return host[:-1]
|
||||
return host
|
||||
|
||||
@@ -47,8 +49,9 @@ def remove_trailing_slash(host):
|
||||
def clean(item):
|
||||
if isinstance(item, Decimal):
|
||||
return float(item)
|
||||
elif isinstance(item, (six.string_types, bool, numbers.Number, datetime,
|
||||
date, type(None))):
|
||||
if isinstance(item, UUID):
|
||||
return str(item)
|
||||
elif isinstance(item, (six.string_types, bool, numbers.Number, datetime, date, type(None))):
|
||||
return item
|
||||
elif isinstance(item, (set, list, tuple)):
|
||||
return _clean_list(item)
|
||||
@@ -69,9 +72,10 @@ def _clean_dict(dict_):
|
||||
data[k] = clean(v)
|
||||
except TypeError:
|
||||
log.warning(
|
||||
'Dictionary values must be serializeable to '
|
||||
'JSON "%s" value %s of type %s is unsupported.',
|
||||
k, v, type(v),
|
||||
'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.',
|
||||
k,
|
||||
v,
|
||||
type(v),
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -82,6 +86,26 @@ def _coerce_unicode(cmplx):
|
||||
except AttributeError as exception:
|
||||
item = ":".join(exception)
|
||||
item.decode("utf-8", "strict")
|
||||
log.warning('Error decoding: %s', item)
|
||||
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)
|
||||
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
VERSION = '1.1.3'
|
||||
VERSION = "2.0.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
[tool.isort]
|
||||
multi_line_output = 3
|
||||
include_trailing_comma = true
|
||||
force_grid_wrap = 8
|
||||
ensure_newline_before_comments = true
|
||||
line_length = 120
|
||||
virtual_env = "env"
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for sentry_django_example project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Django settings for sentry_django_example project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 3.2.2.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-4kzfiq7vb(t0+jbl#vq)u=%06ouf)n*=l%730c8=tk(wkm9i9o"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
# PostHog Setup (can be a separate app)
|
||||
import posthog
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.api_key = "LXP6nQXvo-2TCqGVrWvPah8uJIyVykoMmhnEkEBi5PA" # TODO: replace with your api key
|
||||
|
||||
posthog.personal_api_key = ""
|
||||
|
||||
# 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"
|
||||
|
||||
from posthog.sentry.posthog_integration import PostHogIntegration
|
||||
|
||||
PostHogIntegration.organization = "posthog" # TODO: your sentry organization
|
||||
# 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)
|
||||
# we work around this by setting static class variables beforehand
|
||||
|
||||
# Sentry Setup
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn="https://27ac54f7f4cf484abf1335436b0c52e5@o344752.ingest.sentry.io/5624115", # TODO: your Sentry DSN here
|
||||
integrations=[DjangoIntegration(), PostHogIntegration()],
|
||||
# Set traces_sample_rate to 1.0 to capture 100%
|
||||
# of transactions for performance monitoring.
|
||||
# We recommend adjusting this value in production.
|
||||
traces_sample_rate=1.0,
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# django.contrib.auth) you may enable sending PII data.
|
||||
send_default_pii=True,
|
||||
)
|
||||
|
||||
POSTHOG_DJANGO = {
|
||||
"distinct_id": lambda request: str(uuid4()) # TODO: your logic for generating unique ID, given the request object
|
||||
}
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"posthog.sentry.django.PosthogDistinctIdMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "sentry_django_example.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "sentry_django_example.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/3.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
@@ -0,0 +1,27 @@
|
||||
"""sentry_django_example URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/3.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
|
||||
def trigger_error(request):
|
||||
division_by_zero = 1 / 0
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("sentry-debug/", trigger_error),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for sentry_django_example project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -8,40 +7,39 @@ except ImportError:
|
||||
from distutils.core import setup
|
||||
|
||||
# Don't import analytics-python module here, since deps may not be installed
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'posthog'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
|
||||
from version import VERSION
|
||||
|
||||
long_description = '''
|
||||
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,<2.0.0", "python-dateutil>2.1"]
|
||||
|
||||
tests_require = [
|
||||
"mock>=2.0.0",
|
||||
"freezegun==0.3.15"
|
||||
]
|
||||
extras_require = {
|
||||
"dev": [
|
||||
"black",
|
||||
"isort",
|
||||
"pre-commit",
|
||||
],
|
||||
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest"],
|
||||
"sentry": ["sentry-sdk", "django"],
|
||||
}
|
||||
|
||||
setup(
|
||||
name='posthog',
|
||||
name="posthog",
|
||||
version=VERSION,
|
||||
url='https://github.com/posthog/posthog-python',
|
||||
author='Posthog',
|
||||
author_email='hey@posthog.com',
|
||||
maintainer='PostHog',
|
||||
maintainer_email='hey@posthog.com',
|
||||
test_suite='posthog.test.all',
|
||||
packages=['posthog', 'posthog.test'],
|
||||
license='MIT License',
|
||||
url="https://github.com/posthog/posthog-python",
|
||||
author="Posthog",
|
||||
author_email="hey@posthog.com",
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthog.test.all",
|
||||
packages=["posthog", "posthog.test", "posthog.sentry"],
|
||||
license="MIT License",
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
description='Integrate PostHog into any python application.',
|
||||
extras_require=extras_require,
|
||||
description="Integrate PostHog into any python application.",
|
||||
long_description=long_description,
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
+18
-24
@@ -1,4 +1,3 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -8,39 +7,34 @@ except ImportError:
|
||||
from distutils.core import setup
|
||||
|
||||
# Don't import module here, since deps may not be installed
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'posthoganalytics'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthoganalytics"))
|
||||
from version import VERSION
|
||||
|
||||
long_description = '''
|
||||
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.6.0", "python-dateutil>2.1"]
|
||||
|
||||
tests_require = [
|
||||
"mock>=2.0.0"
|
||||
]
|
||||
tests_require = ["mock>=2.0.0"]
|
||||
|
||||
setup(
|
||||
name='posthoganalytics',
|
||||
name="posthoganalytics",
|
||||
version=VERSION,
|
||||
url='https://github.com/posthog/posthog-python',
|
||||
author='Posthog',
|
||||
author_email='hey@posthog.com',
|
||||
maintainer='PostHog',
|
||||
maintainer_email='hey@posthog.com',
|
||||
test_suite='posthoganalytics.test.all',
|
||||
packages=['posthoganalytics', 'posthoganalytics.test'],
|
||||
license='MIT License',
|
||||
url="https://github.com/posthog/posthog-python",
|
||||
author="Posthog",
|
||||
author_email="hey@posthog.com",
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthoganalytics.test.all",
|
||||
packages=["posthoganalytics", "posthoganalytics.test", "posthoganalytics.sentry"],
|
||||
license="MIT License",
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
description='Integrate PostHog into any python application.',
|
||||
extras_require={
|
||||
"sentry": ["sentry-sdk", "django"],
|
||||
},
|
||||
description="Integrate PostHog into any python application.",
|
||||
long_description=long_description,
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
+55
-32
@@ -1,42 +1,39 @@
|
||||
import posthog
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
|
||||
__name__ = 'simulator.py'
|
||||
__version__ = '0.0.1'
|
||||
__description__ = 'scripting simulator'
|
||||
import posthog
|
||||
|
||||
__name__ = "simulator.py"
|
||||
__version__ = "0.0.1"
|
||||
__description__ = "scripting simulator"
|
||||
|
||||
|
||||
def json_hash(str):
|
||||
if str:
|
||||
return json.loads(str)
|
||||
|
||||
|
||||
# posthog -method=<method> -posthog-write-key=<posthogWriteKey> [options]
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description='send a posthog message')
|
||||
parser = argparse.ArgumentParser(description="send a posthog message")
|
||||
|
||||
parser.add_argument('--writeKey', help='the posthog writeKey')
|
||||
parser.add_argument('--type', help='The posthog message type')
|
||||
parser.add_argument("--writeKey", help="the posthog writeKey")
|
||||
parser.add_argument("--type", help="The posthog message type")
|
||||
|
||||
parser.add_argument('--distinct_id', help='the user id to send the event as')
|
||||
parser.add_argument(
|
||||
'--anonymousId', help='the anonymous user id to send the event as')
|
||||
parser.add_argument(
|
||||
'--context', help='additional context for the event (JSON-encoded)')
|
||||
parser.add_argument("--distinct_id", help="the user id to send the event as")
|
||||
parser.add_argument("--anonymousId", help="the anonymous user id to send the event as")
|
||||
parser.add_argument("--context", help="additional context for the event (JSON-encoded)")
|
||||
|
||||
parser.add_argument('--event', help='the event name to send with the event')
|
||||
parser.add_argument(
|
||||
'--properties', help='the event properties to send (JSON-encoded)')
|
||||
parser.add_argument("--event", help="the event name to send with the event")
|
||||
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
|
||||
|
||||
parser.add_argument(
|
||||
'--name', help='name of the screen or page to send with the message')
|
||||
parser.add_argument("--name", help="name of the screen or page to send with the message")
|
||||
|
||||
parser.add_argument(
|
||||
'--traits', help='the identify/group traits to send (JSON-encoded)')
|
||||
parser.add_argument("--traits", help="the identify/group traits to send (JSON-encoded)")
|
||||
|
||||
parser.add_argument('--groupId', help='the group id')
|
||||
parser.add_argument("--groupId", help="the group id")
|
||||
|
||||
options = parser.parse_args()
|
||||
|
||||
@@ -46,18 +43,48 @@ def failed(status, msg):
|
||||
|
||||
|
||||
def capture():
|
||||
posthog.capture(options.distinct_id, options.event, anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties), context=json_hash(options.context))
|
||||
posthog.capture(
|
||||
options.distinct_id,
|
||||
options.event,
|
||||
anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
def page():
|
||||
posthog.page(options.distinct_id, name=options.name, anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties), context=json_hash(options.context))
|
||||
posthog.page(
|
||||
options.distinct_id,
|
||||
name=options.name,
|
||||
anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
def identify():
|
||||
posthog.identify(options.distinct_id, anonymous_id=options.anonymousId,
|
||||
traits=json_hash(options.traits), context=json_hash(options.context))
|
||||
posthog.identify(
|
||||
options.distinct_id,
|
||||
anonymous_id=options.anonymousId,
|
||||
traits=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
def set_once():
|
||||
posthog.set_once(
|
||||
options.distinct_id,
|
||||
properties=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
def set():
|
||||
posthog.set(
|
||||
options.distinct_id,
|
||||
properties=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
def unknown():
|
||||
@@ -68,16 +95,12 @@ posthog.api_key = options.writeKey
|
||||
posthog.on_error = failed
|
||||
posthog.debug = True
|
||||
|
||||
log = logging.getLogger('posthog')
|
||||
log = logging.getLogger("posthog")
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG)
|
||||
log.addHandler(ch)
|
||||
|
||||
switcher = {
|
||||
"capture": capture,
|
||||
"page": page,
|
||||
"identify": identify
|
||||
}
|
||||
switcher = {"capture": capture, "page": page, "identify": identify, "set_once": set_once, "set": set}
|
||||
|
||||
func = switcher.get(options.type)
|
||||
if func:
|
||||
|
||||
Reference in New Issue
Block a user