Skip to content

Commit d92c398

Browse files
authored
Merge pull request #9 from PostHog/feature-flags
Feature flags
2 parents 8682091 + b331c4a commit d92c398

File tree

8 files changed

+254
-26
lines changed

8 files changed

+254
-26
lines changed

example.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,28 @@
22

33
# Import the library
44
import posthog
5+
import time
56

67
# You can find this key on the /setup page in PostHog
7-
posthog.api_key = '<your key>'
8+
posthog.api_key = ''
9+
posthog.personal_api_key = ''
810

911
# Where you host PostHog, with no trailing /.
1012
# You can remove this line if you're using posthog.com
11-
# posthog.host = 'http://127.0.0.1:8000'
13+
posthog.host = 'http://127.0.0.1:8000'
1214

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

18+
print(posthog.feature_enabled('beta-feature', 'distinct_id'))
19+
20+
print('sleeping')
21+
time.sleep(45)
22+
23+
print(posthog.feature_enabled('beta-feature', 'distinct_id'))
24+
1625
# # Alias a previous distinct id with a new one
17-
# posthog.alias('distinct_id', 'new_distinct_id')
26+
posthog.alias('distinct_id', 'new_distinct_id')
1827

1928
# # Add properties to the person
20-
# posthog.identify('distinct_id', {'email': '[email protected]'})
29+
posthog.identify('distinct_id', {'email': '[email protected]'})

posthog/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
send = True # type: bool
1414
sync_mode = False # type: bool
1515
disabled = False # type: bool
16+
personal_api_key = None # type: str
1617

1718
default_client = None
1819

@@ -100,6 +101,25 @@ def alias(
100101
"""
101102
_proxy('alias', previous_id=previous_id, distinct_id=distinct_id, context=context, timestamp=timestamp, message_id=message_id)
102103

104+
def feature_enabled(
105+
key, # type: str,
106+
distinct_id, # type: str,
107+
default=None, # type: Optional[Any]
108+
):
109+
# type: (...) -> bool
110+
"""
111+
Use feature flags to enable or disable features for users.
112+
113+
For example:
114+
```python
115+
if posthog.feature_enabled('beta feature', 'distinct id'):
116+
# do something
117+
```
118+
119+
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
120+
"""
121+
return _proxy('feature_enabled', key=key, distinct_id=distinct_id, default=default)
122+
103123

104124
def page(*args, **kwargs):
105125
"""Send a page call."""
@@ -135,7 +155,7 @@ def _proxy(method, *args, **kwargs):
135155
if not default_client:
136156
default_client = Client(api_key, host=host, debug=debug,
137157
on_error=on_error, send=send,
138-
sync_mode=sync_mode)
158+
sync_mode=sync_mode, personal_api_key=personal_api_key)
139159

140160
fn = getattr(default_client, method)
141-
fn(*args, **kwargs)
161+
return fn(*args, **kwargs)

posthog/client.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
from datetime import datetime
1+
from datetime import datetime, timedelta
22
from uuid import uuid4
33
import logging
44
import numbers
55
import atexit
6+
import hashlib
67

78
from dateutil.tz import tzutc
89
from six import string_types
910

1011
from posthog.utils import guess_timezone, clean
1112
from posthog.consumer import Consumer
12-
from posthog.request import post
13+
from posthog.request import post, get, APIError
1314
from posthog.version import VERSION
15+
from posthog.poller import Poller
1416

1517
try:
1618
import queue
@@ -19,6 +21,7 @@
1921

2022

2123
ID_TYPES = (numbers.Number, string_types)
24+
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
2225

2326

2427
class Client(object):
@@ -28,10 +31,12 @@ class Client(object):
2831
def __init__(self, api_key=None, host=None, debug=False,
2932
max_queue_size=10000, send=True, on_error=None, flush_at=100,
3033
flush_interval=0.5, gzip=False, max_retries=3,
31-
sync_mode=False, timeout=15, thread=1):
34+
sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None):
3235
require('api_key', api_key, string_types)
3336

3437
self.queue = queue.Queue(max_queue_size)
38+
39+
# api_key: This should be the Team API Key (token), public
3540
self.api_key = api_key
3641
self.on_error = on_error
3742
self.debug = debug
@@ -40,6 +45,11 @@ def __init__(self, api_key=None, host=None, debug=False,
4045
self.host = host
4146
self.gzip = gzip
4247
self.timeout = timeout
48+
self.feature_flags = None
49+
self.poll_interval = poll_interval
50+
51+
# personal_api_key: This should be a generated Personal API Key, private
52+
self.personal_api_key = personal_api_key
4353

4454
if debug:
4555
self.log.setLevel(logging.DEBUG)
@@ -216,6 +226,72 @@ def shutdown(self):
216226
self.flush()
217227
self.join()
218228

229+
def _load_feature_flags(self):
230+
if not self.personal_api_key:
231+
raise ValueError('You have to specify a personal_api_key to use feature flags.')
232+
233+
try:
234+
self.feature_flags = get(self.personal_api_key, '/api/feature_flag/', self.host)['results']
235+
except APIError as e:
236+
if e.status == 401:
237+
raise APIError(
238+
status=401,
239+
message='You are using a write-only key with feature flags. ' \
240+
'To use feature flags, please set a personal_api_key ' \
241+
'More information: https://posthog.com/docs/api/overview'
242+
)
243+
except Exception as e:
244+
self.log.warning('[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds.' % self.poll_interval)
245+
self.log.warning(e)
246+
247+
self._last_feature_flag_poll = datetime.utcnow().replace(tzinfo=tzutc())
248+
249+
def load_feature_flags(self):
250+
self._load_feature_flags()
251+
poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
252+
poller.start()
253+
254+
def feature_enabled(self, key, distinct_id, default=False):
255+
require('key', key, string_types)
256+
require('distinct_id', distinct_id, ID_TYPES)
257+
error = False
258+
259+
if not self.feature_flags:
260+
self.load_feature_flags()
261+
262+
# If loading in previous line failed
263+
if not self.feature_flags:
264+
response = default
265+
error = True
266+
else:
267+
try:
268+
feature_flag = [flag for flag in self.feature_flags if flag['key'] == key][0]
269+
except IndexError:
270+
return default
271+
272+
if feature_flag.get('is_simple_flag'):
273+
response = _hash(key, distinct_id) <= (feature_flag['rollout_percentage'] / 100)
274+
else:
275+
try:
276+
request = get(self.api_key, '/decide/', self.host, timeout=1)
277+
response = key in request['featureFlags']
278+
except Exception as e:
279+
response = default
280+
self.log.warning('[FEATURE FLAGS] Unable to get data for flag %s, because of the following error:' % key)
281+
self.log.warning(e)
282+
error = True
283+
284+
self.capture(distinct_id, '$feature_flag_called', {'$feature_flag': key, '$feature_flag_response': response})
285+
return response
286+
287+
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
288+
# Given the same distinct_id and key, it'll always return the same float. These floats are
289+
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
290+
# we can do _hash(key, distinct_id) < 0.2
291+
def _hash(key, distinct_id):
292+
hash_key = "%s.%s" % (key, distinct_id)
293+
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
294+
return hash_val / __LONG_SCALE__
219295

220296
def require(name, field, data_type):
221297
"""Require that the named `field` has the right `data_type`"""

posthog/poller.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import threading
2+
3+
class Poller(threading.Thread):
4+
def __init__(self, interval, execute, *args, **kwargs):
5+
threading.Thread.__init__(self)
6+
self.daemon = False
7+
self.stopped = threading.Event()
8+
self.interval = interval
9+
self.execute = execute
10+
self.args = args
11+
self.kwargs = kwargs
12+
13+
def stop(self):
14+
self.stopped.set()
15+
self.join()
16+
17+
def run(self):
18+
while not self.stopped.wait(self.interval.total_seconds()):
19+
self.execute(*self.args, **self.kwargs)

posthog/request.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,29 @@
44
import json
55
from gzip import GzipFile
66
from requests.auth import HTTPBasicAuth
7-
from requests import sessions
7+
import requests
88
from io import BytesIO
99

1010
from posthog.version import VERSION
1111
from posthog.utils import remove_trailing_slash
1212

13-
_session = sessions.Session()
13+
_session = requests.sessions.Session()
1414

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

1618
def post(api_key, host=None, gzip=False, timeout=15, **kwargs):
1719
"""Post the `kwargs` to the API"""
1820
log = logging.getLogger('posthog')
1921
body = kwargs
2022
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
21-
url = remove_trailing_slash(host or 'https://t.posthog.com') + '/batch/'
23+
url = remove_trailing_slash(host or DEFAULT_HOST) + '/batch/'
2224
body['api_key'] = api_key
2325
data = json.dumps(body, cls=DatetimeSerializer)
2426
log.debug('making request: %s', data)
2527
headers = {
2628
'Content-Type': 'application/json',
27-
'User-Agent': 'analytics-python/' + VERSION
29+
'User-Agent': USER_AGENT
2830
}
2931
if gzip:
3032
headers['Content-Encoding'] = 'gzip'
@@ -45,21 +47,40 @@ def post(api_key, host=None, gzip=False, timeout=15, **kwargs):
4547
try:
4648
payload = res.json()
4749
log.debug('received response: %s', payload)
48-
raise APIError(res.status_code, payload['code'], payload['message'])
50+
raise APIError(res.status_code, payload['detail'])
4951
except ValueError:
50-
raise APIError(res.status_code, 'unknown', res.text)
52+
raise APIError(res.status_code, res.text)
53+
54+
def get(api_key, url, host=None, timeout=None):
55+
log = logging.getLogger('posthog')
56+
url = remove_trailing_slash(host or DEFAULT_HOST) + url
57+
response = requests.get(
58+
url,
59+
headers={
60+
'Authorization': 'Bearer %s' % api_key,
61+
'User-Agent': USER_AGENT
62+
},
63+
timeout=timeout
64+
)
65+
if response.status_code == 200:
66+
return response.json()
67+
try:
68+
payload = response.json()
69+
log.debug('received response: %s', payload)
70+
raise APIError(response.status_code, payload['detail'])
71+
except ValueError:
72+
raise APIError(response.status_code, response.text)
5173

5274

5375
class APIError(Exception):
5476

55-
def __init__(self, status, code, message):
77+
def __init__(self, status, message):
5678
self.message = message
5779
self.status = status
58-
self.code = code
5980

6081
def __str__(self):
61-
msg = "[PostHog] {0}: {1} ({2})"
62-
return msg.format(self.code, self.message, self.status)
82+
msg = "[PostHog] {0} ({1})"
83+
return msg.format(self.message, self.status)
6384

6485

6586
class DatetimeSerializer(json.JSONEncoder):

0 commit comments

Comments
 (0)