Skip to content

Commit

Permalink
prepare 6.10.0 release (#128)
Browse files Browse the repository at this point in the history
  • Loading branch information
LaunchDarklyCI authored Aug 20, 2019
1 parent 8551e7a commit f1f2db5
Show file tree
Hide file tree
Showing 7 changed files with 305 additions and 75 deletions.
40 changes: 19 additions & 21 deletions ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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.impl.stubs import NullEventProcessor, NullUpdateProcessor
from ldclient.interfaces import FeatureStore
from ldclient.polling import PollingUpdateProcessor
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 @@ -168,7 +171,7 @@ def __exit__(self, type, value, traceback):
def _send_event(self, event):
self._event_processor.send_event(event)

def track(self, event_name, user, data=None):
def track(self, event_name, user, data=None, metric_value=None):
"""Tracks that a user performed an event.
LaunchDarkly automatically tracks pageviews and clicks that are specified in the Goals
Expand All @@ -178,11 +181,14 @@ def track(self, event_name, user, data=None):
:param string event_name: the name of the event, which may correspond to a goal in A/B tests
:param dict user: the attributes of the user
:param data: optional additional data associated with the event
:param metric_value: a numeric value used by the LaunchDarkly experimentation feature in
numeric custom metrics. Can be omitted if this event is used by only non-numeric metrics.
This field will also be returned as part of the custom event for Data Export.
"""
if user is None or user.get('key') is None:
log.warning("Missing user or user key when calling track().")
else:
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, metric_value))

def identify(self, user):
"""Registers the user.
Expand All @@ -196,7 +202,7 @@ def identify(self, user):
if user is None or user.get('key') is None:
log.warning("Missing user or user key when calling identify().")
else:
self._send_event({'kind': 'identify', 'key': str(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 @@ -246,7 +252,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 @@ -263,30 +269,22 @@ 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:
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY'))

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.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
else:
log.warning("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 @@ -298,32 +296,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
7 changes: 5 additions & 2 deletions ldclient/event_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,16 @@ def make_output_event(self, e):
out = {
'kind': 'custom',
'creationDate': e['creationDate'],
'key': e['key'],
'data': e.get('data')
'key': e['key']
}
if self._inline_users:
out['user'] = self._process_user(e)
else:
out['userKey'] = self._get_userkey(e)
if e.get('data') is not None:
out['data'] = e['data']
if e.get('metricValue') is not None:
out['metricValue'] = e['metricValue']
return out
elif kind == 'index':
return {
Expand Down
19 changes: 7 additions & 12 deletions ldclient/flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,17 @@ 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):
sanitized_user = stringify_attrs(user, __USER_ATTRS_TO_STRINGIFY_FOR_EVALUATION__)
prereq_events = []
detail = _evaluate(flag, sanitized_user, store, prereq_events, include_reasons_in_events)
detail = _evaluate(flag, sanitized_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 @@ -141,7 +141,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 @@ -150,17 +150,12 @@ def _check_prerequisites(flag, user, store, events, include_reasons_in_events):
log.warning("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
93 changes: 93 additions & 0 deletions ldclient/impl/event_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@

# 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': str(user.get('key')),
'user': user
}

def new_custom_event(self, event_name, user, data, metric_value):
e = {
'kind': 'custom',
'key': event_name,
'user': user
}
if data is not None:
e['data'] = data
if metric_value is not None:
e['metricValue'] = metric_value
return e

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
3 changes: 2 additions & 1 deletion testing/test_event_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ def test_nontracked_events_are_summarized():
def test_custom_event_is_queued_with_user():
setup_processor(Config())

e = { 'kind': 'custom', 'key': 'eventkey', 'user': user, 'data': { 'thing': 'stuff '} }
e = { 'kind': 'custom', 'key': 'eventkey', 'user': user, 'data': { 'thing': 'stuff '}, 'metricValue': 1.5 }
ep.send_event(e)

output = flush_and_get_events()
Expand Down Expand Up @@ -553,6 +553,7 @@ def check_custom_event(data, source, inline_user):
assert data['userKey'] == source['user']['key']
else:
assert data['user'] == inline_user
assert data.get('metricValue') == source.get('metricValue')

def check_summary_event(data):
assert data['kind'] == 'summary'
Expand Down
Loading

0 comments on commit f1f2db5

Please sign in to comment.