Compare commits

...
20 Commits
Author SHA1 Message Date
James Greenhill b7c1572c32 bump version to 1.1.2 2020-09-30 14:25:49 +01:00
James Greenhill 16acf2e278 better testing and org 2020-09-30 14:20:50 +01:00
James Greenhill df9ae05202 fix tests 2020-09-30 14:10:34 +01:00
James Greenhill b05ee3884a Send distinctID to the decide endpoint to determin if user should have features enabled 2020-09-30 13:53:47 +01:00
James GreenhillandGitHub 144a7744e4 Merge pull request #11 from PostHog/none
Default to False if the feature flag key does not exist. None is confusing
2020-09-29 17:06:53 +01:00
James Greenhill ca979f06c8 type annotations 2020-09-29 17:02:39 +01:00
James Greenhill caa64fb6fe Default to False if the feature flag key does not exist. None is confusing 2020-09-29 16:07:43 +01:00
Tim GlaserandGitHub e007dee07e Merge pull request #10 from PostHog/warn-instead-of-error
Warn instead of error
2020-09-29 13:16:06 +02:00
Tim Glaser 1de9553d61 Fix tests 2020-09-29 12:13:28 +01:00
Tim Glaser d447d170fa Warn instead of error 2020-09-29 11:57:44 +01:00
Tim GlaserandGitHub d92c398c0f Merge pull request #9 from PostHog/feature-flags
Feature flags
2020-09-17 10:17:42 +02:00
Tim Glaser b331c4aae3 Add error handling and timeouts 2020-09-17 10:13:03 +02:00
Yakko Majuri 5e70ca84bb minor changes 2020-09-14 09:37:14 +00:00
Yakko Majuri adef8d4928 fix comment 2020-09-14 08:26:40 +00:00
Tim GlaserandGitHub 8682091eec Merge pull request #8 from PostHog/add-tests
Fix tests and clean up integration
2020-09-11 15:39:51 +02:00
Yakko Majuri 721a6aacf7 fix action 2020-09-11 08:58:14 +00:00
Tim Glaser 72e7e4ad72 typo 2020-09-10 16:52:31 +02:00
Tim Glaser 95d4375663 Fix tests 2020-09-10 16:39:27 +02:00
Tim Glaser 6e39aa0ceb Add poller 2020-09-10 16:35:23 +02:00
Tim Glaser c72ab9a3fd Make feature flags work 2020-09-10 16:30:54 +02:00
11 changed files with 311 additions and 41 deletions
+13 -4
View File
@@ -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'})
+22 -2
View File
@@ -13,6 +13,7 @@ debug = False # type: bool
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
personal_api_key = None # type: str
default_client = None
@@ -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=False, # type: bool
):
# 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."""
@@ -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)
+86 -4
View File
@@ -1,16 +1,19 @@
from datetime import datetime
from datetime import datetime, timedelta
from uuid import uuid4
import logging
import numbers
import atexit
import hashlib
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 post
from posthog.request import batch_post, decide, get, APIError
from posthog.version import VERSION
from posthog.poller import Poller
try:
import queue
@@ -19,6 +22,7 @@ except ImportError:
ID_TYPES = (numbers.Number, string_types)
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
class Client(object):
@@ -28,10 +32,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
@@ -40,6 +46,11 @@ class Client(object):
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)
@@ -178,7 +189,7 @@ class Client(object):
if self.sync_mode:
self.log.debug('enqueued with blocking %s.', msg['event'])
post(self.api_key, self.host, gzip=self.gzip,
batch_post(self.api_key, self.host, gzip=self.gzip,
timeout=self.timeout, batch=[msg])
return True, msg
@@ -216,6 +227,77 @@ class Client(object):
self.flush()
self.join()
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.feature_flags = []
return
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)
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:
self.load_feature_flags()
# If loading in previous line failed
if not self.feature_flags:
response = default
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_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`"""
+2 -2
View File
@@ -4,7 +4,7 @@ import monotonic
import backoff
import json
from posthog.request import post, APIError, DatetimeSerializer
from posthog.request import batch_post, APIError, DatetimeSerializer
try:
from queue import Empty
@@ -128,7 +128,7 @@ class Consumer(Thread):
max_tries=self.retries + 1,
giveup=fatal_exception)
def send_request():
post(self.api_key, self.host, gzip=self.gzip,
batch_post(self.api_key, self.host, gzip=self.gzip,
timeout=self.timeout, batch=batch)
send_request()
+19
View File
@@ -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)
+58 -11
View File
@@ -4,27 +4,30 @@ import logging
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):
def post(api_key, host=None, path=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) + path
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'
@@ -42,24 +45,68 @@ def post(api_key, host=None, gzip=False, timeout=15, **kwargs):
log.debug('data uploaded successfully')
return res
def decide(api_key, host=None, gzip=False, timeout=15, **kwargs):
"""Post the `kwargs to the decide API endpoint"""
log = logging.getLogger('posthog')
res = post(api_key, host, '/decide/', gzip, timeout, **kwargs)
if res.status_code == 200:
log.debug('Feature flags decided successfully')
return res.json()
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 batch_post(api_key, host=None, gzip=False, timeout=15, **kwargs):
"""Post the `kwargs` to the batch API endpoint for events"""
log = logging.getLogger('posthog')
res = post(api_key, host, '/batch/', gzip, timeout, **kwargs)
if res.status_code == 200:
log.debug('data uploaded successfully')
return res
try:
payload = res.json()
log.debug('received response: %s', payload)
raise APIError(res.status_code, payload['detail'])
except ValueError:
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):
+95 -3
View File
@@ -3,21 +3,24 @@ 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 fail(self, e, batch):
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.fail)
self.client = Client(TEST_API_KEY, on_error=self.set_fail)
def test_requires_api_key(self):
self.assertRaises(AssertionError, Client)
@@ -205,7 +208,7 @@ class TestClient(unittest.TestCase):
# the post function should be called 2 times, with a batch size of 10
# each time.
with mock.patch('posthog.consumer.post', side_effect=mock_post_fn) \
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'})
@@ -221,3 +224,92 @@ class TestClient(unittest.TestCase):
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'))
+7 -7
View File
@@ -60,7 +60,7 @@ class TestConsumer(unittest.TestCase):
flush_interval = 0.3
consumer = Consumer(q, TEST_API_KEY, flush_at=10,
flush_interval=flush_interval)
with mock.patch('posthog.consumer.post') as mock_post:
with mock.patch('posthog.consumer.batch_post') as mock_post:
consumer.start()
for i in range(0, 3):
track = {
@@ -80,7 +80,7 @@ class TestConsumer(unittest.TestCase):
flush_at = 10
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at,
flush_interval=flush_interval)
with mock.patch('posthog.consumer.post') as mock_post:
with mock.patch('posthog.consumer.batch_post') as mock_post:
consumer.start()
for i in range(0, flush_at * 2):
track = {
@@ -110,7 +110,7 @@ class TestConsumer(unittest.TestCase):
raise expected_exception
mock_post.call_count = 0
with mock.patch('posthog.consumer.post',
with mock.patch('posthog.consumer.batch_post',
mock.Mock(side_effect=mock_post)):
track = {
'type': 'track',
@@ -142,16 +142,16 @@ class TestConsumer(unittest.TestCase):
# we should retry on server errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, APIError(
500, 'code', 'Internal Server Error'), 2)
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, 'code', 'Too Many Requests'), 2)
429, 'Too Many Requests'), 2)
# we should NOT retry on other client errors
consumer = Consumer(None, TEST_API_KEY)
api_error = APIError(400, 'code', 'Client Errors')
api_error = APIError(400, 'Client Errors')
try:
self._test_request_retry(consumer, api_error, 1)
except APIError:
@@ -162,7 +162,7 @@ class TestConsumer(unittest.TestCase):
# test for number of exceptions raise > retries value
consumer = Consumer(None, TEST_API_KEY, retries=3)
self._test_request_retry(consumer, APIError(
500, 'code', 'Internal Server Error'), 3)
500, 'Internal Server Error'), 3)
def test_pause(self):
consumer = Consumer(None, TEST_API_KEY)
+6 -6
View File
@@ -3,14 +3,14 @@ import unittest
import json
import requests
from posthog.request import post, DatetimeSerializer
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 = post(TEST_API_KEY, batch=[{
res = batch_post(TEST_API_KEY, batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
@@ -18,11 +18,11 @@ class TestRequests(unittest.TestCase):
self.assertEqual(res.status_code, 200)
def test_invalid_request_error(self):
self.assertRaises(Exception, post, 'testsecret',
self.assertRaises(Exception, batch_post, 'testsecret',
'https://t.posthog.com', False, '[{]')
def test_invalid_host(self):
self.assertRaises(Exception, post, 'testsecret',
self.assertRaises(Exception, batch_post, 'testsecret',
't.posthog.com/', batch=[])
def test_datetime_serialization(self):
@@ -38,7 +38,7 @@ class TestRequests(unittest.TestCase):
self.assertEqual(result, expected)
def test_should_not_timeout(self):
res = post(TEST_API_KEY, batch=[{
res = batch_post(TEST_API_KEY, batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
@@ -47,7 +47,7 @@ class TestRequests(unittest.TestCase):
def test_should_timeout(self):
with self.assertRaises(requests.ReadTimeout):
post('key', batch=[{
batch_post('key', batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
+1 -1
View File
@@ -1 +1 @@
VERSION = '1.0.11'
VERSION = '1.1.2'
+2 -1
View File
@@ -24,7 +24,8 @@ install_requires = [
]
tests_require = [
"mock>=2.0.0"
"mock>=2.0.0",
"freezegun==0.3.15"
]
setup(