Skip to content

Commit

Permalink
Merge pull request #99 from launchdarkly/eb/ch32305/experiment
Browse files Browse the repository at this point in the history
add experimentation event overrides for rules and fallthrough
  • Loading branch information
eli-darkly authored Feb 28, 2019
2 parents c7a67dc + afab05d commit e0c563c
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 68 deletions.
35 changes: 15 additions & 20 deletions ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ldclient.feature_store import _FeatureStoreDataSetSorter
from ldclient.flag import EvaluationDetail, evaluate, error_reason
from ldclient.flags_state import FeatureFlagsState
from ldclient.impl.event_factory import _EventFactory
from ldclient.interfaces import FeatureStore
from ldclient.polling import PollingUpdateProcessor
from ldclient.streaming import StreamingUpdateProcessor
Expand Down Expand Up @@ -90,6 +91,8 @@ def __init__(self, sdk_key=None, config=None, start_wait=5):

self._event_processor = None
self._lock = Lock()
self._event_factory_default = _EventFactory(False)
self._event_factory_with_reasons = _EventFactory(True)

self._store = _FeatureStoreClientWrapper(self._config.feature_store)
""" :type: FeatureStore """
Expand Down Expand Up @@ -177,7 +180,7 @@ def track(self, event_name, user, data=None):
self._sanitize_user(user)
if user is None or user.get('key') is None:
log.warn("Missing user or user key when calling track().")
self._send_event({'kind': 'custom', 'key': event_name, 'user': user, 'data': data})
self._send_event(self._event_factory_default.new_custom_event(event_name, user, data))

def identify(self, user):
"""Registers the user.
Expand All @@ -191,7 +194,7 @@ def identify(self, user):
self._sanitize_user(user)
if user is None or user.get('key') is None:
log.warn("Missing user or user key when calling identify().")
self._send_event({'kind': 'identify', 'key': user.get('key'), 'user': user})
self._send_event(self._event_factory_default.new_identify_event(user))

def is_offline(self):
"""Returns true if the client is in offline mode.
Expand Down Expand Up @@ -241,7 +244,7 @@ def variation(self, key, user, default):
available from LaunchDarkly
:return: one of the flag's variation values, or the default value
"""
return self._evaluate_internal(key, user, default, False).value
return self._evaluate_internal(key, user, default, self._event_factory_default).value

def variation_detail(self, key, user, default):
"""Determines the variation of a feature flag for a user, like :func:`variation()`, but also
Expand All @@ -258,9 +261,9 @@ def variation_detail(self, key, user, default):
:return: an object describing the result
:rtype: EvaluationDetail
"""
return self._evaluate_internal(key, user, default, True)
return self._evaluate_internal(key, user, default, self._event_factory_with_reasons)

def _evaluate_internal(self, key, user, default, include_reasons_in_events):
def _evaluate_internal(self, key, user, default, event_factory):
default = self._config.get_default(key, default)

if self._config.offline:
Expand All @@ -269,22 +272,14 @@ def _evaluate_internal(self, key, user, default, include_reasons_in_events):
if user is not None:
self._sanitize_user(user)

def send_event(value, variation=None, flag=None, reason=None):
self._send_event({'kind': 'feature', 'key': key, 'user': user,
'value': value, 'variation': variation, 'default': default,
'version': flag.get('version') if flag else None,
'trackEvents': flag.get('trackEvents') if flag else None,
'debugEventsUntilDate': flag.get('debugEventsUntilDate') if flag else None,
'reason': reason if include_reasons_in_events else None})

if not self.is_initialized():
if self._store.initialized:
log.warn("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
else:
log.warn("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: "
+ str(default) + " for feature key: " + key)
reason = error_reason('CLIENT_NOT_READY')
send_event(default, None, None, reason)
self._send_event(event_factory.new_unknown_flag_event(key, user, default, reason))
return EvaluationDetail(default, None, reason)

if user is not None and user.get('key', "") == "":
Expand All @@ -296,32 +291,32 @@ def send_event(value, variation=None, flag=None, reason=None):
log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e)))
log.debug(traceback.format_exc())
reason = error_reason('EXCEPTION')
send_event(default, None, None, reason)
self._send_event(event_factory.new_unknown_flag_event(key, user, default, reason))
return EvaluationDetail(default, None, reason)
if not flag:
reason = error_reason('FLAG_NOT_FOUND')
send_event(default, None, None, reason)
self._send_event(event_factory.new_unknown_flag_event(key, user, default, reason))
return EvaluationDetail(default, None, reason)
else:
if user is None or user.get('key') is None:
reason = error_reason('USER_NOT_SPECIFIED')
send_event(default, None, flag, reason)
self._send_event(event_factory.new_default_event(flag, user, default, reason))
return EvaluationDetail(default, None, reason)

try:
result = evaluate(flag, user, self._store, include_reasons_in_events)
result = evaluate(flag, user, self._store, event_factory)
for event in result.events or []:
self._send_event(event)
detail = result.detail
if detail.is_default_value():
detail = EvaluationDetail(default, None, detail.reason)
send_event(detail.value, detail.variation_index, flag, detail.reason)
self._send_event(event_factory.new_eval_event(flag, user, detail, default))
return detail
except Exception as e:
log.error("Unexpected error while evaluating feature flag \"%s\": %s" % (key, repr(e)))
log.debug(traceback.format_exc())
reason = error_reason('EXCEPTION')
send_event(default, None, flag, reason)
self._send_event(event_factory.new_default_event(flag, user, default, reason))
return EvaluationDetail(default, None, reason)

def all_flags(self, user):
Expand Down
19 changes: 7 additions & 12 deletions ldclient/flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,16 @@ def error_reason(error_kind):
return {'kind': 'ERROR', 'errorKind': error_kind}


def evaluate(flag, user, store, include_reasons_in_events = False):
def evaluate(flag, user, store, event_factory):
prereq_events = []
detail = _evaluate(flag, user, store, prereq_events, include_reasons_in_events)
detail = _evaluate(flag, user, store, prereq_events, event_factory)
return EvalResult(detail = detail, events = prereq_events)

def _evaluate(flag, user, store, prereq_events, include_reasons_in_events):
def _evaluate(flag, user, store, prereq_events, event_factory):
if not flag.get('on', False):
return _get_off_value(flag, {'kind': 'OFF'})

prereq_failure_reason = _check_prerequisites(flag, user, store, prereq_events, include_reasons_in_events)
prereq_failure_reason = _check_prerequisites(flag, user, store, prereq_events, event_factory)
if prereq_failure_reason is not None:
return _get_off_value(flag, prereq_failure_reason)

Expand All @@ -135,7 +135,7 @@ def _evaluate(flag, user, store, prereq_events, include_reasons_in_events):
return _get_value_for_variation_or_rollout(flag, flag['fallthrough'], user, {'kind': 'FALLTHROUGH'})


def _check_prerequisites(flag, user, store, events, include_reasons_in_events):
def _check_prerequisites(flag, user, store, events, event_factory):
failed_prereq = None
prereq_res = None
for prereq in flag.get('prerequisites') or []:
Expand All @@ -144,17 +144,12 @@ def _check_prerequisites(flag, user, store, events, include_reasons_in_events):
log.warn("Missing prereq flag: " + prereq.get('key'))
failed_prereq = prereq
else:
prereq_res = _evaluate(prereq_flag, user, store, events, include_reasons_in_events)
prereq_res = _evaluate(prereq_flag, user, store, events, event_factory)
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
# off variation was. But we still need to evaluate it in order to generate an event.
if (not prereq_flag.get('on', False)) or prereq_res.variation_index != prereq.get('variation'):
failed_prereq = prereq
event = {'kind': 'feature', 'key': prereq.get('key'), 'user': user,
'variation': prereq_res.variation_index, 'value': prereq_res.value,
'version': prereq_flag.get('version'), 'prereqOf': flag.get('key'),
'trackEvents': prereq_flag.get('trackEvents'),
'debugEventsUntilDate': prereq_flag.get('debugEventsUntilDate'),
'reason': prereq_res.reason if prereq_res and include_reasons_in_events else None}
event = event_factory.new_eval_event(prereq_flag, user, prereq_res, None, flag)
events.append(event)
if failed_prereq:
return {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': failed_prereq.get('key')}
Expand Down
89 changes: 89 additions & 0 deletions ldclient/impl/event_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@

# Event constructors are centralized here to avoid mistakes and repetitive logic.
# The LDClient owns two instances of _EventFactory: one that always embeds evaluation reasons
# in the events (for when variation_detail is called) and one that doesn't.
#
# Note that none of these methods fill in the "creationDate" property, because in the Python
# client, that is done by DefaultEventProcessor.send_event().

class _EventFactory(object):
def __init__(self, with_reasons):
self._with_reasons = with_reasons

def new_eval_event(self, flag, user, detail, default_value, prereq_of_flag = None):
add_experiment_data = self._is_experiment(flag, detail.reason)
e = {
'kind': 'feature',
'key': flag.get('key'),
'user': user,
'value': detail.value,
'variation': detail.variation_index,
'default': default_value,
'version': flag.get('version')
}
# the following properties are handled separately so we don't waste bandwidth on unused keys
if add_experiment_data or flag.get('trackEvents', False):
e['trackEvents'] = True
if flag.get('debugEventsUntilDate', None):
e['debugEventsUntilDate'] = flag.get('debugEventsUntilDate')
if prereq_of_flag is not None:
e['prereqOf'] = prereq_of_flag.get('key')
if add_experiment_data or self._with_reasons:
e['reason'] = detail.reason
return e

def new_default_event(self, flag, user, default_value, reason):
e = {
'kind': 'feature',
'key': flag.get('key'),
'user': user,
'value': default_value,
'default': default_value,
'version': flag.get('version')
}
# the following properties are handled separately so we don't waste bandwidth on unused keys
if flag.get('trackEvents', False):
e['trackEvents'] = True
if flag.get('debugEventsUntilDate', None):
e['debugEventsUntilDate'] = flag.get('debugEventsUntilDate')
if self._with_reasons:
e['reason'] = reason
return e

def new_unknown_flag_event(self, key, user, default_value, reason):
e = {
'kind': 'feature',
'key': key,
'user': user,
'value': default_value,
'default': default_value
}
if self._with_reasons:
e['reason'] = reason
return e

def new_identify_event(self, user):
return {
'kind': 'identify',
'key': user.get('key'),
'user': user
}

def new_custom_event(self, event_name, user, data):
return {
'kind': 'custom',
'key': event_name,
'user': user,
'data': data
}

def _is_experiment(self, flag, reason):
if reason is not None:
kind = reason['kind']
if kind == 'RULE_MATCH':
index = reason['ruleIndex']
rules = flag.get('rules') or []
return index >= 0 and index < len(rules) and rules[index].get('trackEvents', False)
elif kind == 'FALLTHROUGH':
return flag.get('trackEventsFallthrough', False)
return False
Loading

0 comments on commit e0c563c

Please sign in to comment.