Skip to content

Commit 0fd11f1

Browse files
Telemetry Support (#24)
* telemetry support * updating from feedback * Changes from design meeting * formatting, updating samples * fixing merge issue * typing issue * fixing test validations from changes * Update test_json_validations.py * Apply suggestions from code review Co-authored-by: Zhiyuan Liang <[email protected]> * Updating doc strings * Spelling * Updating async name. * Adding missing eval reason. Fixed formatting. * Fixing Merge issue * fixing merge * fixing merge * review comments * Updating evaluation event usage * Updated feature flag usage * updating assign allocation logic * formatting * Adding Just Open Telemetry Support * Update _send_telemetry.py * review items * Update test_send_telemetry_appinsights.py * review comments * fixing default * Removing Just Open Telemetry * Removing open telemetry test * rename to azuremonitor * Update test_send_telemetry_appinsights.py * Update feature_variant_sample_with_telemetry.py * Fixing Enabled = False usage with status override * Removed extra false * Removed extra enabled --------- Co-authored-by: Zhiyuan Liang <[email protected]>
1 parent 7260990 commit 0fd11f1

15 files changed

+434
-151
lines changed

dev_requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ sphinx_rtd_theme
99
sphinx-toolbox
1010
myst_parser
1111
azure-appconfiguration-provider
12+
azure-monitor-opentelemetry
13+
azure-monitor-events-extension

featuremanagement/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from ._featuremanager import FeatureManager
77
from ._featurefilters import FeatureFilter
88
from ._defaultfilters import TimeWindowFilter, TargetingFilter
9-
from ._models import FeatureFlag, Variant, TargetingContext
9+
from ._models import FeatureFlag, Variant, EvaluationEvent, VariantAssignmentReason, TargetingContext
1010

1111
from ._version import VERSION
1212

@@ -18,5 +18,7 @@
1818
"FeatureFilter",
1919
"FeatureFlag",
2020
"Variant",
21+
"EvaluationEvent",
22+
"VariantAssignmentReason",
2123
"TargetingContext",
2224
]

featuremanagement/_featuremanager.py

Lines changed: 97 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import overload
1010
from ._defaultfilters import TimeWindowFilter, TargetingFilter
1111
from ._featurefilters import FeatureFilter
12-
from ._models import FeatureFlag, Variant, EvaluationEvent, TargetingContext
12+
from ._models import FeatureFlag, Variant, EvaluationEvent, VariantAssignmentReason, TargetingContext
1313

1414

1515
FEATURE_MANAGEMENT_KEY = "feature_management"
@@ -73,6 +73,8 @@ class FeatureManager:
7373
7474
:param Mapping configuration: Configuration object.
7575
:keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
76+
:keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is
77+
evaluated.
7678
"""
7779

7880
def __init__(self, configuration, **kwargs):
@@ -82,6 +84,7 @@ def __init__(self, configuration, **kwargs):
8284
self._configuration = configuration
8385
self._cache = {}
8486
self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
87+
self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None)
8588
filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, [])
8689

8790
for feature_filter in filters:
@@ -90,54 +93,63 @@ def __init__(self, configuration, **kwargs):
9093
self._filters[feature_filter.name] = feature_filter
9194

9295
@staticmethod
93-
def _check_default_disabled_variant(feature_flag):
96+
def _check_default_disabled_variant(evaluation_event):
9497
"""
9598
A method called when the feature flag is disabled, to determine what the default variant should be. If there is
9699
no allocation, then None is set as the value of the variant in the EvaluationEvent.
97100
98-
:param FeatureFlag feature_flag: Feature flag object.
99-
:return: EvaluationEvent
101+
:param EvaluationEvent evaluation_event: Evaluation event object.
100102
"""
101-
if not feature_flag.allocation:
102-
return EvaluationEvent(enabled=False)
103-
return FeatureManager._check_variant_override(
104-
feature_flag.variants, feature_flag.allocation.default_when_disabled, False
103+
evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED
104+
if not evaluation_event.feature.allocation:
105+
return
106+
FeatureManager._check_variant_override(
107+
evaluation_event.feature.variants,
108+
evaluation_event.feature.allocation.default_when_disabled,
109+
False,
110+
evaluation_event,
105111
)
106112

107113
@staticmethod
108-
def _check_default_enabled_variant(feature_flag):
114+
def _check_default_enabled_variant(evaluation_event):
109115
"""
110116
A method called when the feature flag is enabled, to determine what the default variant should be. If there is
111117
no allocation, then None is set as the value of the variant in the EvaluationEvent.
112118
113-
:param FeatureFlag feature_flag: Feature flag object.
114-
:return: EvaluationEvent
119+
:param EvaluationEvent evaluation_event: Evaluation event object.
115120
"""
116-
if not feature_flag.allocation:
117-
return EvaluationEvent(enabled=True)
118-
return FeatureManager._check_variant_override(
119-
feature_flag.variants, feature_flag.allocation.default_when_enabled, True
121+
evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED
122+
if not evaluation_event.feature.allocation:
123+
return
124+
FeatureManager._check_variant_override(
125+
evaluation_event.feature.variants,
126+
evaluation_event.feature.allocation.default_when_enabled,
127+
True,
128+
evaluation_event,
120129
)
121130

122131
@staticmethod
123-
def _check_variant_override(variants, default_variant_name, status):
132+
def _check_variant_override(variants, default_variant_name, status, evaluation_event):
124133
"""
125134
A method to check if a variant is overridden to be enabled or disabled by the variant.
126135
127136
:param list[Variant] variants: List of variants.
128137
:param str default_variant_name: Name of the default variant.
129138
:param bool status: Status of the feature flag.
130-
:return: EvaluationEvent
139+
:param EvaluationEvent evaluation_event: Evaluation event object.
131140
"""
132141
if not variants or not default_variant_name:
133-
return EvaluationEvent(enabled=status)
142+
evaluation_event.enabled = status
143+
return
134144
for variant in variants:
135145
if variant.name == default_variant_name:
136146
if variant.status_override == "Enabled":
137-
return EvaluationEvent(enabled=True)
147+
evaluation_event.enabled = True
148+
return
138149
if variant.status_override == "Disabled":
139-
return EvaluationEvent(enabled=False)
140-
return EvaluationEvent(enabled=status)
150+
evaluation_event.enabled = False
151+
return
152+
evaluation_event.enabled = status
141153

142154
@staticmethod
143155
def _is_targeted(context_id):
@@ -147,34 +159,45 @@ def _is_targeted(context_id):
147159

148160
return (context_marker / (2**32 - 1)) * 100
149161

150-
def _assign_variant(self, feature_flag, targeting_context):
162+
def _assign_variant(self, feature_flag, targeting_context, evaluation_event):
151163
"""
152164
Assign a variant to the user based on the allocation.
153165
154-
:param FeatureFlag feature_flag: Feature flag object.
155166
:param TargetingContext targeting_context: Targeting context.
156-
:return: Variant name.
167+
:param EvaluationEvent evaluation_event: Evaluation event object.
157168
"""
158-
if not feature_flag.variants or not feature_flag.allocation:
159-
return None
160-
if feature_flag.allocation.user and targeting_context.user_id:
161-
for user_allocation in feature_flag.allocation.user:
169+
feature = evaluation_event.feature
170+
variant_name = None
171+
if not feature.variants or not feature.allocation:
172+
return
173+
if feature.allocation.user and targeting_context.user_id:
174+
for user_allocation in feature.allocation.user:
162175
if targeting_context.user_id in user_allocation.users:
163-
return user_allocation.variant
164-
if feature_flag.allocation.group and len(targeting_context.groups) > 0:
165-
for group_allocation in feature_flag.allocation.group:
176+
evaluation_event.reason = VariantAssignmentReason.USER
177+
variant_name = user_allocation.variant
178+
elif feature.allocation.group and len(targeting_context.groups) > 0:
179+
for group_allocation in feature.allocation.group:
166180
for group in targeting_context.groups:
167181
if group in group_allocation.groups:
168-
return group_allocation.variant
169-
if feature_flag.allocation.percentile:
170-
context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed
182+
evaluation_event.reason = VariantAssignmentReason.GROUP
183+
variant_name = group_allocation.variant
184+
elif feature.allocation.percentile:
185+
context_id = targeting_context.user_id + "\n" + feature.allocation.seed
171186
box = self._is_targeted(context_id)
172-
for percentile_allocation in feature_flag.allocation.percentile:
187+
for percentile_allocation in feature.allocation.percentile:
173188
if box == 100 and percentile_allocation.percentile_to == 100:
174-
return percentile_allocation.variant
189+
variant_name = percentile_allocation.variant
175190
if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to:
176-
return percentile_allocation.variant
177-
return None
191+
evaluation_event.reason = VariantAssignmentReason.PERCENTILE
192+
variant_name = percentile_allocation.variant
193+
if not variant_name:
194+
FeatureManager._check_default_enabled_variant(evaluation_event)
195+
evaluation_event.variant = self._variant_name_to_variant(
196+
feature_flag, feature_flag.allocation.default_when_enabled
197+
)
198+
return
199+
evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name)
200+
FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event)
178201

179202
def _variant_name_to_variant(self, feature_flag, variant_name):
180203
"""
@@ -186,6 +209,8 @@ def _variant_name_to_variant(self, feature_flag, variant_name):
186209
"""
187210
if not feature_flag.variants:
188211
return None
212+
if not variant_name:
213+
return None
189214
for variant_reference in feature_flag.variants:
190215
if variant_reference.name == variant_name:
191216
configuration = variant_reference.configuration_value
@@ -230,6 +255,9 @@ def is_enabled(self, feature_flag_id, *args, **kwargs):
230255
targeting_context = self._build_targeting_context(args)
231256

232257
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
258+
if self._on_feature_evaluated and result.feature.telemetry.enabled:
259+
result.user = targeting_context.user_id
260+
self._on_feature_evaluated(result)
233261
return result.enabled
234262

235263
@overload
@@ -255,12 +283,15 @@ def get_variant(self, feature_flag_id, *args, **kwargs):
255283
targeting_context = self._build_targeting_context(args)
256284

257285
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
286+
if self._on_feature_evaluated and result.feature.telemetry.enabled:
287+
result.user = targeting_context.user_id
288+
self._on_feature_evaluated(result)
258289
return result.variant
259290

260-
def _check_feature_filters(self, feature_flag, targeting_context, **kwargs):
291+
def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs):
292+
feature_flag = evaluation_event.feature
261293
feature_conditions = feature_flag.conditions
262294
feature_filters = feature_conditions.client_filters
263-
evaluation_event = EvaluationEvent(enabled=False)
264295

265296
if len(feature_filters) == 0:
266297
# Feature flags without any filters return evaluate
@@ -283,31 +314,24 @@ def _check_feature_filters(self, feature_flag, targeting_context, **kwargs):
283314
elif self._filters[filter_name].evaluate(feature_filter, **kwargs):
284315
evaluation_event.enabled = True
285316
break
286-
return evaluation_event
287-
288-
def _assign_allocation(self, feature_flag, evaluation_event, targeting_context):
289-
if feature_flag.allocation and feature_flag.variants:
290-
variant_name = self._assign_variant(feature_flag, targeting_context)
291-
if variant_name:
292-
evaluation_event.enabled = FeatureManager._check_variant_override(
293-
feature_flag.variants, variant_name, evaluation_event.enabled
294-
).enabled
295-
evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name)
296-
evaluation_event.feature = feature_flag
297-
return evaluation_event
298317

299-
variant_name = None
300-
if evaluation_event.enabled:
301-
evaluation_event = FeatureManager._check_default_enabled_variant(feature_flag)
302-
if feature_flag.allocation:
303-
variant_name = feature_flag.allocation.default_when_enabled
304-
else:
305-
evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag)
306-
if feature_flag.allocation:
307-
variant_name = feature_flag.allocation.default_when_disabled
308-
evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name)
309-
evaluation_event.feature = feature_flag
310-
return evaluation_event
318+
def _assign_allocation(self, evaluation_event, targeting_context):
319+
feature_flag = evaluation_event.feature
320+
if feature_flag.variants:
321+
if not feature_flag.allocation:
322+
if evaluation_event.enabled:
323+
evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED
324+
return
325+
evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED
326+
return
327+
if not evaluation_event.enabled:
328+
FeatureManager._check_default_disabled_variant(evaluation_event)
329+
evaluation_event.variant = self._variant_name_to_variant(
330+
feature_flag, feature_flag.allocation.default_when_disabled
331+
)
332+
return
333+
334+
self._assign_variant(feature_flag, targeting_context, evaluation_event)
311335

312336
def _check_feature(self, feature_flag_id, targeting_context, **kwargs):
313337
"""
@@ -327,23 +351,28 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs):
327351
else:
328352
feature_flag = self._cache.get(feature_flag_id)
329353

354+
evaluation_event = EvaluationEvent(feature_flag)
330355
if not feature_flag:
331356
logging.warning("Feature flag %s not found", feature_flag_id)
332357
# Unknown feature flags are disabled by default
333-
return EvaluationEvent(enabled=False)
358+
return evaluation_event
334359

335360
if not feature_flag.enabled:
336361
# Feature flags that are disabled are always disabled
337-
evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag)
362+
FeatureManager._check_default_disabled_variant(evaluation_event)
338363
if feature_flag.allocation:
339364
variant_name = feature_flag.allocation.default_when_disabled
340365
evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name)
341366
evaluation_event.feature = feature_flag
367+
368+
# If a feature flag is disabled and override can't enable it
369+
evaluation_event.enabled = False
342370
return evaluation_event
343371

344-
evaluation_event = self._check_feature_filters(feature_flag, targeting_context, **kwargs)
372+
self._check_feature_filters(evaluation_event, targeting_context, **kwargs)
345373

346-
return self._assign_allocation(feature_flag, evaluation_event, targeting_context)
374+
self._assign_allocation(evaluation_event, targeting_context)
375+
return evaluation_event
347376

348377
def list_feature_flag_names(self):
349378
"""

featuremanagement/_models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from ._feature_flag import FeatureFlag
77
from ._variant import Variant
88
from ._evaluation_event import EvaluationEvent
9+
from ._variant_assignment_reason import VariantAssignmentReason
910
from ._targeting_context import TargetingContext
1011

1112
__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore
1213

13-
__all__ = ["FeatureFlag", "Variant", "EvaluationEvent", "TargetingContext"]
14+
__all__ = ["FeatureFlag", "Variant", "EvaluationEvent", "VariantAssignmentReason", "TargetingContext"]

featuremanagement/_models/_evaluation_event.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ class EvaluationEvent:
1212
Represents a feature flag evaluation event.
1313
"""
1414

15-
def __init__(self, *, enabled=False, feature_flag=None):
15+
def __init__(self, feature_flag):
1616
"""
1717
Initialize the EvaluationEvent.
1818
"""
1919
self.feature = feature_flag
2020
self.user = ""
21-
self.enabled = enabled
21+
self.enabled = False
2222
self.variant = None
2323
self.reason = None
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from enum import Enum
7+
8+
9+
class VariantAssignmentReason(Enum):
10+
"""
11+
Represents an assignment reason.
12+
"""
13+
14+
NONE = "None"
15+
DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled"
16+
DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
17+
USER = "User"
18+
GROUP = "Group"
19+
PERCENTILE = "Percentile"

featuremanagement/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
# license information.
55
# -------------------------------------------------------------------------
66

7-
VERSION = "1.0.0"
7+
VERSION = "2.0.0b1"

0 commit comments

Comments
 (0)