diff --git a/.circleci/config.yml b/.circleci/config.yml index f33121db..4153459f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,7 +49,8 @@ jobs: - run: name: install requirements command: | - sudo pip install --upgrade pip virtualenv; + sudo pip install --upgrade pip; + sudo pip install 'virtualenv~=16.0'; sudo pip install -r test-requirements.txt; sudo pip install -r test-filesource-optional-requirements.txt; sudo pip install -r consul-requirements.txt; diff --git a/CHANGELOG.md b/CHANGELOG.md index e593c823..fd0d1ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ This major release is for Python compatibility updates and removal of deprecated - Removed the individual HTTP-related parameters such as `connect_timeout` from the [`Config`](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.config.Config) type. The correct way to set these now is with the [`HTTPConfig`](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.config.HTTPConfig) sub-configuration object: `Config(sdk_key = "my-sdk-key", http = HTTPConfig(connect_timeout = 10))`. - Removed all other types, parameters, and methods that were deprecated as of the last 6.x release. +## [6.13.3] - 2021-02-23 +### Fixed: +- The SDK could fail to send debug events when event debugging was enabled on the LaunchDarkly dashboard, if the application server's time zone was not GMT. ## [6.13.2] - 2020-09-21 ### Fixed: diff --git a/ldclient/client.py b/ldclient/client.py index c97bbb42..d401df39 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -187,6 +187,25 @@ def track(self, event_name: str, user: dict, data: Optional[Any]=None, metric_va else: self._send_event(self._event_factory_default.new_custom_event(event_name, user, data, metric_value)) + def alias(self, current_user: dict, previous_user: dict): + """Associates two users for analytics purposes. + + This can be helpful in the situation where a person is represented by multiple + LaunchDarkly users. This may happen, for example, when a person initially logs into + an application, the person might be represented by an anonymous user prior to logging + in and a different user after logging in, as denoted by a different user key. + + :param current_user: The new version of a user. + :param previous_user: The old version of a user. + """ + if current_user is None or current_user.get('key') is None: + log.warning("Missing current_user or current_user key when calling alias().") + return None + if previous_user is None or previous_user.get('key') is None: + log.warning("Missing previous_user or previous_user key when calling alias().") + return None + self._send_event(self._event_factory_default.new_alias_event(current_user, previous_user)) + def identify(self, user: dict): """Registers the user. diff --git a/ldclient/event_processor.py b/ldclient/event_processor.py index 6174f7f2..1afb3221 100644 --- a/ldclient/event_processor.py +++ b/ldclient/event_processor.py @@ -65,6 +65,8 @@ def make_output_event(self, e): out['userKey'] = self._get_userkey(e) if e.get('reason'): out['reason'] = e.get('reason') + if e.get('contextKind'): + out['contextKind'] = e.get('contextKind') return out elif kind == 'identify': return { @@ -87,6 +89,8 @@ def make_output_event(self, e): out['data'] = e['data'] if e.get('metricValue') is not None: out['metricValue'] = e['metricValue'] + if e.get('contextKind'): + out['contextKind'] = e.get('contextKind') return out elif kind == 'index': return { diff --git a/ldclient/impl/event_factory.py b/ldclient/impl/event_factory.py index c35d3bbe..16f81ac7 100644 --- a/ldclient/impl/event_factory.py +++ b/ldclient/impl/event_factory.py @@ -30,6 +30,8 @@ def new_eval_event(self, flag, user, detail, default_value, prereq_of_flag = Non e['prereqOf'] = prereq_of_flag.get('key') if add_experiment_data or self._with_reasons: e['reason'] = detail.reason + if user is not None and user.get('anonymous'): + e['contextKind'] = self._user_to_context_kind(user) return e def new_default_event(self, flag, user, default_value, reason): @@ -48,6 +50,8 @@ def new_default_event(self, flag, user, default_value, reason): e['debugEventsUntilDate'] = flag.get('debugEventsUntilDate') if self._with_reasons: e['reason'] = reason + if user is not None and user.get('anonymous'): + e['contextKind'] = self._user_to_context_kind(user) return e def new_unknown_flag_event(self, key, user, default_value, reason): @@ -60,6 +64,8 @@ def new_unknown_flag_event(self, key, user, default_value, reason): } if self._with_reasons: e['reason'] = reason + if user is not None and user.get('anonymous'): + e['contextKind'] = self._user_to_context_kind(user) return e def new_identify_event(self, user): @@ -79,8 +85,25 @@ def new_custom_event(self, event_name, user, data, metric_value): e['data'] = data if metric_value is not None: e['metricValue'] = metric_value + if user.get('anonymous'): + e['contextKind'] = self._user_to_context_kind(user) return e + def new_alias_event(self, current_user, previous_user): + return { + 'kind': 'alias', + 'key': current_user.get('key'), + 'contextKind': self._user_to_context_kind(current_user), + 'previousKey': previous_user.get('key'), + 'previousContextKind': self._user_to_context_kind(previous_user) + } + + def _user_to_context_kind(self, user): + if user.get('anonymous'): + return "anonymousUser" + else: + return "user" + def _is_experiment(self, flag, reason): if reason is not None: kind = reason['kind'] diff --git a/test-filesource-optional-requirements.txt b/test-filesource-optional-requirements.txt index 40e04279..3cfa747b 100644 --- a/test-filesource-optional-requirements.txt +++ b/test-filesource-optional-requirements.txt @@ -1,2 +1,2 @@ pyyaml>=3.0,<5.2 -watchdog>=0.9 +watchdog>=0.9,<1.0 diff --git a/test-requirements.txt b/test-requirements.txt index 1f80fcc7..93da9126 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ mock>=2.0.0 pytest>=2.8 -redis>=2.10.5 -boto3>=1.9.71 +redis>=2.10.5,<3.0.0 +boto3>=1.9.71,<2.0.0 coverage>=4.4 jsonpickle==0.9.3 pytest-cov>=2.4.0 diff --git a/testing/test_ldclient.py b/testing/test_ldclient.py index 7615bb16..86cc319e 100644 --- a/testing/test_ldclient.py +++ b/testing/test_ldclient.py @@ -24,6 +24,10 @@ } } +anonymous_user = { + u'key': u'abc', + u'anonymous': True +} def make_client(store = InMemoryFeatureStore()): return LDClient(config=Config(sdk_key = 'SDK_KEY', @@ -172,6 +176,26 @@ def test_track_no_user_key(): assert count_events(client) == 0 +def test_track_anonymous_user(): + with make_client() as client: + client.track('my_event', anonymous_user) + e = get_first_event(client) + assert e['kind'] == 'custom' and e['key'] == 'my_event' and e['user'] == anonymous_user and e.get('data') is None and e.get('metricValue') is None and e.get('contextKind') == 'anonymousUser' + + +def test_alias(): + with make_client() as client: + client.alias(user, anonymous_user) + e = get_first_event(client) + assert e['kind'] == 'alias' and e['key'] == 'xyz' and e['contextKind'] == 'user' and e['previousKey'] == 'abc' and e['previousContextKind'] == 'anonymousUser' + + +def test_alias_no_user(): + with make_client() as client: + client.alias(None, None) + assert count_events(client) == 0 + + def test_defaults(): config=Config("SDK_KEY", base_uri="http://localhost:3000", defaults={"foo": "bar"}, offline=True) with LDClient(config=config) as client: @@ -226,7 +250,30 @@ def test_event_for_existing_feature(): e.get('reason') is None and e['default'] == 'default' and e['trackEvents'] == True and - e['debugEventsUntilDate'] == 1000) + e['debugEventsUntilDate'] == 1000 and + e.get('contextKind') is None) + + +def test_event_for_existing_feature_anonymous_user(): + feature = make_off_flag_with_value('feature.key', 'value') + feature['trackEvents'] = True + feature['debugEventsUntilDate'] = 1000 + store = InMemoryFeatureStore() + store.init({FEATURES: {'feature.key': feature}}) + with make_client(store) as client: + assert 'value' == client.variation('feature.key', anonymous_user, default='default') + e = get_first_event(client) + assert (e['kind'] == 'feature' and + e['key'] == 'feature.key' and + e['user'] == anonymous_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['trackEvents'] == True and + e['debugEventsUntilDate'] == 1000 and + e['contextKind'] == 'anonymousUser') def test_event_for_existing_feature_with_reason():