Skip to content

Commit

Permalink
feat: Add support for client-side prerequisite events (#314)
Browse files Browse the repository at this point in the history
  • Loading branch information
keelerm84 authored Oct 16, 2024
1 parent e95d324 commit 3025af4
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 12 deletions.
3 changes: 2 additions & 1 deletion contract-tests/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ def status():
'inline-context',
'anonymous-redaction',
'evaluation-hooks',
'omit-anonymous-contexts'
'omit-anonymous-contexts',
'client-prereq-events'
]
}
return (json.dumps(body), 200, {'Content-type': 'application/json'})
Expand Down
4 changes: 3 additions & 1 deletion ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,8 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
if client_only and not flag.get('clientSide', False):
continue
try:
detail = self._evaluator.evaluate(flag, context, self._event_factory_default).detail
result = self._evaluator.evaluate(flag, context, self._event_factory_default)
detail = result.detail
except Exception as e:
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
log.debug(traceback.format_exc())
Expand All @@ -572,6 +573,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
'variation': detail.variation_index,
'reason': detail.reason,
'version': flag['version'],
'prerequisites': result.prerequisites,
'trackEvents': flag.get('trackEvents', False) or requires_experiment_data,
'trackReason': requires_experiment_data,
'debugEventsUntilDate': flag.get('debugEventsUntilDate', None),
Expand Down
6 changes: 4 additions & 2 deletions ldclient/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def reason(self) -> dict:
* ``errorKind``: further describes the nature of the error if the kind was ``ERROR``,
e.g. ``"FLAG_NOT_FOUND"``
* ``bigSegmentsStatus``: describes the validity of Big Segment information, if and only if
the flag evaluation required querying at least one Big Segment; otherwise it returns None.
Allowable values are defined in :class:`BigSegmentsStatus`. For more information, read the
Expand All @@ -65,7 +65,7 @@ def is_default_value(self) -> bool:
variations.
"""
return self.__variation_index is None

def __eq__(self, other) -> bool:
return self.value == other.value and self.variation_index == other.variation_index and self.reason == other.reason

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

if 'prerequisites' in flag_state and len(flag_state['prerequisites']) > 0:
meta['prerequisites'] = flag_state['prerequisites']
if flag_state['variation'] is not None:
meta['variation'] = flag_state['variation']
if trackEvents:
Expand Down
26 changes: 18 additions & 8 deletions ldclient/impl/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
# ended up having to do for the context.
class EvalResult:
__slots__ = ['detail', 'events', 'big_segments_status', 'big_segments_membership',
'original_flag_key', 'prereq_stack', 'segment_stack']
'original_flag_key', 'prereq_stack', 'segment_stack', 'depth', 'prerequisites']

def __init__(self):
self.detail = None
Expand All @@ -34,6 +34,12 @@ def __init__(self):
self.original_flag_key = None # type: Optional[str]
self.prereq_stack = None # type: Optional[List[str]]
self.segment_stack = None # type: Optional[List[str]]
self.depth = 0
self.prerequisites = [] # type: List[str]

def record_prerequisite(self, key: str):
if self.depth == 0:
self.prerequisites.append(key)

def add_event(self, event: EventInputEvaluation):
if self.events is None:
Expand All @@ -48,7 +54,7 @@ class EvaluationException(Exception):
def __init__(self, message: str, error_kind: str = 'MALFORMED_FLAG'):
self._message = message
self._error_kind = error_kind

@property
def message(self) -> str:
return self._message
Expand Down Expand Up @@ -125,7 +131,7 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
prereq_res = None
if flag.prerequisites.count == 0:
return None

try:
# We use the state object to guard against circular references in prerequisites. To avoid
# the overhead of creating the state.prereq_stack list in the most common case where
Expand All @@ -136,7 +142,7 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
if state.prereq_stack is None:
state.prereq_stack = []
state.prereq_stack.append(flag_key)

for prereq in flag.prerequisites:
prereq_key = prereq.key
if (prereq_key == state.original_flag_key or
Expand All @@ -145,11 +151,15 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
' this is probably a temporary condition due to an incomplete update') % prereq_key)

prereq_flag = self.__get_flag(prereq_key)
state.record_prerequisite(prereq_key)

if prereq_flag is None:
log.warning("Missing prereq flag: " + prereq_key)
failed_prereq = prereq
else:
state.depth += 1
prereq_res = self._evaluate(prereq_flag, context, state, event_factory)
state.depth -= 1
# 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.on) or prereq_res.variation_index != prereq.variation:
Expand Down Expand Up @@ -208,7 +218,7 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR
if segment is not None and self._segment_matches_context(segment, context, state):
return _maybe_negate(clause, True)
return _maybe_negate(clause, False)

attr = clause.attribute
if attr is None:
return False
Expand All @@ -220,7 +230,7 @@ def _clause_matches_context(self, clause: Clause, context: Context, state: EvalR
context_value = _get_context_value_by_attr_ref(actual_context, attr)
if context_value is None:
return False

# is the attr an array?
if isinstance(context_value, (list, tuple)):
for v in context_value:
Expand Down Expand Up @@ -287,7 +297,7 @@ def _big_segment_match_context(self, segment: Segment, context: Context, state:
# that as a "not configured" condition.
state.big_segments_status = BigSegmentsStatus.NOT_CONFIGURED
return False

# A big segment can only apply to one context kind, so if we don't have a key for that kind,
# we don't need to bother querying the data.
match_context = context.get_individual_context(segment.unbounded_context_kind or Context.DEFAULT_KIND)
Expand Down Expand Up @@ -357,7 +367,7 @@ def _variation_index_for_context(flag: FeatureFlag, vr: VariationOrRollout, cont
variations = rollout.variations
if len(variations) == 0:
return (None, False)

bucket_by = None if rollout.is_experiment else rollout.bucket_by
bucket = _bucket_context(
rollout.seed,
Expand Down
80 changes: 80 additions & 0 deletions ldclient/testing/test_ldclient_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,86 @@ def test_all_flags_state_returns_state():
'$valid': True
}


def test_all_flags_state_only_includes_top_level_prereqs():
store = InMemoryFeatureStore()
store.init(
{
FEATURES: {
'top-level-has-prereqs-1': {
'key': 'top-level-has-prereqs-1',
'version': 100,
'on': True,
'fallthrough': {'variation': 0},
'variations': ['value'],
'prerequisites': [
{'key': 'prereq1', 'variation': 0},
{'key': 'prereq2', 'variation': 0}
],
},
'top-level-has-prereqs-2': {
'key': 'top-level-has-prereqs-2',
'version': 100,
'on': True,
'fallthrough': {'variation': 0},
'variations': ['value'],
'prerequisites': [
{'key': 'prereq3', 'variation': 0}
],
},
'prereq1': {
'key': 'prereq1',
'version': 200,
'on': True,
'fallthrough': {'variation': 0},
'variations': ['value'],
},
'prereq2': {
'key': 'prereq2',
'version': 200,
'on': True,
'fallthrough': {'variation': 0},
'variations': ['value'],
},
'prereq3': {
'key': 'prereq3',
'version': 200,
'on': True,
'fallthrough': {'variation': 0},
'variations': ['value'],
},
}
}
)
client = make_client(store)
state = client.all_flags_state(user)
assert state.valid
result = state.to_json_dict()
assert result == {
'top-level-has-prereqs-1': 'value',
'top-level-has-prereqs-2': 'value',
'prereq1': 'value',
'prereq2': 'value',
'prereq3': 'value',
'$flagsState': {
'top-level-has-prereqs-1': {
'variation': 0,
'version': 100,
'prerequisites': ['prereq1', 'prereq2']
},
'top-level-has-prereqs-2': {
'variation': 0,
'version': 100,
'prerequisites': ['prereq3']
},
'prereq1': {'variation': 0, 'version': 200},
'prereq2': {'variation': 0, 'version': 200},
'prereq3': {'variation': 0, 'version': 200},
},
'$valid': True
}


def test_all_flags_state_returns_state_with_reasons():
store = InMemoryFeatureStore()
store.init({ FEATURES: { 'key1': flag1, 'key2': flag2 } })
Expand Down

0 comments on commit 3025af4

Please sign in to comment.