Skip to content

Commit 4a88b06

Browse files
mrm9084rossgrambo
andauthored
Feature/exp telemetry (#58)
* Added Accessor (#55) * Added Accessor with comments * comment and format change * fixing log * fixing log length * async version with tests * fix test and pylint issue * Update featuremanagement/aio/_featuremanager.py Co-authored-by: Ross Grambo <[email protected]> * Update _featuremanager.py --------- Co-authored-by: Ross Grambo <[email protected]> * New OTel Integration (#54) * New OTel Integration * fixing issues * pylint fixes * fixing mypy issues * Update _send_telemetry.py * Update _send_telemetry.py * Update dev_requirements.txt * fixing checks * Update _send_telemetry.py * Update _send_telemetry.py * trying mypy fix * Update _send_telemetry.py * Update to use accessor * remove attach_targeting_info * added tests * Adding Quart sample * Added otel to quart sample * Update requirements.txt * Updating Version/Status * review comments * Update _version.py --------- Co-authored-by: Ross Grambo <[email protected]>
1 parent d4815b5 commit 4a88b06

15 files changed

+394
-17
lines changed

dev_requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ sphinx
88
sphinx_rtd_theme
99
sphinx-toolbox
1010
myst_parser
11+
opentelemetry-api
12+
opentelemetry-sdk

featuremanagement/_featuremanager.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# Licensed under the MIT License. See License.txt in the project root for
44
# license information.
55
# -------------------------------------------------------------------------
6-
from typing import cast, overload, Any, Optional, Dict, Mapping, List
6+
import logging
7+
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
78
from ._defaultfilters import TimeWindowFilter, TargetingFilter
89
from ._featurefilters import FeatureFilter
910
from ._models import EvaluationEvent, Variant, TargetingContext
@@ -14,6 +15,8 @@
1415
FEATURE_FILTER_NAME,
1516
)
1617

18+
logger = logging.getLogger(__name__)
19+
1720

1821
class FeatureManager(FeatureManagerBase):
1922
"""
@@ -23,6 +26,8 @@ class FeatureManager(FeatureManagerBase):
2326
:keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
2427
:keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is
2528
evaluated.
29+
:keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
30+
context if one isn't provided.
2631
"""
2732

2833
def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
@@ -56,7 +61,7 @@ def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> bool:
5661
:return: True if the feature flag is enabled for the given context.
5762
:rtype: bool
5863
"""
59-
targeting_context = self._build_targeting_context(args)
64+
targeting_context: TargetingContext = self._build_targeting_context(args)
6065

6166
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
6267
if (
@@ -89,7 +94,7 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option
8994
:return: Variant instance.
9095
:rtype: Variant
9196
"""
92-
targeting_context = self._build_targeting_context(args)
97+
targeting_context: TargetingContext = self._build_targeting_context(args)
9398

9499
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
95100
if (
@@ -102,6 +107,21 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option
102107
self._on_feature_evaluated(result)
103108
return result.variant
104109

110+
def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
111+
targeting_context = super()._build_targeting_context(args)
112+
if targeting_context:
113+
return targeting_context
114+
if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):
115+
targeting_context = self._targeting_context_accessor()
116+
if targeting_context and isinstance(targeting_context, TargetingContext):
117+
return targeting_context
118+
logger.warning(
119+
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
120+
type(targeting_context),
121+
)
122+
123+
return TargetingContext()
124+
105125
def _check_feature_filters(
106126
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
107127
) -> None:

featuremanagement/_featuremanagerbase.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import hashlib
77
import logging
88
from abc import ABC
9-
from typing import List, Optional, Dict, Tuple, Any, Mapping
9+
from typing import List, Optional, Dict, Tuple, Any, Mapping, Callable
1010
from ._models import FeatureFlag, Variant, VariantAssignmentReason, TargetingContext, EvaluationEvent, VariantReference
1111

1212

@@ -21,6 +21,9 @@
2121
FEATURE_FILTER_PARAMETERS = "parameters"
2222

2323

24+
logger = logging.getLogger(__name__)
25+
26+
2427
def _get_feature_flag(configuration: Mapping[str, Any], feature_flag_name: str) -> Optional[FeatureFlag]:
2528
"""
2629
Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
@@ -77,6 +80,9 @@ def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
7780
self._cache: Dict[str, Optional[FeatureFlag]] = {}
7881
self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
7982
self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None)
83+
self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop(
84+
"targeting_context_accessor", None
85+
)
8086

8187
@staticmethod
8288
def _assign_default_disabled_variant(evaluation_event: EvaluationEvent) -> None:
@@ -218,7 +224,7 @@ def _variant_name_to_variant(self, feature_flag: FeatureFlag, variant_name: Opti
218224
return Variant(variant_reference.name, variant_reference.configuration_value)
219225
return None
220226

221-
def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
227+
def _build_targeting_context(self, args: Tuple[Any]) -> Optional[TargetingContext]:
222228
"""
223229
Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
224230
returns an empty context.
@@ -229,10 +235,12 @@ def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
229235
if len(args) == 1:
230236
arg = args[0]
231237
if isinstance(arg, str):
238+
# If the user_id is provided, return a TargetingContext with the user_id
232239
return TargetingContext(user_id=arg, groups=[])
233240
if isinstance(arg, TargetingContext):
241+
# If a TargetingContext is provided, return it
234242
return arg
235-
return TargetingContext()
243+
return None
236244

237245
def _assign_allocation(self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext) -> None:
238246
feature_flag = evaluation_event.feature
@@ -271,7 +279,7 @@ def _check_feature_base(self, feature_flag_id: str) -> Tuple[EvaluationEvent, bo
271279

272280
evaluation_event = EvaluationEvent(feature_flag)
273281
if not feature_flag:
274-
logging.warning("Feature flag %s not found", feature_flag_id)
282+
logger.warning("Feature flag %s not found", feature_flag_id)
275283
# Unknown feature flags are disabled by default
276284
return evaluation_event, True
277285

featuremanagement/aio/_featuremanager.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
# license information.
55
# -------------------------------------------------------------------------
66
import inspect
7-
from typing import cast, overload, Any, Optional, Dict, Mapping, List
7+
import logging
8+
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
89
from ._defaultfilters import TimeWindowFilter, TargetingFilter
910
from ._featurefilters import FeatureFilter
1011
from .._models import EvaluationEvent, Variant, TargetingContext
@@ -15,6 +16,8 @@
1516
FEATURE_FILTER_NAME,
1617
)
1718

19+
logger = logging.getLogger(__name__)
20+
1821

1922
class FeatureManager(FeatureManagerBase):
2023
"""
@@ -24,6 +27,8 @@ class FeatureManager(FeatureManagerBase):
2427
:keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
2528
:keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is
2629
evaluated.
30+
:keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
31+
context if one isn't provided.
2732
"""
2833

2934
def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
@@ -57,7 +62,7 @@ async def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> b
5762
:return: True if the feature flag is enabled for the given context.
5863
:rtype: bool
5964
"""
60-
targeting_context = self._build_targeting_context(args)
65+
targeting_context: TargetingContext = await self._build_targeting_context_async(args)
6166

6267
result = await self._check_feature(feature_flag_id, targeting_context, **kwargs)
6368
if (
@@ -93,7 +98,7 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) ->
9398
:return: Variant instance.
9499
:rtype: Variant
95100
"""
96-
targeting_context = self._build_targeting_context(args)
101+
targeting_context: TargetingContext = await self._build_targeting_context_async(args)
97102

98103
result = await self._check_feature(feature_flag_id, targeting_context, **kwargs)
99104
if (
@@ -109,6 +114,25 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) ->
109114
self._on_feature_evaluated(result)
110115
return result.variant
111116

117+
async def _build_targeting_context_async(self, args: Tuple[Any]) -> TargetingContext:
118+
targeting_context = super()._build_targeting_context(args)
119+
if targeting_context:
120+
return targeting_context
121+
if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):
122+
123+
if inspect.iscoroutinefunction(self._targeting_context_accessor):
124+
# If a targeting_context_accessor is provided, return the TargetingContext from it
125+
targeting_context = await self._targeting_context_accessor()
126+
else:
127+
targeting_context = self._targeting_context_accessor()
128+
if targeting_context and isinstance(targeting_context, TargetingContext):
129+
return targeting_context
130+
logger.warning(
131+
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
132+
type(targeting_context),
133+
)
134+
return TargetingContext()
135+
112136
async def _check_feature_filters(
113137
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
114138
) -> None:

featuremanagement/azuremonitor/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
# Licensed under the MIT License. See License.txt in the project root for
44
# license information.
55
# -------------------------------------------------------------------------
6-
from ._send_telemetry import publish_telemetry, track_event
6+
from ._send_telemetry import publish_telemetry, track_event, TargetingSpanProcessor
77

88

99
__all__ = [
1010
"publish_telemetry",
1111
"track_event",
12+
"TargetingSpanProcessor",
1213
]

featuremanagement/azuremonitor/_send_telemetry.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,38 @@
44
# license information.
55
# --------------------------------------------------------------------------
66
import logging
7-
from typing import Dict, Optional
8-
from .._models import EvaluationEvent
7+
import inspect
8+
from typing import Any, Callable, Dict, Optional
9+
from .._models import VariantAssignmentReason, EvaluationEvent, TargetingContext
10+
11+
logger = logging.getLogger(__name__)
912

1013
try:
1114
from azure.monitor.events.extension import track_event as azure_monitor_track_event # type: ignore
15+
from opentelemetry.context.context import Context
16+
from opentelemetry.sdk.trace import Span, SpanProcessor
1217

1318
HAS_AZURE_MONITOR_EVENTS_EXTENSION = True
1419
except ImportError:
1520
HAS_AZURE_MONITOR_EVENTS_EXTENSION = False
16-
logging.warning(
21+
logger.warning(
1722
"azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights."
1823
)
24+
SpanProcessor = object # type: ignore
25+
Span = object # type: ignore
26+
Context = object # type: ignore
1927

2028
FEATURE_NAME = "FeatureName"
2129
ENABLED = "Enabled"
2230
TARGETING_ID = "TargetingId"
2331
VARIANT = "Variant"
2432
REASON = "VariantAssignmentReason"
2533

34+
DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
35+
VERSION = "Version"
36+
VARIANT_ASSIGNMENT_PERCENTAGE = "VariantAssignmentPercentage"
37+
MICROSOFT_TARGETING_ID = "Microsoft.TargetingId"
38+
2639
EVENT_NAME = "FeatureEvaluation"
2740

2841
EVALUATION_EVENT_VERSION = "1.0.0"
@@ -64,7 +77,7 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None:
6477
event: Dict[str, Optional[str]] = {
6578
FEATURE_NAME: feature.name,
6679
ENABLED: str(evaluation_event.enabled),
67-
"Version": EVALUATION_EVENT_VERSION,
80+
VERSION: EVALUATION_EVENT_VERSION,
6881
}
6982

7083
reason = evaluation_event.reason
@@ -75,9 +88,67 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None:
7588
if variant:
7689
event[VARIANT] = variant.name
7790

91+
# VariantAllocationPercentage
92+
allocation_percentage = 0
93+
if reason == VariantAssignmentReason.DEFAULT_WHEN_ENABLED:
94+
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100)
95+
if feature.allocation:
96+
for allocation in feature.allocation.percentile:
97+
allocation_percentage += allocation.percentile_to - allocation.percentile_from
98+
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100 - allocation_percentage)
99+
elif reason == VariantAssignmentReason.PERCENTILE:
100+
if feature.allocation and feature.allocation.percentile:
101+
for allocation in feature.allocation.percentile:
102+
if variant and allocation.variant == variant.name:
103+
allocation_percentage += allocation.percentile_to - allocation.percentile_from
104+
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(allocation_percentage)
105+
106+
# DefaultWhenEnabled
107+
if feature.allocation and feature.allocation.default_when_enabled:
108+
event[DEFAULT_WHEN_ENABLED] = feature.allocation.default_when_enabled
109+
78110
if feature.telemetry:
79111
for metadata_key, metadata_value in feature.telemetry.metadata.items():
80112
if metadata_key not in event:
81113
event[metadata_key] = metadata_value
82114

83115
track_event(EVENT_NAME, evaluation_event.user, event_properties=event)
116+
117+
118+
class TargetingSpanProcessor(SpanProcessor):
119+
"""
120+
A custom SpanProcessor that attaches the targeting ID to the span and baggage when a new span is started.
121+
:keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
122+
context if one isn't provided.
123+
"""
124+
125+
def __init__(self, **kwargs: Any) -> None:
126+
self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop(
127+
"targeting_context_accessor", None
128+
)
129+
130+
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
131+
"""
132+
Attaches the targeting ID to the span and baggage when a new span is started.
133+
134+
:param Span span: The span that was started.
135+
:param parent_context: The parent context of the span.
136+
"""
137+
if not HAS_AZURE_MONITOR_EVENTS_EXTENSION:
138+
logger.warning("Azure Monitor Events Extension is not installed.")
139+
return
140+
if self._targeting_context_accessor and callable(self._targeting_context_accessor):
141+
if inspect.iscoroutinefunction(self._targeting_context_accessor):
142+
logger.warning("Async targeting_context_accessor is not supported.")
143+
return
144+
targeting_context = self._targeting_context_accessor()
145+
if not targeting_context or not isinstance(targeting_context, TargetingContext):
146+
logger.warning(
147+
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
148+
type(targeting_context),
149+
)
150+
return
151+
if not targeting_context.user_id:
152+
logger.debug("TargetingContext does not have a user ID.")
153+
return
154+
span.set_attribute(TARGETING_ID, targeting_context.user_id)

project-words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ featuremanagerbase
1212
quickstart
1313
rtype
1414
usefixtures
15+
urandom
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
7+
import json
8+
import os
9+
import sys
10+
from random_filter import RandomFilter
11+
from featuremanagement import FeatureManager, TargetingContext
12+
13+
14+
script_directory = os.path.dirname(os.path.abspath(sys.argv[0]))
15+
16+
with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f:
17+
feature_flags = json.load(f)
18+
19+
USER_ID = "Adam"
20+
21+
22+
def my_targeting_accessor() -> TargetingContext:
23+
return TargetingContext(user_id=USER_ID)
24+
25+
26+
feature_manager = FeatureManager(
27+
feature_flags, feature_filters=[RandomFilter()], targeting_context_accessor=my_targeting_accessor
28+
)
29+
30+
print(feature_manager.is_enabled("TestVariants"))
31+
print(feature_manager.get_variant("TestVariants").configuration)
32+
33+
USER_ID = "Ellie"
34+
35+
print(feature_manager.is_enabled("TestVariants"))
36+
print(feature_manager.get_variant("TestVariants").configuration)

samples/formatted_feature_flags.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
},
219219
{
220220
"name": "False_Override",
221+
"configuration_value": "The Variant False_Override overrides to True",
221222
"status_override": "True"
222223
}
223224
]

0 commit comments

Comments
 (0)