diff --git a/ldclient/client.py b/ldclient/client.py index 5a65201a..ac087c4d 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -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 @@ -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 """ @@ -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 @@ -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. @@ -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. @@ -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 @@ -263,22 +269,14 @@ 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) @@ -286,7 +284,7 @@ def send_event(value, variation=None, flag=None, reason=None): 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', "") == "": @@ -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): diff --git a/ldclient/event_processor.py b/ldclient/event_processor.py index cf52a2fb..5a532861 100644 --- a/ldclient/event_processor.py +++ b/ldclient/event_processor.py @@ -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 { diff --git a/ldclient/flag.py b/ldclient/flag.py index c7515e63..61e93ce8 100644 --- a/ldclient/flag.py +++ b/ldclient/flag.py @@ -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) @@ -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 []: @@ -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')} diff --git a/ldclient/impl/event_factory.py b/ldclient/impl/event_factory.py new file mode 100644 index 00000000..d2a62ad8 --- /dev/null +++ b/ldclient/impl/event_factory.py @@ -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 diff --git a/testing/test_event_processor.py b/testing/test_event_processor.py index 8faa78d3..afd1b7f9 100644 --- a/testing/test_event_processor.py +++ b/testing/test_event_processor.py @@ -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() @@ -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' diff --git a/testing/test_flag.py b/testing/test_flag.py index 9ca4b05a..ced400e5 100644 --- a/testing/test_flag.py +++ b/testing/test_flag.py @@ -1,10 +1,12 @@ import pytest from ldclient.feature_store import InMemoryFeatureStore from ldclient.flag import EvaluationDetail, EvalResult, _bucket_user, evaluate +from ldclient.impl.event_factory import _EventFactory from ldclient.versioned_data_kind import FEATURES, SEGMENTS empty_store = InMemoryFeatureStore() +event_factory = _EventFactory(False) def make_boolean_flag_with_rules(rules): @@ -27,7 +29,7 @@ def test_flag_returns_off_variation_if_flag_is_off(): } user = { 'key': 'x' } detail = EvaluationDetail('b', 1, {'kind': 'OFF'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_none_if_flag_is_off_and_off_variation_is_unspecified(): flag = { @@ -37,7 +39,7 @@ def test_flag_returns_none_if_flag_is_off_and_off_variation_is_unspecified(): } user = { 'key': 'x' } detail = EvaluationDetail(None, None, {'kind': 'OFF'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_off_variation_is_too_high(): flag = { @@ -48,7 +50,7 @@ def test_flag_returns_error_if_off_variation_is_too_high(): } user = { 'key': 'x' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_off_variation_is_negative(): flag = { @@ -59,7 +61,7 @@ def test_flag_returns_error_if_off_variation_is_negative(): } user = { 'key': 'x' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_off_variation_if_prerequisite_not_found(): flag = { @@ -72,7 +74,7 @@ def test_flag_returns_off_variation_if_prerequisite_not_found(): } user = { 'key': 'x' } detail = EvaluationDetail('b', 1, {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': 'badfeature'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_off_variation_and_event_if_prerequisite_is_off(): store = InMemoryFeatureStore() @@ -98,9 +100,9 @@ def test_flag_returns_off_variation_and_event_if_prerequisite_is_off(): store.upsert(FEATURES, flag1) user = { 'key': 'x' } detail = EvaluationDetail('b', 1, {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': 'feature1'}) - events_should_be = [{'kind': 'feature', 'key': 'feature1', 'variation': 1, 'value': 'e', - 'version': 2, 'user': user, 'prereqOf': 'feature0', 'trackEvents': False, 'debugEventsUntilDate': None, 'reason': None}] - assert evaluate(flag, user, store) == EvalResult(detail, events_should_be) + events_should_be = [{'kind': 'feature', 'key': 'feature1', 'variation': 1, 'value': 'e', 'default': None, + 'version': 2, 'user': user, 'prereqOf': 'feature0'}] + assert evaluate(flag, user, store, event_factory) == EvalResult(detail, events_should_be) def test_flag_returns_off_variation_and_event_if_prerequisite_is_not_met(): store = InMemoryFeatureStore() @@ -124,9 +126,9 @@ def test_flag_returns_off_variation_and_event_if_prerequisite_is_not_met(): store.upsert(FEATURES, flag1) user = { 'key': 'x' } detail = EvaluationDetail('b', 1, {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': 'feature1'}) - events_should_be = [{'kind': 'feature', 'key': 'feature1', 'variation': 0, 'value': 'd', - 'version': 2, 'user': user, 'prereqOf': 'feature0', 'trackEvents': False, 'debugEventsUntilDate': None, 'reason': None}] - assert evaluate(flag, user, store) == EvalResult(detail, events_should_be) + events_should_be = [{'kind': 'feature', 'key': 'feature1', 'variation': 0, 'value': 'd', 'default': None, + 'version': 2, 'user': user, 'prereqOf': 'feature0'}] + assert evaluate(flag, user, store, event_factory) == EvalResult(detail, events_should_be) def test_flag_returns_fallthrough_and_event_if_prereq_is_met_and_there_are_no_rules(): store = InMemoryFeatureStore() @@ -150,9 +152,9 @@ def test_flag_returns_fallthrough_and_event_if_prereq_is_met_and_there_are_no_ru store.upsert(FEATURES, flag1) user = { 'key': 'x' } detail = EvaluationDetail('a', 0, {'kind': 'FALLTHROUGH'}) - events_should_be = [{'kind': 'feature', 'key': 'feature1', 'variation': 1, 'value': 'e', - 'version': 2, 'user': user, 'prereqOf': 'feature0', 'trackEvents': False, 'debugEventsUntilDate': None, 'reason': None}] - assert evaluate(flag, user, store) == EvalResult(detail, events_should_be) + events_should_be = [{'kind': 'feature', 'key': 'feature1', 'variation': 1, 'value': 'e', 'default': None, + 'version': 2, 'user': user, 'prereqOf': 'feature0'}] + assert evaluate(flag, user, store, event_factory) == EvalResult(detail, events_should_be) def test_flag_returns_error_if_fallthrough_variation_is_too_high(): flag = { @@ -163,7 +165,7 @@ def test_flag_returns_error_if_fallthrough_variation_is_too_high(): } user = { 'key': 'x' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_fallthrough_variation_is_negative(): flag = { @@ -174,7 +176,7 @@ def test_flag_returns_error_if_fallthrough_variation_is_negative(): } user = { 'key': 'x' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_fallthrough_has_no_variation_or_rollout(): flag = { @@ -185,7 +187,7 @@ def test_flag_returns_error_if_fallthrough_has_no_variation_or_rollout(): } user = { 'key': 'x' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_fallthrough_has_rollout_with_no_variations(): flag = { @@ -197,7 +199,7 @@ def test_flag_returns_error_if_fallthrough_has_rollout_with_no_variations(): } user = { 'key': 'x' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_matches_user_from_targets(): flag = { @@ -210,35 +212,35 @@ def test_flag_matches_user_from_targets(): } user = { 'key': 'userkey' } detail = EvaluationDetail('c', 2, {'kind': 'TARGET_MATCH'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_matches_user_from_rules(): rule = { 'id': 'id', 'clauses': [{'attribute': 'key', 'op': 'in', 'values': ['userkey']}], 'variation': 1} flag = make_boolean_flag_with_rules([rule]) user = { 'key': 'userkey' } detail = EvaluationDetail(True, 1, {'kind': 'RULE_MATCH', 'ruleIndex': 0, 'ruleId': 'id'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_rule_variation_is_too_high(): rule = { 'id': 'id', 'clauses': [{'attribute': 'key', 'op': 'in', 'values': ['userkey']}], 'variation': 999} flag = make_boolean_flag_with_rules([rule]) user = { 'key': 'userkey' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_rule_variation_is_negative(): rule = { 'id': 'id', 'clauses': [{'attribute': 'key', 'op': 'in', 'values': ['userkey']}], 'variation': -1} flag = make_boolean_flag_with_rules([rule]) user = { 'key': 'userkey' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_rule_has_no_variation_or_rollout(): rule = { 'id': 'id', 'clauses': [{'attribute': 'key', 'op': 'in', 'values': ['userkey']}]} flag = make_boolean_flag_with_rules([rule]) user = { 'key': 'userkey' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_flag_returns_error_if_rule_has_rollout_with_no_variations(): rule = { 'id': 'id', 'clauses': [{'attribute': 'key', 'op': 'in', 'values': ['userkey']}], @@ -246,13 +248,13 @@ def test_flag_returns_error_if_rule_has_rollout_with_no_variations(): flag = make_boolean_flag_with_rules([rule]) user = { 'key': 'userkey' } detail = EvaluationDetail(None, None, {'kind': 'ERROR', 'errorKind': 'MALFORMED_FLAG'}) - assert evaluate(flag, user, empty_store) == EvalResult(detail, []) + assert evaluate(flag, user, empty_store, event_factory) == EvalResult(detail, []) def test_user_key_is_coerced_to_string_for_evaluation(): clause = { 'attribute': 'key', 'op': 'in', 'values': [ '999' ] } flag = _make_bool_flag_from_clause(clause) user = { 'key': 999 } - assert evaluate(flag, user, empty_store).detail.value == True + assert evaluate(flag, user, empty_store, event_factory).detail.value == True def test_secondary_key_is_coerced_to_string_for_evaluation(): # We can't really verify that the rollout calculation works correctly, but we can at least @@ -269,7 +271,7 @@ def test_secondary_key_is_coerced_to_string_for_evaluation(): } flag = make_boolean_flag_with_rules([rule]) user = { 'key': 'userkey', 'secondary': 999 } - assert evaluate(flag, user, empty_store).detail.value == True + assert evaluate(flag, user, empty_store, event_factory).detail.value == True def test_segment_match_clause_retrieves_segment_from_store(): store = InMemoryFeatureStore() @@ -300,7 +302,7 @@ def test_segment_match_clause_retrieves_segment_from_store(): ] } - assert evaluate(flag, user, store).detail.value == True + assert evaluate(flag, user, store, event_factory).detail.value == True def test_segment_match_clause_falls_through_with_no_errors_if_segment_not_found(): user = { "key": "foo" } @@ -323,7 +325,7 @@ def test_segment_match_clause_falls_through_with_no_errors_if_segment_not_found( ] } - assert evaluate(flag, user, empty_store).detail.value == False + assert evaluate(flag, user, empty_store, event_factory).detail.value == False def test_clause_matches_builtin_attribute(): clause = { @@ -333,7 +335,7 @@ def test_clause_matches_builtin_attribute(): } user = { 'key': 'x', 'name': 'Bob' } flag = _make_bool_flag_from_clause(clause) - assert evaluate(flag, user, empty_store).detail.value == True + assert evaluate(flag, user, empty_store, event_factory).detail.value == True def test_clause_matches_custom_attribute(): clause = { @@ -343,7 +345,7 @@ def test_clause_matches_custom_attribute(): } user = { 'key': 'x', 'name': 'Bob', 'custom': { 'legs': 4 } } flag = _make_bool_flag_from_clause(clause) - assert evaluate(flag, user, empty_store).detail.value == True + assert evaluate(flag, user, empty_store, event_factory).detail.value == True def test_clause_returns_false_for_missing_attribute(): clause = { @@ -353,7 +355,7 @@ def test_clause_returns_false_for_missing_attribute(): } user = { 'key': 'x', 'name': 'Bob' } flag = _make_bool_flag_from_clause(clause) - assert evaluate(flag, user, empty_store).detail.value == False + assert evaluate(flag, user, empty_store, event_factory).detail.value == False def test_clause_can_be_negated(): clause = { @@ -364,7 +366,7 @@ def test_clause_can_be_negated(): } user = { 'key': 'x', 'name': 'Bob' } flag = _make_bool_flag_from_clause(clause) - assert evaluate(flag, user, empty_store).detail.value == False + assert evaluate(flag, user, empty_store, event_factory).detail.value == False def _make_bool_flag_from_clause(clause): @@ -397,7 +399,6 @@ def test_bucket_by_user_key(): assert bucket == pytest.approx(0.10343106) def test_bucket_by_int_attr(): - feature = { u'key': u'hashKey', u'salt': u'saltyA' } user = { u'key': u'userKey', u'custom': { @@ -411,7 +412,6 @@ def test_bucket_by_int_attr(): assert bucket2 == bucket def test_bucket_by_float_attr_not_allowed(): - feature = { u'key': u'hashKey', u'salt': u'saltyA' } user = { u'key': u'userKey', u'custom': { diff --git a/testing/test_ldclient.py b/testing/test_ldclient.py index e1ee3910..a6789e4d 100644 --- a/testing/test_ldclient.py +++ b/testing/test_ldclient.py @@ -150,10 +150,24 @@ def test_identify_no_user_key(): def test_track(): + with make_client() as client: + client.track('my_event', user) + e = get_first_event(client) + assert e['kind'] == 'custom' and e['key'] == 'my_event' and e['user'] == user and e.get('data') is None and e.get('metricValue') is None + + +def test_track_with_data(): with make_client() as client: client.track('my_event', user, 42) e = get_first_event(client) - assert e['kind'] == 'custom' and e['key'] == 'my_event' and e['user'] == user and e['data'] == 42 + assert e['kind'] == 'custom' and e['key'] == 'my_event' and e['user'] == user and e['data'] == 42 and e.get('metricValue') is None + + +def test_track_with_metric_value(): + with make_client() as client: + client.track('my_event', user, 42, 1.5) + e = get_first_event(client) + assert e['kind'] == 'custom' and e['key'] == 'my_event' and e['user'] == user and e['data'] == 42 and e.get('metricValue') == 1.5 def test_track_no_user(): @@ -244,6 +258,132 @@ def test_event_for_existing_feature_with_reason(): e['debugEventsUntilDate'] == 1000) +def test_event_for_existing_feature_with_tracked_rule(): + feature = { + 'key': 'feature.key', + 'version': 100, + 'salt': u'', + 'on': True, + 'rules': [ + { + 'clauses': [ + { 'attribute': 'key', 'op': 'in', 'values': [ user['key'] ] } + ], + 'variation': 0, + 'trackEvents': True, + 'id': 'rule_id' + } + ], + 'variations': [ 'value' ] + } + store = InMemoryFeatureStore() + store.init({FEATURES: {feature['key']: feature}}) + client = make_client(store) + assert 'value' == client.variation(feature['key'], user, default='default') + e = get_first_event(client) + assert (e['kind'] == 'feature' and + e['key'] == feature['key'] and + e['user'] == user and + e['version'] == feature['version'] and + e['value'] == 'value' and + e['variation'] == 0 and + e['reason'] == { 'kind': 'RULE_MATCH', 'ruleIndex': 0, 'ruleId': 'rule_id' } and + e['default'] == 'default' and + e['trackEvents'] == True and + e.get('debugEventsUntilDate') is None) + + +def test_event_for_existing_feature_with_untracked_rule(): + feature = { + 'key': 'feature.key', + 'version': 100, + 'salt': u'', + 'on': True, + 'rules': [ + { + 'clauses': [ + { 'attribute': 'key', 'op': 'in', 'values': [ user['key'] ] } + ], + 'variation': 0, + 'trackEvents': False, + 'id': 'rule_id' + } + ], + 'variations': [ 'value' ] + } + store = InMemoryFeatureStore() + store.init({FEATURES: {feature['key']: feature}}) + client = make_client(store) + assert 'value' == client.variation(feature['key'], user, default='default') + e = get_first_event(client) + assert (e['kind'] == 'feature' and + e['key'] == feature['key'] and + e['user'] == user and + e['version'] == feature['version'] and + e['value'] == 'value' and + e['variation'] == 0 and + e.get('reason') is None and + e['default'] == 'default' and + e.get('trackEvents', False) == False and + e.get('debugEventsUntilDate') is None) + + +def test_event_for_existing_feature_with_tracked_fallthrough(): + feature = { + 'key': 'feature.key', + 'version': 100, + 'salt': u'', + 'on': True, + 'rules': [], + 'fallthrough': { 'variation': 0 }, + 'variations': [ 'value' ], + 'trackEventsFallthrough': True + } + store = InMemoryFeatureStore() + store.init({FEATURES: {feature['key']: feature}}) + client = make_client(store) + assert 'value' == client.variation(feature['key'], user, default='default') + e = get_first_event(client) + assert (e['kind'] == 'feature' and + e['key'] == feature['key'] and + e['user'] == user and + e['version'] == feature['version'] and + e['value'] == 'value' and + e['variation'] == 0 and + e['reason'] == { 'kind': 'FALLTHROUGH' } and + e['default'] == 'default' and + e['trackEvents'] == True and + e.get('debugEventsUntilDate') is None) + + +def test_event_for_existing_feature_with_untracked_fallthrough(): + feature = { + 'key': 'feature.key', + 'version': 100, + 'salt': u'', + 'on': True, + 'rules': [], + 'fallthrough': { 'variation': 0 }, + 'variations': [ 'value' ], + 'trackEventsFallthrough': False + } + store = InMemoryFeatureStore() + store.init({FEATURES: {feature['key']: feature}}) + client = make_client(store) + assert 'value' == client.variation(feature['key'], user, default='default') + e = get_first_event(client) + assert (e['kind'] == 'feature' and + e['key'] == feature['key'] and + e['user'] == user and + e['version'] == feature['version'] and + e['value'] == 'value' and + e['variation'] == 0 and + e.get('reason') is None and + e['default'] == 'default' and + e.get('trackEvents', False) == False and + e.get('debugEventsUntilDate') is None) + + def test_event_for_unknown_feature(): store = InMemoryFeatureStore() store.init({FEATURES: {}}) @@ -254,7 +394,7 @@ def test_event_for_unknown_feature(): e['key'] == 'feature.key' and e['user'] == user and e['value'] == 'default' and - e['variation'] == None and + e.get('variation') is None and e['default'] == 'default') @@ -269,10 +409,10 @@ def test_event_for_existing_feature_with_no_user(): e = get_first_event(client) assert (e['kind'] == 'feature' and e['key'] == 'feature.key' and - e['user'] == None and + e.get('user') is None and e['version'] == feature['version'] and e['value'] == 'default' and - e['variation'] == None and + e.get('variation') is None and e['default'] == 'default' and e['trackEvents'] == True and e['debugEventsUntilDate'] == 1000) @@ -293,7 +433,7 @@ def test_event_for_existing_feature_with_no_user_key(): e['user'] == bad_user and e['version'] == feature['version'] and e['value'] == 'default' and - e['variation'] == None and + e.get('variation') is None and e['default'] == 'default' and e['trackEvents'] == True and e['debugEventsUntilDate'] == 1000)