Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for client-side prerequisite events #314

Merged
merged 1 commit into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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