Skip to content

Commit 2c6b675

Browse files
authored
feat(feature-flags): Enable local evaluation of flags (#68)
1 parent 3a6fd07 commit 2c6b675

File tree

8 files changed

+3471
-209
lines changed

8 files changed

+3471
-209
lines changed

example.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,13 @@
5252
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
5353

5454
# posthog.shutdown()
55+
56+
# #############################################################################
57+
# Make sure you have a personal API key for the examples below
58+
59+
# Local Evaluation
60+
61+
# If flag has City=Sydney, this call doesn't go to `/decide`
62+
print(posthog.feature_enabled("test-flag", "distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}))
63+
64+
print(posthog.get_all_flags("distinct_id_random_22"))

posthog/__init__.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ def feature_enabled(
236236
distinct_id, # type: str,
237237
default=False, # type: bool
238238
groups={}, # type: dict
239+
person_properties={}, # type: dict
240+
group_properties={}, # type: dict
239241
):
240242
# type: (...) -> bool
241243
"""
@@ -251,10 +253,25 @@ def feature_enabled(
251253
252254
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
253255
"""
254-
return _proxy("feature_enabled", key=key, distinct_id=distinct_id, default=default, groups=groups)
256+
return _proxy(
257+
"feature_enabled",
258+
key=key,
259+
distinct_id=distinct_id,
260+
default=default,
261+
groups=groups,
262+
person_properties=person_properties,
263+
group_properties=group_properties,
264+
)
255265

256266

257-
def get_feature_flag(key, distinct_id, groups):
267+
def get_feature_flag(
268+
key, # type: str,
269+
distinct_id, # type: str,
270+
default=False, # type: bool
271+
groups={}, # type: dict
272+
person_properties={}, # type: dict
273+
group_properties={}, # type: dict
274+
):
258275
"""
259276
Get feature flag variant for users. Used with experiments.
260277
Example:
@@ -264,8 +281,52 @@ def get_feature_flag(key, distinct_id, groups):
264281
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'control':
265282
# do control code
266283
```
284+
285+
`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",
286+
you would pass groups={"organization": "5"}.
287+
288+
`group_properties` take the format: { group_type_name: { group_properties } }
289+
290+
So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count,
291+
you'll send these as:
292+
293+
```python
294+
group_properties={"organization": {"name": "PostHog", "employees": 11}}
295+
```
267296
"""
268-
return _proxy("get_feature_flag", key=key, distinct_id=distinct_id, groups=groups)
297+
return _proxy(
298+
"get_feature_flag",
299+
key=key,
300+
distinct_id=distinct_id,
301+
default=default,
302+
groups=groups,
303+
person_properties=person_properties,
304+
group_properties=group_properties,
305+
)
306+
307+
308+
def get_all_flags(
309+
distinct_id, # type: str,
310+
groups={}, # type: dict
311+
person_properties={}, # type: dict
312+
group_properties={}, # type: dict
313+
):
314+
"""
315+
Get all flags for a given user.
316+
Example:
317+
```python
318+
flags = posthog.get_all_flags('distinct_id')
319+
```
320+
321+
flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
322+
"""
323+
return _proxy(
324+
"get_all_flags",
325+
distinct_id=distinct_id,
326+
groups=groups,
327+
person_properties=person_properties,
328+
group_properties=group_properties,
329+
)
269330

270331

271332
def page(*args, **kwargs):

posthog/client.py

Lines changed: 121 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import atexit
2-
import hashlib
32
import logging
43
import numbers
4+
from collections import defaultdict
55
from datetime import datetime, timedelta
6+
from tokenize import group
67
from uuid import UUID, uuid4
78

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

1112
from posthog.consumer import Consumer
13+
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
1214
from posthog.poller import Poller
1315
from posthog.request import APIError, batch_post, decide, get
14-
from posthog.utils import clean, guess_timezone
16+
from posthog.utils import SizeLimitedDict, clean, guess_timezone
1517
from posthog.version import VERSION
1618

1719
try:
@@ -21,7 +23,7 @@
2123

2224

2325
ID_TYPES = (numbers.Number, string_types, UUID)
24-
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
26+
MAX_DICT_SIZE = 100_000
2527

2628

2729
class Client(object):
@@ -64,8 +66,10 @@ def __init__(
6466
self.gzip = gzip
6567
self.timeout = timeout
6668
self.feature_flags = None
69+
self.group_type_mapping = None
6770
self.poll_interval = poll_interval
6871
self.poller = None
72+
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
6973

7074
# personal_api_key: This should be a generated Personal API Key, private
7175
self.personal_api_key = personal_api_key
@@ -120,8 +124,7 @@ def identify(self, distinct_id=None, properties=None, context=None, timestamp=No
120124

121125
return self._enqueue(msg)
122126

123-
def get_feature_variants(self, distinct_id, groups=None):
124-
assert self.personal_api_key, "You have to specify a personal_api_key to use feature flags."
127+
def get_feature_variants(self, distinct_id, groups=None, person_properties=None, group_properties=None):
125128
require("distinct_id", distinct_id, ID_TYPES)
126129

127130
if groups:
@@ -131,8 +134,9 @@ def get_feature_variants(self, distinct_id, groups=None):
131134

132135
request_data = {
133136
"distinct_id": distinct_id,
134-
"personal_api_key": self.personal_api_key,
135137
"groups": groups,
138+
"person_properties": person_properties,
139+
"group_properties": group_properties,
136140
}
137141
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
138142
return resp_data["featureFlags"]
@@ -352,8 +356,12 @@ def shutdown(self):
352356

353357
def _load_feature_flags(self):
354358
try:
355-
flags = get(self.personal_api_key, f"/api/feature_flag/?token={self.api_key}", self.host)["results"]
356-
self.feature_flags = [flag for flag in flags if flag["active"]]
359+
response = get(
360+
self.personal_api_key, f"/api/feature_flag/local_evaluation/?token={self.api_key}", self.host
361+
)
362+
self.feature_flags = [flag for flag in response["flags"] if flag["active"]]
363+
self.group_type_mapping = response["group_type_mapping"]
364+
357365
except APIError as e:
358366
if e.status == 401:
359367
raise APIError(
@@ -384,7 +392,51 @@ def load_feature_flags(self):
384392
self.poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
385393
self.poller.start()
386394

387-
def feature_enabled(self, key, distinct_id, default=False, *, groups={}):
395+
def _compute_flag_locally(self, feature_flag, distinct_id, *, groups={}, person_properties={}, group_properties={}):
396+
397+
if feature_flag.get("ensure_experience_continuity", False):
398+
raise InconclusiveMatchError("Flag has experience continuity enabled")
399+
400+
flag_filters = feature_flag.get("filters") or {}
401+
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
402+
if aggregation_group_type_index is not None:
403+
group_name = self.group_type_mapping.get(str(aggregation_group_type_index))
404+
405+
if not group_name:
406+
self.log.warning(
407+
f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}"
408+
)
409+
# failover to `/decide/`
410+
raise InconclusiveMatchError("Flag has unknown group type index")
411+
412+
if group_name not in groups:
413+
# Group flags are never enabled in `groups` aren't passed in
414+
# don't failover to `/decide/`, since response will be the same
415+
self.log.warning(
416+
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
417+
)
418+
return False
419+
420+
focused_group_properties = group_properties[group_name]
421+
return match_feature_flag_properties(feature_flag, groups[group_name], focused_group_properties)
422+
else:
423+
return match_feature_flag_properties(feature_flag, distinct_id, person_properties)
424+
425+
def feature_enabled(self, key, distinct_id, default=False, *, groups={}, person_properties={}, group_properties={}):
426+
return bool(
427+
self.get_feature_flag(
428+
key,
429+
distinct_id,
430+
default,
431+
groups=groups,
432+
person_properties=person_properties,
433+
group_properties=group_properties,
434+
)
435+
)
436+
437+
def get_feature_flag(
438+
self, key, distinct_id, default=False, *, groups={}, person_properties={}, group_properties={}
439+
):
388440
require("key", key, string_types)
389441
require("distinct_id", distinct_id, ID_TYPES)
390442
require("groups", groups, dict)
@@ -397,45 +449,78 @@ def feature_enabled(self, key, distinct_id, default=False, *, groups={}):
397449
if self.feature_flags:
398450
for flag in self.feature_flags:
399451
if flag["key"] == key:
400-
feature_flag = flag
401-
if feature_flag.get("is_simple_flag"):
402-
rollout_percentage = (
403-
feature_flag.get("rollout_percentage")
404-
if feature_flag.get("rollout_percentage") is not None
405-
else 100
452+
try:
453+
response = self._compute_flag_locally(
454+
flag,
455+
distinct_id,
456+
groups=groups,
457+
person_properties=person_properties,
458+
group_properties=group_properties,
406459
)
407-
response = _hash(key, distinct_id) <= (rollout_percentage / 100)
408-
if response == None:
460+
except InconclusiveMatchError as e:
461+
# No need to log this, since it's just telling us to fall back to `/decide`
462+
continue
463+
except Exception as e:
464+
self.log.exception(f"[FEATURE FLAGS] Error while computing variant: {e}")
465+
continue
466+
467+
if response is None:
409468
try:
410-
feature_flags = self.get_feature_variants(distinct_id, groups=groups)
469+
feature_flags = self.get_feature_variants(
470+
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
471+
)
472+
response = feature_flags.get(key)
411473
except Exception as e:
412474
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
413475
response = default
414-
else:
415-
response = True if feature_flags.get(key) else default
416-
self.capture(distinct_id, "$feature_flag_called", {"$feature_flag": key, "$feature_flag_response": response})
476+
477+
if key not in self.distinct_ids_feature_flags_reported[distinct_id]:
478+
self.capture(
479+
distinct_id, "$feature_flag_called", {"$feature_flag": key, "$feature_flag_response": response}
480+
)
481+
self.distinct_ids_feature_flags_reported[distinct_id].add(key)
417482
return response
418483

419-
def get_feature_flag(self, key, distinct_id, groups={}):
420-
require("key", key, string_types)
484+
def get_all_flags(self, distinct_id, *, groups={}, person_properties={}, group_properties={}):
421485
require("distinct_id", distinct_id, ID_TYPES)
422486
require("groups", groups, dict)
423487

424-
variants = self.get_feature_variants(distinct_id, groups=groups)
425-
self.capture(
426-
distinct_id, "$feature_flag_called", {"$feature_flag": key, "$feature_flag_response": variants.get(key)}
427-
)
428-
return variants.get(key)
488+
if self.feature_flags == None and self.personal_api_key:
489+
self.load_feature_flags()
490+
491+
response = {}
492+
fallback_to_decide = False
493+
494+
# If loading in previous line failed
495+
if self.feature_flags:
496+
for flag in self.feature_flags:
497+
try:
498+
response[flag["key"]] = self._compute_flag_locally(
499+
flag,
500+
distinct_id,
501+
groups=groups,
502+
person_properties=person_properties,
503+
group_properties=group_properties,
504+
)
505+
except InconclusiveMatchError as e:
506+
# No need to log this, since it's just telling us to fall back to `/decide`
507+
fallback_to_decide = True
508+
except Exception as e:
509+
self.log.exception(f"[FEATURE FLAGS] Error while computing variant: {e}")
510+
fallback_to_decide = True
511+
else:
512+
fallback_to_decide = True
429513

514+
if fallback_to_decide:
515+
try:
516+
feature_flags = self.get_feature_variants(
517+
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
518+
)
519+
response = {**response, **feature_flags}
520+
except Exception as e:
521+
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
430522

431-
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
432-
# Given the same distinct_id and key, it'll always return the same float. These floats are
433-
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
434-
# we can do _hash(key, distinct_id) < 0.2
435-
def _hash(key, distinct_id):
436-
hash_key = "%s.%s" % (key, distinct_id)
437-
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
438-
return hash_val / __LONG_SCALE__
523+
return response
439524

440525

441526
def require(name, field, data_type):

0 commit comments

Comments
 (0)