Skip to content

Commit 3025af4

Browse files
authored
feat: Add support for client-side prerequisite events (#314)
1 parent e95d324 commit 3025af4

File tree

5 files changed

+107
-12
lines changed

5 files changed

+107
-12
lines changed

contract-tests/service.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ def status():
7777
'inline-context',
7878
'anonymous-redaction',
7979
'evaluation-hooks',
80-
'omit-anonymous-contexts'
80+
'omit-anonymous-contexts',
81+
'client-prereq-events'
8182
]
8283
}
8384
return (json.dumps(body), 200, {'Content-type': 'application/json'})

ldclient/client.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,8 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
558558
if client_only and not flag.get('clientSide', False):
559559
continue
560560
try:
561-
detail = self._evaluator.evaluate(flag, context, self._event_factory_default).detail
561+
result = self._evaluator.evaluate(flag, context, self._event_factory_default)
562+
detail = result.detail
562563
except Exception as e:
563564
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
564565
log.debug(traceback.format_exc())
@@ -572,6 +573,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
572573
'variation': detail.variation_index,
573574
'reason': detail.reason,
574575
'version': flag['version'],
576+
'prerequisites': result.prerequisites,
575577
'trackEvents': flag.get('trackEvents', False) or requires_experiment_data,
576578
'trackReason': requires_experiment_data,
577579
'debugEventsUntilDate': flag.get('debugEventsUntilDate', None),

ldclient/evaluation.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def reason(self) -> dict:
5252
5353
* ``errorKind``: further describes the nature of the error if the kind was ``ERROR``,
5454
e.g. ``"FLAG_NOT_FOUND"``
55-
55+
5656
* ``bigSegmentsStatus``: describes the validity of Big Segment information, if and only if
5757
the flag evaluation required querying at least one Big Segment; otherwise it returns None.
5858
Allowable values are defined in :class:`BigSegmentsStatus`. For more information, read the
@@ -65,7 +65,7 @@ def is_default_value(self) -> bool:
6565
variations.
6666
"""
6767
return self.__variation_index is None
68-
68+
6969
def __eq__(self, other) -> bool:
7070
return self.value == other.value and self.variation_index == other.variation_index and self.reason == other.reason
7171

@@ -141,6 +141,8 @@ def add_flag(self, flag_state, with_reasons, details_only_if_tracked):
141141
if not omit_details:
142142
meta['version'] = flag_state['version']
143143

144+
if 'prerequisites' in flag_state and len(flag_state['prerequisites']) > 0:
145+
meta['prerequisites'] = flag_state['prerequisites']
144146
if flag_state['variation'] is not None:
145147
meta['variation'] = flag_state['variation']
146148
if trackEvents:

ldclient/impl/evaluator.py

+18-8
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
# ended up having to do for the context.
2525
class EvalResult:
2626
__slots__ = ['detail', 'events', 'big_segments_status', 'big_segments_membership',
27-
'original_flag_key', 'prereq_stack', 'segment_stack']
27+
'original_flag_key', 'prereq_stack', 'segment_stack', 'depth', 'prerequisites']
2828

2929
def __init__(self):
3030
self.detail = None
@@ -34,6 +34,12 @@ def __init__(self):
3434
self.original_flag_key = None # type: Optional[str]
3535
self.prereq_stack = None # type: Optional[List[str]]
3636
self.segment_stack = None # type: Optional[List[str]]
37+
self.depth = 0
38+
self.prerequisites = [] # type: List[str]
39+
40+
def record_prerequisite(self, key: str):
41+
if self.depth == 0:
42+
self.prerequisites.append(key)
3743

3844
def add_event(self, event: EventInputEvaluation):
3945
if self.events is None:
@@ -48,7 +54,7 @@ class EvaluationException(Exception):
4854
def __init__(self, message: str, error_kind: str = 'MALFORMED_FLAG'):
4955
self._message = message
5056
self._error_kind = error_kind
51-
57+
5258
@property
5359
def message(self) -> str:
5460
return self._message
@@ -125,7 +131,7 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
125131
prereq_res = None
126132
if flag.prerequisites.count == 0:
127133
return None
128-
134+
129135
try:
130136
# We use the state object to guard against circular references in prerequisites. To avoid
131137
# the overhead of creating the state.prereq_stack list in the most common case where
@@ -136,7 +142,7 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
136142
if state.prereq_stack is None:
137143
state.prereq_stack = []
138144
state.prereq_stack.append(flag_key)
139-
145+
140146
for prereq in flag.prerequisites:
141147
prereq_key = prereq.key
142148
if (prereq_key == state.original_flag_key or
@@ -145,11 +151,15 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
145151
' this is probably a temporary condition due to an incomplete update') % prereq_key)
146152

147153
prereq_flag = self.__get_flag(prereq_key)
154+
state.record_prerequisite(prereq_key)
155+
148156
if prereq_flag is None:
149157
log.warning("Missing prereq flag: " + prereq_key)
150158
failed_prereq = prereq
151159
else:
160+
state.depth += 1
152161
prereq_res = self._evaluate(prereq_flag, context, state, event_factory)
162+
state.depth -= 1
153163
# Note that if the prerequisite flag is off, we don't consider it a match no matter what its
154164
# off variation was. But we still need to evaluate it in order to generate an event.
155165
if (not prereq_flag.on) or prereq_res.variation_index != prereq.variation:
@@ -208,7 +218,7 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR
208218
if segment is not None and self._segment_matches_context(segment, context, state):
209219
return _maybe_negate(clause, True)
210220
return _maybe_negate(clause, False)
211-
221+
212222
attr = clause.attribute
213223
if attr is None:
214224
return False
@@ -220,7 +230,7 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR
220230
context_value = _get_context_value_by_attr_ref(actual_context, attr)
221231
if context_value is None:
222232
return False
223-
233+
224234
# is the attr an array?
225235
if isinstance(context_value, (list, tuple)):
226236
for v in context_value:
@@ -287,7 +297,7 @@ def _big_segment_match_context(self, segment: Segment, context: Context, state:
287297
# that as a "not configured" condition.
288298
state.big_segments_status = BigSegmentsStatus.NOT_CONFIGURED
289299
return False
290-
300+
291301
# A big segment can only apply to one context kind, so if we don't have a key for that kind,
292302
# we don't need to bother querying the data.
293303
match_context = context.get_individual_context(segment.unbounded_context_kind or Context.DEFAULT_KIND)
@@ -357,7 +367,7 @@ def _variation_index_for_context(flag: FeatureFlag, vr: VariationOrRollout, cont
357367
variations = rollout.variations
358368
if len(variations) == 0:
359369
return (None, False)
360-
370+
361371
bucket_by = None if rollout.is_experiment else rollout.bucket_by
362372
bucket = _bucket_context(
363373
rollout.seed,

ldclient/testing/test_ldclient_evaluation.py

+80
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,86 @@ def test_all_flags_state_returns_state():
217217
'$valid': True
218218
}
219219

220+
221+
def test_all_flags_state_only_includes_top_level_prereqs():
222+
store = InMemoryFeatureStore()
223+
store.init(
224+
{
225+
FEATURES: {
226+
'top-level-has-prereqs-1': {
227+
'key': 'top-level-has-prereqs-1',
228+
'version': 100,
229+
'on': True,
230+
'fallthrough': {'variation': 0},
231+
'variations': ['value'],
232+
'prerequisites': [
233+
{'key': 'prereq1', 'variation': 0},
234+
{'key': 'prereq2', 'variation': 0}
235+
],
236+
},
237+
'top-level-has-prereqs-2': {
238+
'key': 'top-level-has-prereqs-2',
239+
'version': 100,
240+
'on': True,
241+
'fallthrough': {'variation': 0},
242+
'variations': ['value'],
243+
'prerequisites': [
244+
{'key': 'prereq3', 'variation': 0}
245+
],
246+
},
247+
'prereq1': {
248+
'key': 'prereq1',
249+
'version': 200,
250+
'on': True,
251+
'fallthrough': {'variation': 0},
252+
'variations': ['value'],
253+
},
254+
'prereq2': {
255+
'key': 'prereq2',
256+
'version': 200,
257+
'on': True,
258+
'fallthrough': {'variation': 0},
259+
'variations': ['value'],
260+
},
261+
'prereq3': {
262+
'key': 'prereq3',
263+
'version': 200,
264+
'on': True,
265+
'fallthrough': {'variation': 0},
266+
'variations': ['value'],
267+
},
268+
}
269+
}
270+
)
271+
client = make_client(store)
272+
state = client.all_flags_state(user)
273+
assert state.valid
274+
result = state.to_json_dict()
275+
assert result == {
276+
'top-level-has-prereqs-1': 'value',
277+
'top-level-has-prereqs-2': 'value',
278+
'prereq1': 'value',
279+
'prereq2': 'value',
280+
'prereq3': 'value',
281+
'$flagsState': {
282+
'top-level-has-prereqs-1': {
283+
'variation': 0,
284+
'version': 100,
285+
'prerequisites': ['prereq1', 'prereq2']
286+
},
287+
'top-level-has-prereqs-2': {
288+
'variation': 0,
289+
'version': 100,
290+
'prerequisites': ['prereq3']
291+
},
292+
'prereq1': {'variation': 0, 'version': 200},
293+
'prereq2': {'variation': 0, 'version': 200},
294+
'prereq3': {'variation': 0, 'version': 200},
295+
},
296+
'$valid': True
297+
}
298+
299+
220300
def test_all_flags_state_returns_state_with_reasons():
221301
store = InMemoryFeatureStore()
222302
store.init({ FEATURES: { 'key1': flag1, 'key2': flag2 } })

0 commit comments

Comments
 (0)