Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions dev_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ sphinx
sphinx_rtd_theme
sphinx-toolbox
myst_parser
opentelemetry-api
opentelemetry-sdk
26 changes: 23 additions & 3 deletions featuremanagement/_featuremanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------
from typing import cast, overload, Any, Optional, Dict, Mapping, List
import logging
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
from ._defaultfilters import TimeWindowFilter, TargetingFilter
from ._featurefilters import FeatureFilter
from ._models import EvaluationEvent, Variant, TargetingContext
Expand All @@ -14,6 +15,8 @@
FEATURE_FILTER_NAME,
)

logger = logging.getLogger(__name__)


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

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

result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
if (
Expand Down Expand Up @@ -89,7 +94,7 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option
:return: Variant instance.
:rtype: Variant
"""
targeting_context = self._build_targeting_context(args)
targeting_context: TargetingContext = self._build_targeting_context(args)

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

def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
targeting_context = super()._build_targeting_context(args)
if targeting_context:
return targeting_context
if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):
targeting_context = self._targeting_context_accessor()
if targeting_context and isinstance(targeting_context, TargetingContext):
return targeting_context
logger.warning(
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
type(targeting_context),
)

return TargetingContext()

def _check_feature_filters(
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
) -> None:
Expand Down
16 changes: 12 additions & 4 deletions featuremanagement/_featuremanagerbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import hashlib
import logging
from abc import ABC
from typing import List, Optional, Dict, Tuple, Any, Mapping
from typing import List, Optional, Dict, Tuple, Any, Mapping, Callable
from ._models import FeatureFlag, Variant, VariantAssignmentReason, TargetingContext, EvaluationEvent, VariantReference


Expand All @@ -21,6 +21,9 @@
FEATURE_FILTER_PARAMETERS = "parameters"


logger = logging.getLogger(__name__)


def _get_feature_flag(configuration: Mapping[str, Any], feature_flag_name: str) -> Optional[FeatureFlag]:
"""
Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
Expand Down Expand Up @@ -77,6 +80,9 @@ def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
self._cache: Dict[str, Optional[FeatureFlag]] = {}
self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None)
self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop(
"targeting_context_accessor", None
)

@staticmethod
def _assign_default_disabled_variant(evaluation_event: EvaluationEvent) -> None:
Expand Down Expand Up @@ -218,7 +224,7 @@ def _variant_name_to_variant(self, feature_flag: FeatureFlag, variant_name: Opti
return Variant(variant_reference.name, variant_reference.configuration_value)
return None

def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
def _build_targeting_context(self, args: Tuple[Any]) -> Optional[TargetingContext]:
Copy link

Copilot AI Apr 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the return type from TargetingContext to Optional[TargetingContext] may lead to unexpected None values in downstream calls. Consider returning an empty TargetingContext() as a fallback to ensure consistency.

Copilot uses AI. Check for mistakes.
"""
Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
returns an empty context.
Expand All @@ -229,10 +235,12 @@ def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
if len(args) == 1:
arg = args[0]
if isinstance(arg, str):
# If the user_id is provided, return a TargetingContext with the user_id
return TargetingContext(user_id=arg, groups=[])
if isinstance(arg, TargetingContext):
# If a TargetingContext is provided, return it
return arg
return TargetingContext()
return None

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

evaluation_event = EvaluationEvent(feature_flag)
if not feature_flag:
logging.warning("Feature flag %s not found", feature_flag_id)
logger.warning("Feature flag %s not found", feature_flag_id)
# Unknown feature flags are disabled by default
return evaluation_event, True

Expand Down
2 changes: 1 addition & 1 deletion featuremanagement/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
# license information.
# -------------------------------------------------------------------------

VERSION = "2.1.0"
VERSION = "2.2.0b1"
30 changes: 27 additions & 3 deletions featuremanagement/aio/_featuremanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# license information.
# -------------------------------------------------------------------------
import inspect
from typing import cast, overload, Any, Optional, Dict, Mapping, List
import logging
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
from ._defaultfilters import TimeWindowFilter, TargetingFilter
from ._featurefilters import FeatureFilter
from .._models import EvaluationEvent, Variant, TargetingContext
Expand All @@ -15,6 +16,8 @@
FEATURE_FILTER_NAME,
)

logger = logging.getLogger(__name__)


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

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

result = await self._check_feature(feature_flag_id, targeting_context, **kwargs)
if (
Expand Down Expand Up @@ -93,7 +98,7 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) ->
:return: Variant instance.
:rtype: Variant
"""
targeting_context = self._build_targeting_context(args)
targeting_context: TargetingContext = await self._build_targeting_context_async(args)

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

async def _build_targeting_context_async(self, args: Tuple[Any]) -> TargetingContext:
targeting_context = super()._build_targeting_context(args)
if targeting_context:
return targeting_context
if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):

if inspect.iscoroutinefunction(self._targeting_context_accessor):
# If a targeting_context_accessor is provided, return the TargetingContext from it
targeting_context = await self._targeting_context_accessor()
else:
targeting_context = self._targeting_context_accessor()
if targeting_context and isinstance(targeting_context, TargetingContext):
return targeting_context
logger.warning(
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
type(targeting_context),
)
return TargetingContext()

async def _check_feature_filters(
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
) -> None:
Expand Down
3 changes: 2 additions & 1 deletion featuremanagement/azuremonitor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------
from ._send_telemetry import publish_telemetry, track_event
from ._send_telemetry import publish_telemetry, track_event, TargetingSpanProcessor


__all__ = [
"publish_telemetry",
"track_event",
"TargetingSpanProcessor",
]
80 changes: 76 additions & 4 deletions featuremanagement/azuremonitor/_send_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,39 @@
# license information.
# --------------------------------------------------------------------------
import logging
from typing import Dict, Optional
from .._models import EvaluationEvent
import inspect
from typing import Any, Callable, Dict, Optional
from .._models import VariantAssignmentReason, EvaluationEvent, TargetingContext

logger = logging.getLogger(__name__)

try:
from azure.monitor.events.extension import track_event as azure_monitor_track_event # type: ignore
from opentelemetry.context.context import Context
from opentelemetry.sdk.trace import Span, SpanProcessor

HAS_AZURE_MONITOR_EVENTS_EXTENSION = True
except ImportError:
HAS_AZURE_MONITOR_EVENTS_EXTENSION = False
logging.warning(
logger.warning(
"azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights."
)
SpanProcessor = object # type: ignore
Span = object # type: ignore
Context = object # type: ignore

FEATURE_NAME = "FeatureName"
ENABLED = "Enabled"
TARGETING_ID = "TargetingId"
VARIANT = "Variant"
REASON = "VariantAssignmentReason"

DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
VERSION = "Version"
VARIANT_ASSIGNMENT_PERCENTAGE = "VariantAssignmentPercentage"
MICROSOFT_TARGETING_ID = "Microsoft.TargetingId"
SPAN = "Span"

EVENT_NAME = "FeatureEvaluation"

EVALUATION_EVENT_VERSION = "1.0.0"
Expand Down Expand Up @@ -64,7 +78,7 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None:
event: Dict[str, Optional[str]] = {
FEATURE_NAME: feature.name,
ENABLED: str(evaluation_event.enabled),
"Version": EVALUATION_EVENT_VERSION,
VERSION: EVALUATION_EVENT_VERSION,
}

reason = evaluation_event.reason
Expand All @@ -75,9 +89,67 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None:
if variant:
event[VARIANT] = variant.name

# VariantAllocationPercentage
allocation_percentage = 0
if reason == VariantAssignmentReason.DEFAULT_WHEN_ENABLED:
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100)
if feature.allocation:
for allocation in feature.allocation.percentile:
allocation_percentage += allocation.percentile_to - allocation.percentile_from
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100 - allocation_percentage)
elif reason == VariantAssignmentReason.PERCENTILE:
if feature.allocation and feature.allocation.percentile:
for allocation in feature.allocation.percentile:
if variant and allocation.variant == variant.name:
allocation_percentage += allocation.percentile_to - allocation.percentile_from
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(allocation_percentage)

# DefaultWhenEnabled
if feature.allocation and feature.allocation.default_when_enabled:
event[DEFAULT_WHEN_ENABLED] = feature.allocation.default_when_enabled

if feature.telemetry:
for metadata_key, metadata_value in feature.telemetry.metadata.items():
if metadata_key not in event:
event[metadata_key] = metadata_value

track_event(EVENT_NAME, evaluation_event.user, event_properties=event)


class TargetingSpanProcessor(SpanProcessor):
"""
A custom SpanProcessor that attaches the targeting ID to the span and baggage when a new span is started.
:keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
context if one isn't provided.
"""

def __init__(self, **kwargs: Any) -> None:
self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop(
"targeting_context_accessor", None
)

def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
"""
Attaches the targeting ID to the span and baggage when a new span is started.

:param Span span: The span that was started.
:param parent_context: The parent context of the span.
"""
if not HAS_AZURE_MONITOR_EVENTS_EXTENSION:
logger.warning("Azure Monitor Events Extension is not installed.")
return
if self._targeting_context_accessor and callable(self._targeting_context_accessor):
if inspect.iscoroutinefunction(self._targeting_context_accessor):
logger.warning("Async targeting_context_accessor is not supported.")
return
targeting_context = self._targeting_context_accessor()
if not targeting_context or not isinstance(targeting_context, TargetingContext):
logger.warning(
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
type(targeting_context),
)
return
if not targeting_context.user_id:
logger.debug("TargetingContext does not have a user ID.")
return
span.set_attribute(TARGETING_ID, targeting_context.user_id)
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ featuremanagerbase
quickstart
rtype
usefixtures
urandom
36 changes: 36 additions & 0 deletions samples/feature_variant_sample_with_targeting_accessor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------

import json
import os
import sys
from random_filter import RandomFilter
from featuremanagement import FeatureManager, TargetingContext


script_directory = os.path.dirname(os.path.abspath(sys.argv[0]))

with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f:
feature_flags = json.load(f)

USER_ID = "Adam"


def my_targeting_accessor() -> TargetingContext:
return TargetingContext(user_id=USER_ID)


feature_manager = FeatureManager(
feature_flags, feature_filters=[RandomFilter()], targeting_context_accessor=my_targeting_accessor
)

print(feature_manager.is_enabled("TestVariants"))
print(feature_manager.get_variant("TestVariants").configuration)

USER_ID = "Ellie"

print(feature_manager.is_enabled("TestVariants"))
print(feature_manager.get_variant("TestVariants").configuration)
1 change: 1 addition & 0 deletions samples/formatted_feature_flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@
},
{
"name": "False_Override",
"configuration_value": "The Variant False_Override overrides to True",
"status_override": "True"
}
]
Expand Down
Loading