Skip to content

Commit f1f2db5

Browse files
prepare 6.10.0 release (#128)
1 parent 8551e7a commit f1f2db5

File tree

7 files changed

+305
-75
lines changed

7 files changed

+305
-75
lines changed

ldclient/client.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ldclient.feature_store import _FeatureStoreDataSetSorter
1313
from ldclient.flag import EvaluationDetail, evaluate, error_reason
1414
from ldclient.flags_state import FeatureFlagsState
15+
from ldclient.impl.event_factory import _EventFactory
1516
from ldclient.impl.stubs import NullEventProcessor, NullUpdateProcessor
1617
from ldclient.interfaces import FeatureStore
1718
from ldclient.polling import PollingUpdateProcessor
@@ -90,6 +91,8 @@ def __init__(self, sdk_key=None, config=None, start_wait=5):
9091

9192
self._event_processor = None
9293
self._lock = Lock()
94+
self._event_factory_default = _EventFactory(False)
95+
self._event_factory_with_reasons = _EventFactory(True)
9396

9497
self._store = _FeatureStoreClientWrapper(self._config.feature_store)
9598
""" :type: FeatureStore """
@@ -168,7 +171,7 @@ def __exit__(self, type, value, traceback):
168171
def _send_event(self, event):
169172
self._event_processor.send_event(event)
170173

171-
def track(self, event_name, user, data=None):
174+
def track(self, event_name, user, data=None, metric_value=None):
172175
"""Tracks that a user performed an event.
173176
174177
LaunchDarkly automatically tracks pageviews and clicks that are specified in the Goals
@@ -178,11 +181,14 @@ def track(self, event_name, user, data=None):
178181
:param string event_name: the name of the event, which may correspond to a goal in A/B tests
179182
:param dict user: the attributes of the user
180183
:param data: optional additional data associated with the event
184+
:param metric_value: a numeric value used by the LaunchDarkly experimentation feature in
185+
numeric custom metrics. Can be omitted if this event is used by only non-numeric metrics.
186+
This field will also be returned as part of the custom event for Data Export.
181187
"""
182188
if user is None or user.get('key') is None:
183189
log.warning("Missing user or user key when calling track().")
184190
else:
185-
self._send_event({'kind': 'custom', 'key': event_name, 'user': user, 'data': data})
191+
self._send_event(self._event_factory_default.new_custom_event(event_name, user, data, metric_value))
186192

187193
def identify(self, user):
188194
"""Registers the user.
@@ -196,7 +202,7 @@ def identify(self, user):
196202
if user is None or user.get('key') is None:
197203
log.warning("Missing user or user key when calling identify().")
198204
else:
199-
self._send_event({'kind': 'identify', 'key': str(user.get('key')), 'user': user})
205+
self._send_event(self._event_factory_default.new_identify_event(user))
200206

201207
def is_offline(self):
202208
"""Returns true if the client is in offline mode.
@@ -246,7 +252,7 @@ def variation(self, key, user, default):
246252
available from LaunchDarkly
247253
:return: one of the flag's variation values, or the default value
248254
"""
249-
return self._evaluate_internal(key, user, default, False).value
255+
return self._evaluate_internal(key, user, default, self._event_factory_default).value
250256

251257
def variation_detail(self, key, user, default):
252258
"""Determines the variation of a feature flag for a user, like :func:`variation()`, but also
@@ -263,30 +269,22 @@ def variation_detail(self, key, user, default):
263269
:return: an object describing the result
264270
:rtype: EvaluationDetail
265271
"""
266-
return self._evaluate_internal(key, user, default, True)
272+
return self._evaluate_internal(key, user, default, self._event_factory_with_reasons)
267273

268-
def _evaluate_internal(self, key, user, default, include_reasons_in_events):
274+
def _evaluate_internal(self, key, user, default, event_factory):
269275
default = self._config.get_default(key, default)
270276

271277
if self._config.offline:
272278
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY'))
273279

274-
def send_event(value, variation=None, flag=None, reason=None):
275-
self._send_event({'kind': 'feature', 'key': key, 'user': user,
276-
'value': value, 'variation': variation, 'default': default,
277-
'version': flag.get('version') if flag else None,
278-
'trackEvents': flag.get('trackEvents') if flag else None,
279-
'debugEventsUntilDate': flag.get('debugEventsUntilDate') if flag else None,
280-
'reason': reason if include_reasons_in_events else None})
281-
282280
if not self.is_initialized():
283281
if self._store.initialized:
284282
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
285283
else:
286284
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: "
287285
+ str(default) + " for feature key: " + key)
288286
reason = error_reason('CLIENT_NOT_READY')
289-
send_event(default, None, None, reason)
287+
self._send_event(event_factory.new_unknown_flag_event(key, user, default, reason))
290288
return EvaluationDetail(default, None, reason)
291289

292290
if user is not None and user.get('key', "") == "":
@@ -298,32 +296,32 @@ def send_event(value, variation=None, flag=None, reason=None):
298296
log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e)))
299297
log.debug(traceback.format_exc())
300298
reason = error_reason('EXCEPTION')
301-
send_event(default, None, None, reason)
299+
self._send_event(event_factory.new_unknown_flag_event(key, user, default, reason))
302300
return EvaluationDetail(default, None, reason)
303301
if not flag:
304302
reason = error_reason('FLAG_NOT_FOUND')
305-
send_event(default, None, None, reason)
303+
self._send_event(event_factory.new_unknown_flag_event(key, user, default, reason))
306304
return EvaluationDetail(default, None, reason)
307305
else:
308306
if user is None or user.get('key') is None:
309307
reason = error_reason('USER_NOT_SPECIFIED')
310-
send_event(default, None, flag, reason)
308+
self._send_event(event_factory.new_default_event(flag, user, default, reason))
311309
return EvaluationDetail(default, None, reason)
312310

313311
try:
314-
result = evaluate(flag, user, self._store, include_reasons_in_events)
312+
result = evaluate(flag, user, self._store, event_factory)
315313
for event in result.events or []:
316314
self._send_event(event)
317315
detail = result.detail
318316
if detail.is_default_value():
319317
detail = EvaluationDetail(default, None, detail.reason)
320-
send_event(detail.value, detail.variation_index, flag, detail.reason)
318+
self._send_event(event_factory.new_eval_event(flag, user, detail, default))
321319
return detail
322320
except Exception as e:
323321
log.error("Unexpected error while evaluating feature flag \"%s\": %s" % (key, repr(e)))
324322
log.debug(traceback.format_exc())
325323
reason = error_reason('EXCEPTION')
326-
send_event(default, None, flag, reason)
324+
self._send_event(event_factory.new_default_event(flag, user, default, reason))
327325
return EvaluationDetail(default, None, reason)
328326

329327
def all_flags(self, user):

ldclient/event_processor.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,16 @@ def make_output_event(self, e):
8383
out = {
8484
'kind': 'custom',
8585
'creationDate': e['creationDate'],
86-
'key': e['key'],
87-
'data': e.get('data')
86+
'key': e['key']
8887
}
8988
if self._inline_users:
9089
out['user'] = self._process_user(e)
9190
else:
9291
out['userKey'] = self._get_userkey(e)
92+
if e.get('data') is not None:
93+
out['data'] = e['data']
94+
if e.get('metricValue') is not None:
95+
out['metricValue'] = e['metricValue']
9396
return out
9497
elif kind == 'index':
9598
return {

ldclient/flag.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,17 @@ def error_reason(error_kind):
110110
return {'kind': 'ERROR', 'errorKind': error_kind}
111111

112112

113-
def evaluate(flag, user, store, include_reasons_in_events = False):
113+
def evaluate(flag, user, store, event_factory):
114114
sanitized_user = stringify_attrs(user, __USER_ATTRS_TO_STRINGIFY_FOR_EVALUATION__)
115115
prereq_events = []
116-
detail = _evaluate(flag, sanitized_user, store, prereq_events, include_reasons_in_events)
116+
detail = _evaluate(flag, sanitized_user, store, prereq_events, event_factory)
117117
return EvalResult(detail = detail, events = prereq_events)
118118

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

123-
prereq_failure_reason = _check_prerequisites(flag, user, store, prereq_events, include_reasons_in_events)
123+
prereq_failure_reason = _check_prerequisites(flag, user, store, prereq_events, event_factory)
124124
if prereq_failure_reason is not None:
125125
return _get_off_value(flag, prereq_failure_reason)
126126

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

143143

144-
def _check_prerequisites(flag, user, store, events, include_reasons_in_events):
144+
def _check_prerequisites(flag, user, store, events, event_factory):
145145
failed_prereq = None
146146
prereq_res = None
147147
for prereq in flag.get('prerequisites') or []:
@@ -150,17 +150,12 @@ def _check_prerequisites(flag, user, store, events, include_reasons_in_events):
150150
log.warning("Missing prereq flag: " + prereq.get('key'))
151151
failed_prereq = prereq
152152
else:
153-
prereq_res = _evaluate(prereq_flag, user, store, events, include_reasons_in_events)
153+
prereq_res = _evaluate(prereq_flag, user, store, events, event_factory)
154154
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
155155
# off variation was. But we still need to evaluate it in order to generate an event.
156156
if (not prereq_flag.get('on', False)) or prereq_res.variation_index != prereq.get('variation'):
157157
failed_prereq = prereq
158-
event = {'kind': 'feature', 'key': prereq.get('key'), 'user': user,
159-
'variation': prereq_res.variation_index, 'value': prereq_res.value,
160-
'version': prereq_flag.get('version'), 'prereqOf': flag.get('key'),
161-
'trackEvents': prereq_flag.get('trackEvents'),
162-
'debugEventsUntilDate': prereq_flag.get('debugEventsUntilDate'),
163-
'reason': prereq_res.reason if prereq_res and include_reasons_in_events else None}
158+
event = event_factory.new_eval_event(prereq_flag, user, prereq_res, None, flag)
164159
events.append(event)
165160
if failed_prereq:
166161
return {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': failed_prereq.get('key')}

ldclient/impl/event_factory.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
2+
# Event constructors are centralized here to avoid mistakes and repetitive logic.
3+
# The LDClient owns two instances of _EventFactory: one that always embeds evaluation reasons
4+
# in the events (for when variation_detail is called) and one that doesn't.
5+
#
6+
# Note that none of these methods fill in the "creationDate" property, because in the Python
7+
# client, that is done by DefaultEventProcessor.send_event().
8+
9+
class _EventFactory(object):
10+
def __init__(self, with_reasons):
11+
self._with_reasons = with_reasons
12+
13+
def new_eval_event(self, flag, user, detail, default_value, prereq_of_flag = None):
14+
add_experiment_data = self._is_experiment(flag, detail.reason)
15+
e = {
16+
'kind': 'feature',
17+
'key': flag.get('key'),
18+
'user': user,
19+
'value': detail.value,
20+
'variation': detail.variation_index,
21+
'default': default_value,
22+
'version': flag.get('version')
23+
}
24+
# the following properties are handled separately so we don't waste bandwidth on unused keys
25+
if add_experiment_data or flag.get('trackEvents', False):
26+
e['trackEvents'] = True
27+
if flag.get('debugEventsUntilDate', None):
28+
e['debugEventsUntilDate'] = flag.get('debugEventsUntilDate')
29+
if prereq_of_flag is not None:
30+
e['prereqOf'] = prereq_of_flag.get('key')
31+
if add_experiment_data or self._with_reasons:
32+
e['reason'] = detail.reason
33+
return e
34+
35+
def new_default_event(self, flag, user, default_value, reason):
36+
e = {
37+
'kind': 'feature',
38+
'key': flag.get('key'),
39+
'user': user,
40+
'value': default_value,
41+
'default': default_value,
42+
'version': flag.get('version')
43+
}
44+
# the following properties are handled separately so we don't waste bandwidth on unused keys
45+
if flag.get('trackEvents', False):
46+
e['trackEvents'] = True
47+
if flag.get('debugEventsUntilDate', None):
48+
e['debugEventsUntilDate'] = flag.get('debugEventsUntilDate')
49+
if self._with_reasons:
50+
e['reason'] = reason
51+
return e
52+
53+
def new_unknown_flag_event(self, key, user, default_value, reason):
54+
e = {
55+
'kind': 'feature',
56+
'key': key,
57+
'user': user,
58+
'value': default_value,
59+
'default': default_value
60+
}
61+
if self._with_reasons:
62+
e['reason'] = reason
63+
return e
64+
65+
def new_identify_event(self, user):
66+
return {
67+
'kind': 'identify',
68+
'key': str(user.get('key')),
69+
'user': user
70+
}
71+
72+
def new_custom_event(self, event_name, user, data, metric_value):
73+
e = {
74+
'kind': 'custom',
75+
'key': event_name,
76+
'user': user
77+
}
78+
if data is not None:
79+
e['data'] = data
80+
if metric_value is not None:
81+
e['metricValue'] = metric_value
82+
return e
83+
84+
def _is_experiment(self, flag, reason):
85+
if reason is not None:
86+
kind = reason['kind']
87+
if kind == 'RULE_MATCH':
88+
index = reason['ruleIndex']
89+
rules = flag.get('rules') or []
90+
return index >= 0 and index < len(rules) and rules[index].get('trackEvents', False)
91+
elif kind == 'FALLTHROUGH':
92+
return flag.get('trackEventsFallthrough', False)
93+
return False

testing/test_event_processor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ def test_nontracked_events_are_summarized():
393393
def test_custom_event_is_queued_with_user():
394394
setup_processor(Config())
395395

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

399399
output = flush_and_get_events()
@@ -553,6 +553,7 @@ def check_custom_event(data, source, inline_user):
553553
assert data['userKey'] == source['user']['key']
554554
else:
555555
assert data['user'] == inline_user
556+
assert data.get('metricValue') == source.get('metricValue')
556557

557558
def check_summary_event(data):
558559
assert data['kind'] == 'summary'

0 commit comments

Comments
 (0)