Skip to content

Commit 042fce2

Browse files
committed
feat: Mark override-affected evaluations in analytics events
Carries the override-affected marking on the evaluation event input, so the event processor keys on the marking alone and never reads the evaluation reason. An evaluation marked as override-affected produces no individual feature event and no debug event, even when the flag requests them, and it is counted in summary events like any other evaluation. The marking is part of the summary counter key, so override-affected and ordinary evaluations of the same flag, variation, and version accumulate into separate counters, and a counter that aggregates marked evaluations carries the overrideAffected marker, present only when true, like the unknown marker. The evaluator passes each prerequisite record's own marking to its event, so a marked prerequisite record produces no individual event while an unaffected prerequisite inside a marked evaluation is recorded as usual. The client passes the top-level marking to the evaluation event, keeps the marking on the reason when a migration evaluation replaces it with a wrong-type error, and presents an override-affected flag in the all-flags state with trackEvents and trackReason false and no debugEventsUntilDate, so a consumer bootstrapped from the state sends no individual events for it. The async client mirrors these changes. The OVERRIDE specification vector runner now also checks the marking each evaluation contributes to its summary counter.
1 parent 3fcdc80 commit 042fce2

12 files changed

Lines changed: 482 additions & 22 deletions

‎ldclient/async_client.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,12 @@ async def evaluate():
486486
tracker = OpTracker(key, flag, context, detail, default_stage)
487487
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': stage, 'tracker': tracker})
488488

489-
detail = EvaluationDetail(default_stage.value, None, error_reason('WRONG_TYPE'))
489+
# The type mismatch replaces the reason. The evaluation read the same definitions,
490+
# so the new reason keeps the override-affected marking.
491+
reason = error_reason('WRONG_TYPE')
492+
if detail.reason.get('overrideAffected') is True:
493+
reason['overrideAffected'] = True
494+
detail = EvaluationDetail(default_stage.value, None, reason)
490495
tracker = OpTracker(key, flag, context, detail, default_stage)
491496
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': default_stage, 'tracker': tracker})
492497

@@ -546,7 +551,7 @@ def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
546551
detail = result.detail
547552
if detail.is_default_value():
548553
detail = EvaluationDetail(default, None, detail.reason)
549-
self._send_event(event_factory.new_eval_event(flag, context, detail, default))
554+
self._send_event(event_factory.new_eval_event(flag, context, detail, default, None, result.override_affected))
550555
return detail, flag
551556
except Exception as e:
552557
log.error("Unexpected error while evaluating feature flag \"%s\": %s" % (key, repr(e)))
@@ -630,23 +635,35 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
630635
result = await self._evaluator.evaluate(flag, context, self._event_factory_default)
631636
detail = result.detail
632637
prerequisites = result.prerequisites
638+
override_affected = result.override_affected
633639
except Exception as e:
634640
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
635641
log.debug(traceback.format_exc())
636642
reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}
637643
detail = EvaluationDetail(None, None, reason)
638644
prerequisites = []
645+
override_affected = False
639646
requires_experiment_data = EventFactory.is_experiment(flag, detail.reason)
647+
track_events = flag.get('trackEvents', False) or requires_experiment_data
648+
track_reason = requires_experiment_data
649+
debug_events_until_date = flag.get('debugEventsUntilDate', None)
650+
if override_affected:
651+
# A consumer of this state sends individual events according to these fields.
652+
# An override-affected evaluation produces no individual events, so the state
653+
# turns them off for this flag. The flag, its value, and its reason stay.
654+
track_events = False
655+
track_reason = False
656+
debug_events_until_date = None
640657
flag_state = {
641658
'key': flag['key'],
642659
'value': detail.value,
643660
'variation': detail.variation_index,
644661
'reason': detail.reason,
645662
'version': flag['version'],
646663
'prerequisites': prerequisites,
647-
'trackEvents': flag.get('trackEvents', False) or requires_experiment_data,
648-
'trackReason': requires_experiment_data,
649-
'debugEventsUntilDate': flag.get('debugEventsUntilDate', None),
664+
'trackEvents': track_events,
665+
'trackReason': track_reason,
666+
'debugEventsUntilDate': debug_events_until_date,
650667
}
651668

652669
state.add_flag(flag_state, with_reasons, details_only_if_tracked)

‎ldclient/client.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,12 @@ def evaluate():
455455
tracker = OpTracker(key, flag, context, detail, default_stage)
456456
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': stage, 'tracker': tracker})
457457

458-
detail = EvaluationDetail(default_stage.value, None, error_reason('WRONG_TYPE'))
458+
# The type mismatch replaces the reason. The evaluation read the same definitions,
459+
# so the new reason keeps the override-affected marking.
460+
reason = error_reason('WRONG_TYPE')
461+
if detail.reason.get('overrideAffected') is True:
462+
reason['overrideAffected'] = True
463+
detail = EvaluationDetail(default_stage.value, None, reason)
459464
tracker = OpTracker(key, flag, context, detail, default_stage)
460465
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': default_stage, 'tracker': tracker})
461466

@@ -517,7 +522,7 @@ def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
517522
detail = result.detail
518523
if detail.is_default_value():
519524
detail = EvaluationDetail(default, None, detail.reason)
520-
self._send_event(event_factory.new_eval_event(flag, context, detail, default))
525+
self._send_event(event_factory.new_eval_event(flag, context, detail, default, None, result.override_affected))
521526
return detail, flag
522527
except Exception as e:
523528
log.error("Unexpected error while evaluating feature flag \"%s\": %s" % (key, repr(e)))
@@ -607,24 +612,36 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
607612
result = self._evaluator.evaluate(flag, context, self._event_factory_default)
608613
detail = result.detail
609614
prerequisites = result.prerequisites
615+
override_affected = result.override_affected
610616
except Exception as e:
611617
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
612618
log.debug(traceback.format_exc())
613619
reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}
614620
detail = EvaluationDetail(None, None, reason)
615621
prerequisites = []
622+
override_affected = False
616623

617624
requires_experiment_data = EventFactory.is_experiment(flag, detail.reason)
625+
track_events = flag.get('trackEvents', False) or requires_experiment_data
626+
track_reason = requires_experiment_data
627+
debug_events_until_date = flag.get('debugEventsUntilDate', None)
628+
if override_affected:
629+
# A consumer of this state sends individual events according to these fields.
630+
# An override-affected evaluation produces no individual events, so the state
631+
# turns them off for this flag. The flag, its value, and its reason stay.
632+
track_events = False
633+
track_reason = False
634+
debug_events_until_date = None
618635
flag_state = {
619636
'key': flag['key'],
620637
'value': detail.value,
621638
'variation': detail.variation_index,
622639
'reason': detail.reason,
623640
'version': flag['version'],
624641
'prerequisites': prerequisites,
625-
'trackEvents': flag.get('trackEvents', False) or requires_experiment_data,
626-
'trackReason': requires_experiment_data,
627-
'debugEventsUntilDate': flag.get('debugEventsUntilDate', None),
642+
'trackEvents': track_events,
643+
'trackReason': track_reason,
644+
'debugEventsUntilDate': debug_events_until_date,
628645
}
629646

630647
state.add_flag(flag_state, with_reasons, details_only_if_tracked)

‎ldclient/impl/async_evaluator.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async def _check_prerequisites(self, flag: FeatureFlag, context: Context, state:
148148
# off variation was. But we still need to evaluate it in order to generate an event.
149149
if (not prereq_flag.on) or prereq_res.variation_index != prereq.variation:
150150
failed_prereq = prereq
151-
event = event_factory.new_eval_event(prereq_flag, context, prereq_res, None, flag)
151+
event = event_factory.new_eval_event(prereq_flag, context, prereq_res, None, flag, prereq_override_affected)
152152
state.add_event(event)
153153
if failed_prereq:
154154
return {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': failed_prereq.key}

‎ldclient/impl/evaluator.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def _check_prerequisites(self, flag: FeatureFlag, context: Context, state: EvalR
146146
# off variation was. But we still need to evaluate it in order to generate an event.
147147
if (not prereq_flag.on) or prereq_res.variation_index != prereq.variation:
148148
failed_prereq = prereq
149-
event = event_factory.new_eval_event(prereq_flag, context, prereq_res, None, flag)
149+
event = event_factory.new_eval_event(prereq_flag, context, prereq_res, None, flag, prereq_override_affected)
150150
state.add_event(event)
151151
if failed_prereq:
152152
return {'kind': 'PREREQUISITE_FAILED', 'prerequisiteKey': failed_prereq.key}

‎ldclient/impl/events/event_processor_common.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,17 @@ def make_summary_event(self, summary: EventSummary):
181181
}
182182
counters = []
183183
for ckey, cval in flag_data.counters.items():
184-
variation, version = ckey
184+
variation, version, override_affected = ckey
185185
counter = {'count': cval.count, 'value': cval.value}
186186
if variation is not None:
187187
counter['variation'] = variation
188188
if version is None:
189189
counter['unknown'] = True
190190
else:
191191
counter['version'] = version
192+
# The marker is present only when true, like the unknown marker.
193+
if override_affected:
194+
counter['overrideAffected'] = True
192195
counters.append(counter)
193196
flag_data_out['counters'] = counters
194197
flags_out[key] = flag_data_out
@@ -266,9 +269,11 @@ def _process_event(self, event: EventInput):
266269
context = event.context
267270
if not event.exclude_from_summaries:
268271
self._outbox.add_to_summary(event)
269-
if event.track_events:
272+
# An override-affected evaluation appears only in the summary counters. It produces
273+
# no individual feature event and no debug event, even when the flag requests them.
274+
if event.track_events and not event.override_affected:
270275
full_event = event
271-
if self._should_debug_event(event):
276+
if not event.override_affected and self._should_debug_event(event):
272277
debug_event = DebugEvent(event)
273278
elif isinstance(event, EventInputIdentify):
274279
if self._omit_anonymous_contexts:

‎ldclient/impl/events/event_summarizer.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ def __repr__(self) -> str: # used only in test debugging
2222
return "EventSummaryCounter(%d, %s)" % (self.count, self.value)
2323

2424

25+
# A counter is keyed by variation index, flag version, and whether the evaluations it counts
26+
# were override-affected. The marker is part of the key, so override-affected and ordinary
27+
# evaluations of the same flag, variation, and version accumulate into separate counters.
28+
CounterKey = Tuple[Optional[int], Optional[int], bool]
29+
30+
2531
class EventSummaryFlag:
2632
__slots__ = ['context_kinds', 'default', 'counters']
2733

28-
def __init__(self, context_kinds: Set[str], default: Any, counters: Dict[Tuple[Optional[int], Optional[int]], EventSummaryCounter]):
34+
def __init__(self, context_kinds: Set[str], default: Any, counters: Dict[CounterKey, EventSummaryCounter]):
2935
self.context_kinds = context_kinds
3036
self.counters = counters
3137
self.default = default
@@ -71,7 +77,7 @@ def summarize_event(self, event: EventInputEvaluation):
7177
if c is not None:
7278
flag_data.context_kinds.add(c.kind)
7379

74-
counter_key = (event.variation, None if event.flag is None else event.flag.version)
80+
counter_key = (event.variation, None if event.flag is None else event.flag.version, event.override_affected)
7581
counter = flag_data.counters.get(counter_key)
7682
if counter is None:
7783
counter = EventSummaryCounter(1, event.value)

‎ldclient/impl/events/types.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class EventInputEvaluation(EventInput):
5252
"track_events",
5353
"sampling_ratio",
5454
"exclude_from_summaries",
55+
"override_affected",
5556
]
5657

5758
def __init__(
@@ -66,6 +67,7 @@ def __init__(
6667
default_value: Any,
6768
prereq_of: Optional[FeatureFlag] = None,
6869
track_events: bool = False,
70+
override_affected: bool = False,
6971
):
7072
super().__init__(timestamp, context, 1 if flag is None else flag.sampling_ratio)
7173
self.key = key
@@ -79,6 +81,11 @@ def __init__(
7981
self.exclude_from_summaries = (
8082
False if flag is None else flag.exclude_from_summaries
8183
)
84+
# True when a flag override affected this evaluation, directly or through a
85+
# prerequisite or segment. The event processor keys on this scalar alone, never on the
86+
# reason: such an evaluation produces no individual feature or debug event and is
87+
# counted in a summary counter that carries the override-affected marker.
88+
self.override_affected = override_affected
8289

8390
def to_debugging_dict(self) -> dict:
8491
return {
@@ -94,6 +101,7 @@ def to_debugging_dict(self) -> dict:
94101
"track_events": self.track_events,
95102
"exclude_from_summaries": self.exclude_from_summaries,
96103
"sampling_ratio": self.sampling_ratio,
104+
"override_affected": self.override_affected,
97105
}
98106

99107

@@ -155,6 +163,7 @@ def new_eval_event(
155163
detail: EvaluationDetail,
156164
default_value: Any,
157165
prereq_of_flag: Optional[FeatureFlag] = None,
166+
override_affected: bool = False,
158167
) -> EventInputEvaluation:
159168
add_experiment_data = self.is_experiment(flag, detail.reason)
160169
return EventInputEvaluation(
@@ -168,6 +177,7 @@ def new_eval_event(
168177
default_value,
169178
prereq_of_flag,
170179
flag.track_events or add_experiment_data,
180+
override_affected,
171181
)
172182

173183
def new_default_event(

0 commit comments

Comments
 (0)