diff --git a/r2/r2/config/feature/__init__.py b/r2/r2/config/feature/__init__.py index d548f983e5..8608ff03cc 100644 --- a/r2/r2/config/feature/__init__.py +++ b/r2/r2/config/feature/__init__.py @@ -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 diff --git a/r2/r2/config/feature/feature.py b/r2/r2/config/feature/feature.py index 2077b787df..c9e5726a5e 100644 --- a/r2/r2/config/feature/feature.py +++ b/r2/r2/config/feature/feature.py @@ -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(): diff --git a/r2/r2/config/feature/state.py b/r2/r2/config/feature/state.py index 4e44ec6ae3..feefd16cbe 100644 --- a/r2/r2/config/feature/state.py +++ b/r2/r2/config/feature/state.py @@ -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 @@ -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. diff --git a/r2/r2/config/feature/world.py b/r2/r2/config/feature/world.py index 2e3ee685f9..6dc1b44042 100644 --- a/r2/r2/config/feature/world.py +++ b/r2/r2/config/feature/world.py @@ -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: diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index f26a3b401e..cd325bb440 100644 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -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) diff --git a/r2/r2/lib/jsontemplates.py b/r2/r2/lib/jsontemplates.py index b8ef1f8a47..f98b472a80 100644 --- a/r2/r2/lib/jsontemplates.py +++ b/r2/r2/lib/jsontemplates.py @@ -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", @@ -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) diff --git a/r2/r2/lib/zookeeper.py b/r2/r2/lib/zookeeper.py index d9ceda4fe6..0ea3c56162 100644 --- a/r2/r2/lib/zookeeper.py +++ b/r2/r2/lib/zookeeper.py @@ -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 "" % self.data