Skip to content
This repository has been archived by the owner on Nov 9, 2017. It is now read-only.

Commit

Permalink
Expose feature flags data via API
Browse files Browse the repository at this point in the history
We need to access experiment variant assignments in the mobile web
client, and in general it would be useful to condition client features
on server-provided feature flags. We expose only the enabled flags and
only the experiments for which non-null variants have been assigned. We
expose the feature data through /api/me.json and /api/v1/me.
  • Loading branch information
prashtx committed May 17, 2016
1 parent 552145d commit 5f7a822
Show file tree
Hide file tree
Showing 7 changed files with 72 additions and 8 deletions.
2 changes: 1 addition & 1 deletion r2/r2/config/feature/__init__.py
Expand Up @@ -20,4 +20,4 @@
# Inc. All Rights Reserved.
###############################################################################

from r2.config.feature.feature import is_enabled, variant
from r2.config.feature.feature import is_enabled, variant, all_enabled
34 changes: 34 additions & 0 deletions r2/r2/config/feature/feature.py
Expand Up @@ -73,6 +73,40 @@ def variant(name, user=None):

return _get_featurestate(name).variant(user)

def all_enabled(user=None):
"""Return a list of enabled features and experiments for the user.
Provides the user's assigned variant and the experiment ID for experiments.
This does not trigger bucketing events, so it should not be used for
feature flagging purposes on the server. It is meant to let clients
condition features on experiment variants. Those clients should manually
send the appropriate bucketing events.
:param user - (optional) an Account. Defaults to None, for which we
determine logged-out features.
:return dict - a dictionary mapping enabled feature keys to True or to the
experiment/variant information
"""
features = FeatureState.get_all(_world)

# Get enabled features and experiments
active = {}
for feature in features:
experiment = feature.config.get('experiment')
if experiment:
# Get experiment names, ids, and assigned variants, leaving out
# experiments for which this user is excluded
variant = feature.variant(user)
if variant:
active[feature.name] = {
'experiment_id': experiment.get('experiment_id'),
'variant': variant
}
elif feature.is_enabled(user):
active[feature.name] = True

return active

@feature_hooks.on('worker.live_config.update')
def clear_featurestate_cache():
Expand Down
28 changes: 23 additions & 5 deletions r2/r2/config/feature/state.py
Expand Up @@ -51,21 +51,23 @@ class FeatureState(object):
# The variant definition for control groups that are added by default.
DEFAULT_CONTROL_GROUPS = {'control_1': 10, 'control_2': 10}

def __init__(self, name, world):
def __init__(self, name, world, config_name=None, config_str=None):
self.name = name
self.world = world
self.config = self._parse_config(name)
self.config = self._parse_config(name, config_name, config_str)

def _parse_config(self, name):
def _parse_config(self, name, config_name=None, config_str=None):
"""Find and parse a config from our live config with this given name.
:param name string - a given feature name
:return dict - a dictionary with at least "enabled". May include more
depending on the enabled type.
"""
config_name = "feature_%s" % name
if not config_name:
config_name = "feature_%s" % name

config_str = self.world.live_config(config_name)
if not config_str:
config_str = self.world.live_config(config_name)

if not config_str or config_str == FeatureState.GLOBALLY_OFF:
return self.DISABLED_CFG
Expand All @@ -86,6 +88,22 @@ def _parse_config(self, name):

return config

@staticmethod
def get_all(world):
"""Return FeatureState objects for all features in live_config.
Creates a FeatureState object for every config entry prefixed with
"feature_".
:param world - World proxy object to the app/request state.
"""
features = []
for (key, config_str) in world.live_config_iteritems():
if key.startswith('feature_'):
feature_state = FeatureState(key[8:], world, key, config_str)
features.append(feature_state)
return features

def _calculate_bucket(self, seed):
"""Sort something into one of self.NUM_BUCKETS buckets.
Expand Down
4 changes: 4 additions & 0 deletions r2/r2/config/feature/world.py
Expand Up @@ -118,6 +118,10 @@ def live_config(self, name):
live = self.stacked_proxy_safe_get(g, 'live_config', {})
return live.get(name)

def live_config_iteritems(self):
live = self.stacked_proxy_safe_get(g, 'live_config', {})
return live.iteritems()

def simple_event(self, name):
stats = self.stacked_proxy_safe_get(g, 'stats', None)
if stats:
Expand Down
6 changes: 4 additions & 2 deletions r2/r2/controllers/api.py
Expand Up @@ -231,9 +231,11 @@ def GET_me(self, responder):
"""
if c.user_is_loggedin:
return Wrapped(c.user).render()
user_data = Wrapped(c.user).render()
user_data['data'].update({'features': feature.all_enabled(c.user)})
return user_data
else:
return {}
return {'data': {'features': feature.all_enabled(None)}}

@json_validate(user=VUname(("user",)))
@api_doc(api_section.users)
Expand Down
3 changes: 3 additions & 0 deletions r2/r2/lib/jsontemplates.py
Expand Up @@ -525,6 +525,7 @@ class IdentityJsonTemplate(ThingJsonTemplate):
gold_expiration="gold_expiration",
is_suspended="in_timeout",
suspension_expiration_utc="timeout_expiration_utc",
features="features",
)
_public_attrs = {
"name",
Expand Down Expand Up @@ -591,6 +592,8 @@ def thing_attr(self, thing, attr):
return None

return calendar.timegm(expiration_date.utctimetuple())
elif attr == "features":
return feature.all_enabled(c.user)

return ThingJsonTemplate.thing_attr(self, thing, attr)

Expand Down
3 changes: 3 additions & 0 deletions r2/r2/lib/zookeeper.py
Expand Up @@ -78,6 +78,9 @@ def __getitem__(self, key):
def get(self, key, default=None):
return self.data.get(key, default)

def iteritems(self):
return self.data.iteritems()

def __repr__(self):
return "<LiveConfig %r>" % self.data

Expand Down

0 comments on commit 5f7a822

Please sign in to comment.