Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,5 @@ jobs:
python -m pip install -e .

- name: Run posthog tests
env:
SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only
DATABASE_URL: 'postgres://postgres:postgres@localhost:${{ job.services.postgres.ports[5432] }}/postgres'
run: |
python setup.py test
- name: Run EE tests
env:
SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only
DATABASE_URL: 'postgres://postgres:postgres@localhost:${{ job.services.postgres.ports[5432] }}/postgres'
PRIMARY_DB: 'clickhouse'
CLICKHOUSE_HOST: 'localhost'
CLICKHOUSE_DATABASE: 'posthog_test'
CLICKHOUSE_SECURE: 'False'
CLICKHOUSE_VERIFY: 'False'
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
touch frontend/dist/layout.html
touch frontend/dist/shared_dashboard.html
python manage.py test ee --testrunner="ee.clickhouse.clickhouse_test_runner.ClickhouseTestRunner"
17 changes: 13 additions & 4 deletions example.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@

# Import the library
import posthog
import time

# You can find this key on the /setup page in PostHog
posthog.api_key = '<your key>'
posthog.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://127.0.0.1:8000'

# Capture an event
posthog.capture('distinct_id', 'event', {'property1': 'value', 'property2': 'value'})

print(posthog.feature_enabled('beta-feature', 'distinct_id'))

print('sleeping')
time.sleep(45)

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')

# # Add properties to the person
# posthog.identify('distinct_id', {'email': 'something@something.com'})
posthog.identify('distinct_id', {'email': 'something@something.com'})
24 changes: 22 additions & 2 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
personal_api_key = None # type: str

default_client = None

Expand Down Expand Up @@ -100,6 +101,25 @@ def alias(
"""
_proxy('alias', previous_id=previous_id, distinct_id=distinct_id, context=context, timestamp=timestamp, message_id=message_id)

def feature_enabled(
key, # type: str,
distinct_id, # type: str,
default=None, # type: Optional[Any]
):
# type: (...) -> bool
"""
Use feature flags to enable or disable features for users.

For example:
```python
if posthog.feature_enabled('beta feature', 'distinct id'):
# 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)


def page(*args, **kwargs):
"""Send a page call."""
Expand Down Expand Up @@ -135,7 +155,7 @@ def _proxy(method, *args, **kwargs):
if not default_client:
default_client = Client(api_key, host=host, debug=debug,
on_error=on_error, send=send,
sync_mode=sync_mode)
sync_mode=sync_mode, personal_api_key=personal_api_key)

fn = getattr(default_client, method)
fn(*args, **kwargs)
return fn(*args, **kwargs)
82 changes: 79 additions & 3 deletions posthog/client.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from datetime import datetime
from datetime import datetime, timedelta
from uuid import uuid4
import logging
import numbers
import atexit
import hashlib

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 post
from posthog.request import post, get, APIError
from posthog.version import VERSION
from posthog.poller import Poller

try:
import queue
Expand All @@ -19,6 +21,7 @@


ID_TYPES = (numbers.Number, string_types)
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)


class Client(object):
Expand All @@ -28,10 +31,12 @@ class Client(object):
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):
sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None):
require('api_key', api_key, string_types)

self.queue = queue.Queue(max_queue_size)

# api_key: This should be the Team API Key (token), public
self.api_key = api_key
self.on_error = on_error
self.debug = debug
Expand All @@ -40,6 +45,11 @@ def __init__(self, api_key=None, host=None, debug=False,
self.host = host
self.gzip = gzip
self.timeout = timeout
self.feature_flags = None
self.poll_interval = poll_interval

# 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)
Expand Down Expand Up @@ -216,6 +226,72 @@ def shutdown(self):
self.flush()
self.join()

def _load_feature_flags(self):
if not self.personal_api_key:
raise ValueError('You have to specify a personal_api_key to use feature flags.')

try:
self.feature_flags = get(self.personal_api_key, '/api/feature_flag/', self.host)['results']
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'
)
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(e)

self._last_feature_flag_poll = datetime.utcnow().replace(tzinfo=tzutc())

def load_feature_flags(self):
self._load_feature_flags()
poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
poller.start()

def feature_enabled(self, key, distinct_id, default=False):
require('key', key, string_types)
require('distinct_id', distinct_id, ID_TYPES)
error = False

if not self.feature_flags:
self.load_feature_flags()

# If loading in previous line failed
if not self.feature_flags:
response = default
error = True
else:
try:
feature_flag = [flag for flag in self.feature_flags if flag['key'] == key][0]
except IndexError:
return default

if feature_flag.get('is_simple_flag'):
response = _hash(key, distinct_id) <= (feature_flag['rollout_percentage'] / 100)
else:
try:
request = get(self.api_key, '/decide/', self.host, timeout=1)
response = key in request['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)
error = True

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`"""
Expand Down
19 changes: 19 additions & 0 deletions posthog/poller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import threading

class Poller(threading.Thread):
def __init__(self, interval, execute, *args, **kwargs):
threading.Thread.__init__(self)
self.daemon = False
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)
41 changes: 31 additions & 10 deletions posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,29 @@
import json
from gzip import GzipFile
from requests.auth import HTTPBasicAuth
from requests import sessions
import requests
from io import BytesIO

from posthog.version import VERSION
from posthog.utils import remove_trailing_slash

_session = sessions.Session()
_session = requests.sessions.Session()

DEFAULT_HOST = 'https://app.posthog.com'
USER_AGENT = 'posthog-python/' + VERSION

def post(api_key, host=None, gzip=False, timeout=15, **kwargs):
"""Post the `kwargs` to the API"""
log = logging.getLogger('posthog')
body = kwargs
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
url = remove_trailing_slash(host or 'https://t.posthog.com') + '/batch/'
url = remove_trailing_slash(host or DEFAULT_HOST) + '/batch/'
body['api_key'] = api_key
data = json.dumps(body, cls=DatetimeSerializer)
log.debug('making request: %s', data)
headers = {
'Content-Type': 'application/json',
'User-Agent': 'analytics-python/' + VERSION
'User-Agent': USER_AGENT
}
if gzip:
headers['Content-Encoding'] = 'gzip'
Expand All @@ -45,21 +47,40 @@ def post(api_key, host=None, gzip=False, timeout=15, **kwargs):
try:
payload = res.json()
log.debug('received response: %s', payload)
raise APIError(res.status_code, payload['code'], payload['message'])
raise APIError(res.status_code, payload['detail'])
except ValueError:
raise APIError(res.status_code, 'unknown', res.text)
raise APIError(res.status_code, res.text)

def get(api_key, url, host=None, timeout=None):
log = logging.getLogger('posthog')
url = remove_trailing_slash(host or DEFAULT_HOST) + url
response = requests.get(
url,
headers={
'Authorization': 'Bearer %s' % api_key,
'User-Agent': USER_AGENT
},
timeout=timeout
)
if response.status_code == 200:
return response.json()
try:
payload = response.json()
log.debug('received response: %s', payload)
raise APIError(response.status_code, payload['detail'])
except ValueError:
raise APIError(response.status_code, response.text)


class APIError(Exception):

def __init__(self, status, code, message):
def __init__(self, status, message):
self.message = message
self.status = status
self.code = code

def __str__(self):
msg = "[PostHog] {0}: {1} ({2})"
return msg.format(self.code, self.message, self.status)
msg = "[PostHog] {0} ({1})"
return msg.format(self.message, self.status)


class DatetimeSerializer(json.JSONEncoder):
Expand Down
Loading